From 59272ae1ccfafd9ccc80edba8b0a0776d55c703b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 14 Sep 2026 18:40:37 +0800 Subject: [PATCH 1/3] fix(android): keep system bar icons light and fix bottom nav insets Capacitor's SystemBars plugin re-applies its default style on every configuration change, which turned status bar icons dark in light system theme. Set SystemBars and StatusBar to DARK in config and at startup. Disable the Android nav bar contrast scrim and use the app background as the window background so the bottom nav shows behind the 3-button bar without a white strip or startup flash. Use env() safe area insets on Android, which SystemBars keeps accurate for the keyboard and rotation, removing the gap above the keyboard. --- android/app/src/main/res/values/colors.xml | 4 ++ android/app/src/main/res/values/styles.xml | 5 +- capacitor.config.ts | 4 ++ docs/capacitor.md | 4 +- src/App.tsx | 9 ++++ src/__tests__/unit/App.firebase-auth.test.tsx | 6 +++ src/__tests__/unit/App.integration.test.tsx | 46 +++++++++++++++++++ src/__tests__/unit/lib/safeArea.test.ts | 19 ++++++++ src/lib/safeArea.ts | 9 ++-- 9 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/res/values/colors.xml diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..cc87f250 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #111928 + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 7b5def12..17ce2a7f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ - + diff --git a/capacitor.config.ts b/capacitor.config.ts index 452e06a2..cdb1fd84 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -44,6 +44,10 @@ const config: CapacitorConfig = { }, StatusBar: { overlaysWebView: true, + style: "DARK", + }, + SystemBars: { + style: "DARK", }, LiveUpdate: { appId: "e96c260c-3271-4895-bff0-de9f8ca4d05a", diff --git a/docs/capacitor.md b/docs/capacitor.md index 8cd7be00..9baa7af3 100644 --- a/docs/capacitor.md +++ b/docs/capacitor.md @@ -295,7 +295,9 @@ const safeInsets = useStatusStore((state) => state.safeInsets); ``` -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, and from CSS `env(safe-area-inset-*)` on Android and web. On Android, Capacitor's `SystemBars` plugin keeps `env()` accurate when the keyboard opens or the device rotates. The plugin's raw window insets ignore the keyboard, which left a gap above it. + +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. --- diff --git a/src/App.tsx b/src/App.tsx index 017868cb..584f63d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -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 diff --git a/src/__tests__/unit/App.firebase-auth.test.tsx b/src/__tests__/unit/App.firebase-auth.test.tsx index 0fc6fd7c..626f94b5 100644 --- a/src/__tests__/unit/App.firebase-auth.test.tsx +++ b/src/__tests__/unit/App.firebase-auth.test.tsx @@ -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", () => ({ diff --git a/src/__tests__/unit/App.integration.test.tsx b/src/__tests__/unit/App.integration.test.tsx index 741fac12..f25af443 100644 --- a/src/__tests__/unit/App.integration.test.tsx +++ b/src/__tests__/unit/App.integration.test.tsx @@ -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", () => ({ @@ -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(); + + 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(); + + 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"); @@ -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(); + + await waitFor(() => { + expect(logger.warn).toHaveBeenCalledWith( + "SystemBars setup failed:", + error, + ); + }); + }); }); diff --git a/src/__tests__/unit/lib/safeArea.test.ts b/src/__tests__/unit/lib/safeArea.test.ts index 7129d9fa..2d3b5eb2 100644 --- a/src/__tests__/unit/lib/safeArea.test.ts +++ b/src/__tests__/unit/lib/safeArea.test.ts @@ -52,6 +52,25 @@ describe("safeArea", () => { expect(SafeArea.getSafeAreaInsets).not.toHaveBeenCalled(); }); + it("should use env() values 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: "env(safe-area-inset-top, 0px)", + bottom: "env(safe-area-inset-bottom, 0px)", + left: "env(safe-area-inset-left, 0px)", + 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"); diff --git a/src/lib/safeArea.ts b/src/lib/safeArea.ts index eba28d57..dc98295b 100644 --- a/src/lib/safeArea.ts +++ b/src/lib/safeArea.ts @@ -18,7 +18,7 @@ export const defaultSafeAreaInsets: SafeAreaInsets = { right: "0px", }; -const webInsets = { +const envInsets = { top: "env(safe-area-inset-top, 0px)", bottom: "env(safe-area-inset-bottom, 0px)", left: "env(safe-area-inset-left, 0px)", @@ -44,8 +44,11 @@ export const initSafeAreaInsets = async ( listen = true, isActive: () => boolean = () => true, ): Promise => { - if (!Capacitor.isNativePlatform()) { - if (isActive()) setInsets(webInsets); + // On Android, Capacitor's SystemBars plugin keeps env() 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.isNativePlatform() || Capacitor.getPlatform() === "android") { + if (isActive()) setInsets(envInsets); return null; } From 6e83a1758fec84dafead5f9d138ba60544a64bac Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 15 Sep 2026 07:26:41 +0800 Subject: [PATCH 2/3] fix(android): prefer SystemBars inset variables over env() Android WebView before 140 reports wrong env(safe-area-inset-*) values. SystemBars injects --safe-area-inset-* for those, so use them first and fall back to env() when they are unset. --- docs/capacitor.md | 2 +- src/__tests__/unit/lib/safeArea.test.ts | 11 ++++++----- src/lib/safeArea.ts | 23 +++++++++++++++++++---- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/capacitor.md b/docs/capacitor.md index 9baa7af3..9484fccd 100644 --- a/docs/capacitor.md +++ b/docs/capacitor.md @@ -295,7 +295,7 @@ const safeInsets = useStatusStore((state) => state.safeInsets); ``` -Safe area insets are populated on app initialization: from `capacitor-plugin-safe-area` on iOS, and from CSS `env(safe-area-inset-*)` on Android and web. On Android, Capacitor's `SystemBars` plugin keeps `env()` accurate when the keyboard opens or the device rotates. The plugin's raw window insets ignore the keyboard, which left a gap above it. +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. diff --git a/src/__tests__/unit/lib/safeArea.test.ts b/src/__tests__/unit/lib/safeArea.test.ts index 2d3b5eb2..e245ae9c 100644 --- a/src/__tests__/unit/lib/safeArea.test.ts +++ b/src/__tests__/unit/lib/safeArea.test.ts @@ -52,7 +52,7 @@ describe("safeArea", () => { expect(SafeArea.getSafeAreaInsets).not.toHaveBeenCalled(); }); - it("should use env() values on Android so keyboard insets apply", async () => { + 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"); @@ -62,10 +62,11 @@ describe("safeArea", () => { await expect(initSafeAreaInsets(setInsets)).resolves.toBeNull(); expect(setInsets).toHaveBeenCalledWith({ - top: "env(safe-area-inset-top, 0px)", - bottom: "env(safe-area-inset-bottom, 0px)", - left: "env(safe-area-inset-left, 0px)", - right: "env(safe-area-inset-right, 0px)", + 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(); diff --git a/src/lib/safeArea.ts b/src/lib/safeArea.ts index dc98295b..d553e289 100644 --- a/src/lib/safeArea.ts +++ b/src/lib/safeArea.ts @@ -18,13 +18,23 @@ export const defaultSafeAreaInsets: SafeAreaInsets = { right: "0px", }; -const envInsets = { +const webInsets = { top: "env(safe-area-inset-top, 0px)", bottom: "env(safe-area-inset-bottom, 0px)", left: "env(safe-area-inset-left, 0px)", 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 { @@ -44,11 +54,16 @@ export const initSafeAreaInsets = async ( listen = true, isActive: () => boolean = () => true, ): Promise => { - // On Android, Capacitor's SystemBars plugin keeps env() in sync with the + if (!Capacitor.isNativePlatform()) { + if (isActive()) setInsets(webInsets); + 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.isNativePlatform() || Capacitor.getPlatform() === "android") { - if (isActive()) setInsets(envInsets); + if (Capacitor.getPlatform() === "android") { + if (isActive()) setInsets(androidInsets); return null; } From 8800520b7844f166997bea711a1c13cfc4c3303b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 15 Sep 2026 08:04:35 +0800 Subject: [PATCH 3/3] fix: stop capacitor-zeroconf logging an unhandled rejection at startup The zeroconf web fallback created one rejected promise when its module loaded. Vite bundles the fallback into the vendor chunk, so every platform logged an unhandled rejection on startup. Patch it to reject per call instead. --- docs/capacitor.md | 7 ++- patches/capacitor-zeroconf+4.0.0.patch | 50 ++++++++++++++++++++++ src/__tests__/unit/lib/zeroconfWeb.test.ts | 27 ++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/unit/lib/zeroconfWeb.test.ts diff --git a/docs/capacitor.md b/docs/capacitor.md index 9484fccd..56e8f28d 100644 --- a/docs/capacitor.md +++ b/docs/capacitor.md @@ -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. diff --git a/patches/capacitor-zeroconf+4.0.0.patch b/patches/capacitor-zeroconf+4.0.0.patch index 60ddbd8d..1149ffc1 100644 --- a/patches/capacitor-zeroconf+4.0.0.patch +++ b/patches/capacitor-zeroconf+4.0.0.patch @@ -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 diff --git a/src/__tests__/unit/lib/zeroconfWeb.test.ts b/src/__tests__/unit/lib/zeroconfWeb.test.ts new file mode 100644 index 00000000..e38bfe1b --- /dev/null +++ b/src/__tests__/unit/lib/zeroconfWeb.test.ts @@ -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); + } + }); +});