Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="app_background">#111928</color>
</resources>
5 changes: 4 additions & 1 deletion android/app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
Expand All @@ -20,6 +20,9 @@
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowBackground">@color/app_background</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item>
</style>


Expand Down
4 changes: 4 additions & 0 deletions capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const config: CapacitorConfig = {
},
StatusBar: {
overlaysWebView: true,
style: "DARK",
},
SystemBars: {
style: "DARK",
},
LiveUpdate: {
appId: "e96c260c-3271-4895-bff0-de9f8ca4d05a",
Expand Down
11 changes: 8 additions & 3 deletions docs/capacitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ const platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
| `capacitor-plugin-safe-area` | Safe area insets for notched devices |
| `capacitor-zeroconf` | Zeroconf/Bonjour network discovery |

`capacitor-zeroconf` is patched with `patch-package` (`patches/capacitor-zeroconf+4.0.0.patch`, applied by the `postinstall` script). Upstream acquires the Android Wi-Fi multicast lock on the first watch and only releases it in `close()`, which the app never calls because `useNetworkScan` uses `unwatch()` to keep JmDNS warm. A held multicast lock disables the Wi-Fi chip's multicast filter, so the CPU wakes for every mDNS and broadcast frame for the rest of the process lifetime. The patch takes the lock per watch, releases it when the last watch is removed, and re-registers the listener on every `watch()` call so later scans receive events again. It changes native code, so it ships only with a store build. npm 12 blocks the tarball URL patch-package fetches, so regenerate the patch with:
`capacitor-zeroconf` is patched with `patch-package` (`patches/capacitor-zeroconf+4.0.0.patch`, applied by the `postinstall` script). Upstream acquires the Android Wi-Fi multicast lock on the first watch and only releases it in `close()`, which the app never calls because `useNetworkScan` uses `unwatch()` to keep JmDNS warm. A held multicast lock disables the Wi-Fi chip's multicast filter, so the CPU wakes for every mDNS and broadcast frame for the rest of the process lifetime. The patch takes the lock per watch, releases it when the last watch is removed, and re-registers the listener on every `watch()` call so later scans receive events again. The patch also makes the web fallback in `dist/esm/web.js` reject per call. Upstream creates one rejected promise when the module loads, and Vite bundles the fallback into the vendor chunk, so every platform logged an unhandled rejection at startup. The native part ships only with a store build. npm 12 blocks the tarball URL patch-package fetches, so regenerate the patch with:

```bash
npm_config_allow_remote=root npx patch-package capacitor-zeroconf --include 'android/src/main/java'
npm_config_allow_remote=root npx patch-package capacitor-zeroconf --include '^(android/src/main/java|dist/esm/web\.js)'
sed -i 's/\r$//' patches/capacitor-zeroconf+4.0.0.patch
```

The upstream `web.js` uses CRLF line endings, which Git would rewrite in the patch. patch-package ignores trailing whitespace when it applies the patch, so the LF patch still applies.

### Shake detection bridge

Shake-to-launch uses the app-local `ShakeDetector` plugin instead of a third-party package, because the previous plugin registered the Android accelerometer at game rate in `load()` and never released it.
Expand Down Expand Up @@ -295,7 +298,9 @@ const safeInsets = useStatusStore((state) => state.safeInsets);
</div>
```

Safe area insets are automatically populated by `capacitor-plugin-safe-area` on app initialization.
Safe area insets are populated on app initialization: from `capacitor-plugin-safe-area` on iOS, from CSS `env(safe-area-inset-*)` on web, and from `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` on Android. Capacitor's `SystemBars` plugin keeps these values accurate when the keyboard opens or the device rotates. The `capacitor-plugin-safe-area` raw window insets ignore the keyboard, which left a gap above it. Android WebView before 140 reports wrong `env()` values, so `SystemBars` injects the `--safe-area-inset-*` variables on Android 15 and newer. When a variable is unset, the `env()` value applies.

System bar icons stay light on the dark app background. Both `StatusBar` and Capacitor's core `SystemBars` plugin are set to `DARK` in `capacitor.config.ts` and again in `App.tsx`. `SystemBars` re-applies its own style on every Android configuration change (rotation, fold, theme), so setting only `StatusBar` is not enough. On Android the theme in `styles.xml` disables the system bar contrast scrim and sets a `#111928` window background, so the bottom nav background shows behind the navigation buttons.

---

Expand Down
50 changes: 50 additions & 0 deletions patches/capacitor-zeroconf+4.0.0.patch
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,53 @@ index 00ada2a..b0a6b14 100644

calls.clear();

diff --git a/node_modules/capacitor-zeroconf/dist/esm/web.js b/node_modules/capacitor-zeroconf/dist/esm/web.js
index 534f5e0..af07e9b 100644
--- a/node_modules/capacitor-zeroconf/dist/esm/web.js
+++ b/node_modules/capacitor-zeroconf/dist/esm/web.js
@@ -1,34 +1,36 @@
import { WebPlugin } from '@capacitor/core';
const errorString = 'The plugin is not available on this platform';
-const errorFn = Promise.reject(errorString);
+// Reject per call. A module-level rejected promise is unhandled as soon as a
+// bundler evaluates this file, even on native platforms that never use it.
+const errorFn = () => Promise.reject(errorString);
export class ZeroConfWeb extends WebPlugin {
getHostname() {
- return errorFn;
+ return errorFn();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
register(_request) {
- return errorFn;
+ return errorFn();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
unregister(_request) {
- return errorFn;
+ return errorFn();
}
stop() {
- return errorFn;
+ return errorFn();
}
watch(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_request,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_callback) {
- return errorFn;
+ return errorFn();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
unwatch(_request) {
- return errorFn;
+ return errorFn();
}
close() {
- return errorFn;
+ return errorFn();
}
}
//# sourceMappingURL=web.js.map
\ No newline at end of file
9 changes: 9 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import toast, { Toaster } from "react-hot-toast";
import { SystemBars, SystemBarsStyle } from "@capacitor/core";
import { StatusBar, Style } from "@capacitor/status-bar";
import { usePrevious } from "@uidotdev/usehooks";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -278,6 +279,14 @@ export default function App() {
logger.warn("StatusBar setup failed:", e);
});
}

// SystemBars re-applies its own style on every configuration change
// (rotation, fold, theme), which overrides StatusBar unless it is set too.
if (isNativePluginAvailable("SystemBars")) {
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch((e) => {
logger.warn("SystemBars setup failed:", e);
});
}
}, []);

// Check Pro access status once at app startup
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/unit/App.firebase-auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ vi.mock("@capacitor/core", () => ({
isPluginAvailable: mockIsPluginAvailable,
},
registerPlugin: vi.fn(),
SystemBars: {
setStyle: vi.fn(() => Promise.resolve()),
},
SystemBarsStyle: {
Dark: "DARK",
},
}));

vi.mock("@uidotdev/usehooks", () => ({
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/unit/App.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ vi.mock("@capacitor/core", () => ({
isPluginAvailable: vi.fn(() => true),
},
registerPlugin: vi.fn(),
SystemBars: {
setStyle: vi.fn(() => Promise.resolve()),
},
SystemBarsStyle: {
Dark: "DARK",
},
}));

vi.mock("@uidotdev/usehooks", () => ({
Expand Down Expand Up @@ -453,6 +459,29 @@ describe("App Integration", () => {
expect(StatusBar.setStyle).not.toHaveBeenCalled();
});

it("should keep system bar icons light when SystemBars is available", async () => {
const { Capacitor, SystemBars } = await import("@capacitor/core");

vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);

render(<App />);

expect(SystemBars.setStyle).toHaveBeenCalledWith({ style: "DARK" });
});

it("should skip SystemBars setup when native plugin is unavailable", async () => {
const { Capacitor, SystemBars } = await import("@capacitor/core");

vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
vi.mocked(isNativePluginAvailable).mockImplementation(
(pluginName: string) => pluginName !== "SystemBars",
);

render(<App />);

expect(SystemBars.setStyle).not.toHaveBeenCalled();
});

it("should log non-critical StatusBar setup failures", async () => {
const { Capacitor } = await import("@capacitor/core");
const { StatusBar } = await import("@capacitor/status-bar");
Expand All @@ -470,4 +499,21 @@ describe("App Integration", () => {
);
});
});

it("should log non-critical SystemBars setup failures", async () => {
const { Capacitor, SystemBars } = await import("@capacitor/core");
const error = new Error("SystemBars unavailable");

vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
vi.mocked(SystemBars.setStyle).mockRejectedValueOnce(error);

render(<App />);

await waitFor(() => {
expect(logger.warn).toHaveBeenCalledWith(
"SystemBars setup failed:",
error,
);
});
});
});
20 changes: 20 additions & 0 deletions src/__tests__/unit/lib/safeArea.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ describe("safeArea", () => {
expect(SafeArea.getSafeAreaInsets).not.toHaveBeenCalled();
});

it("should use SystemBars CSS insets on Android so keyboard insets apply", async () => {
vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
vi.mocked(Capacitor.getPlatform).mockReturnValue("android");

const { initSafeAreaInsets } = await import("@/lib/safeArea");
const setInsets = vi.fn();

await expect(initSafeAreaInsets(setInsets)).resolves.toBeNull();

expect(setInsets).toHaveBeenCalledWith({
top: "var(--safe-area-inset-top, env(safe-area-inset-top, 0px))",
bottom:
"var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
left: "var(--safe-area-inset-left, env(safe-area-inset-left, 0px))",
right: "var(--safe-area-inset-right, env(safe-area-inset-right, 0px))",
});
expect(SafeArea.getSafeAreaInsets).not.toHaveBeenCalled();
expect(SafeArea.addListener).not.toHaveBeenCalled();
});

it("should use SafeArea plugin values on native platform", async () => {
vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true);
vi.mocked(Capacitor.getPlatform).mockReturnValue("ios");
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/unit/lib/zeroconfWeb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";

// The web fallback extends the real WebPlugin class.
vi.unmock("@capacitor/core");

describe("capacitor-zeroconf web fallback", () => {
it("should reject calls without an unhandled rejection when loaded", async () => {
const onUnhandledRejection = vi.fn();
process.on("unhandledRejection", onUnhandledRejection);

try {
const { ZeroConfWeb } =
await import("capacitor-zeroconf/dist/esm/web.js");
await new Promise((resolve) => setImmediate(resolve));

expect(onUnhandledRejection).not.toHaveBeenCalled();
await expect(
new ZeroConfWeb().watch(
{ type: "_zaparoo._tcp.", domain: "local." },
vi.fn(),
),
).rejects.toBe("The plugin is not available on this platform");
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
});
18 changes: 18 additions & 0 deletions src/lib/safeArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ const webInsets = {
right: "env(safe-area-inset-right, 0px)",
};

// SystemBars injects --safe-area-inset-* because WebView before 140 reports
// wrong env() values. The variables are unset below Android 15, so env() is
// the fallback.
const androidInsets = {
top: "var(--safe-area-inset-top, env(safe-area-inset-top, 0px))",
bottom: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
left: "var(--safe-area-inset-left, env(safe-area-inset-left, 0px))",
right: "var(--safe-area-inset-right, env(safe-area-inset-right, 0px))",
};

async function removeSafeAreaListener(
handle: PluginListenerHandle,
): Promise<void> {
Expand All @@ -49,6 +59,14 @@ export const initSafeAreaInsets = async (
return null;
}

// On Android, Capacitor's SystemBars plugin keeps the insets in sync with the
// WebView's real overlap, including the keyboard and rotation. The SafeArea
// plugin reports raw window insets, which ignore the keyboard.
if (Capacitor.getPlatform() === "android") {
if (isActive()) setInsets(androidInsets);
return null;
}

try {
const { insets } = await SafeArea.getSafeAreaInsets();
if (!isActive()) return null;
Expand Down