From bb3ed744805c7558c727d304591465a48f409868 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 22 Jun 2026 16:07:06 +0200 Subject: [PATCH] feat(android): migrate edge-to-edge to built-in SystemBars (#8543) * feat(android): migrate edge-to-edge to built-in SystemBars Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35 tail is covered by env() fallback plus a native keyboard shim (adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does not fight SystemBars' own IME padding. - config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the plugin from the generated gradle registration - theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them; the SCSS env() fallback preserves the #8283 top fix); bars are now transparent with the theme color painted behind them via setWebViewBackgroundColor, since SystemBars has no bar-color API - StartupOverlayManager: derive the overlay inset from the system-bar insets instead of the removed plugin's WebView margin Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device behavior NOT yet validated (gradle unavailable in this environment). Requires `npm install` + `npx cap sync android` and the device matrix in docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge. * build(deps): drop @capawesome edge-to-edge from lockfile Reconcile package-lock.json with the package.json change in the edge-to-edge -> SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge- support entry). Generated by `npm install`. * docs(android): clarify SystemBars band model from implementation review Comment-only + plan-doc follow-up to the multi-agent review of the migration: - Correct the band model in the inset comments: SystemBars *injects* --safe-area-inset-* only on API >= 35; WebView >= 140 is native env() passthrough (no injection below API 35). The SCSS var(..., env()) fallback covers every band. (capacitor.config.ts, global-theme.service.ts) - Fix a stale comment in StartupOverlayManager.show() that still described the removed @capawesome plugin insetting the WebView. - Record the device-validation-only findings in the plan doc (API>=35/WebView<140 double-count corner, env vs var consumer split, API 30-34/WebView<140 IME owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed (a blind shim extension would risk re-creating #8508). No behavior change. --- android/app/capacitor.build.gradle | 1 - .../CapacitorMainActivity.kt | 57 ++-- .../widget/StartupOverlayManager.kt | 66 +++-- android/capacitor.settings.gradle | 3 - capacitor.config.ts | 30 +-- docs/android-edge-to-edge-keyboard.md | 12 + ...-android-systembars-migration-corrected.md | 247 ++++++++++++++++++ package-lock.json | 20 -- package.json | 1 - src/app/core/theme/global-theme.service.ts | 95 +++---- 10 files changed, 364 insertions(+), 168 deletions(-) create mode 100644 docs/plans/2026-06-22-android-systembars-migration-corrected.md diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 6f763cd639..433e7dfd0a 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -17,7 +17,6 @@ dependencies { implementation project(':capacitor-local-notifications') implementation project(':capacitor-share') implementation project(':capawesome-capacitor-android-dark-mode-support') - implementation project(':capawesome-capacitor-android-edge-to-edge-support') implementation project(':capawesome-capacitor-background-task') } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt index 7935ce6ace..44523e5a26 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt @@ -452,45 +452,40 @@ class CapacitorMainActivity : BridgeActivity() { } /** - * SDK < 30 soft-keyboard workaround for the add-task bar sitting behind the + * SDK < 30 soft-keyboard fallback for the add-task bar sitting behind the * keyboard (#8508 follow-up, Android 9 / API 28). * - * Root cause: the `@capawesome` edge-to-edge plugin sets the WebView - * `bottomMargin = 0` whenever the IME is visible (`EdgeToEdge.applyInsetsInternal`: - * "the system already resizes the window for the keyboard"), but under enforced - * edge-to-edge (targetSdk 36) the window does NOT resize on API < 30, so the - * WebView keeps its full height and the `position: fixed` bar sits behind the - * keyboard. + * Context: Android edge-to-edge inset handling is owned by Capacitor's + * built-in SystemBars now (the `@capawesome` edge-to-edge plugin was removed). + * SystemBars only pads the WebView for the IME on **WebView >= 140** + * (passthrough) or **API >= 35**; below that band it is a no-op, and under + * enforced edge-to-edge the window does NOT resize for the IME on API < 30, + * so the `position: fixed` add-task bar sits behind the keyboard. This shim + * covers exactly that WebView < 140 / API < 30 tail and is **gated to + * WebView < 140** so it never double-counts against SystemBars' own padding. * - * We must NOT correct this via `bottomMargin`: the plugin owns that property and - * rewrites it on every inset dispatch, so a second writer just flickers - * (confirmed on an API 28 device — the margin alternated `0 ↔ lift`). WebView - * *padding* does not move the web layout viewport either. - * - * Instead, while the keyboard is up we set an explicit WebView **layout height** - * (to the keyboard top), and restore the resting height ([webViewLayoutHeightDefault], - * e.g. MATCH_PARENT) on hide. Height is a different property than the margin the - * plugin manages, and for an explicit-height view the bottom margin does not - * change the view's size — so the two never fight, and the plugin keeps doing - * everything else (system-bar insets AND its color overlays, so no white navbar - * gap). Shrinking the view shrinks the web layout viewport, so the existing CSS - * resolves the bar above the keyboard with no web-side keyboard-height math - * (avoiding the reverted #8295 fallback). The target (`rect.bottom − webViewTop`) - * is read from `getWindowVisibleDisplayFrame` (reliable on API 28) and does not + * We must NOT correct this via `bottomMargin` or `padding` (a margin writer + * fights whatever owns the insets, and WebView padding does not move the web + * layout viewport). Instead, while the keyboard is up we set an explicit + * WebView **layout height** (to the keyboard top) and restore the resting + * height ([webViewLayoutHeightDefault], e.g. MATCH_PARENT) on hide. Shrinking + * the view shrinks the web layout viewport, so the existing CSS resolves the + * bar above the keyboard with no web-side keyboard-height math (avoiding the + * reverted #8295 fallback). The target (`rect.bottom − webViewTop`) is read + * from `getWindowVisibleDisplayFrame` (reliable on API 28) and does not * depend on the WebView's own height, so it is stable across passes — no - * feedback loop. See docs/android-edge-to-edge-keyboard.md. + * feedback loop. See docs/android-edge-to-edge-keyboard.md and + * docs/plans/2026-06-22-android-systembars-migration-corrected.md. * - * API >= 30 is a strict no-op — the plugin stays fully in charge, so the - * behavior verified in 18.12.0 is unchanged. - * - * NOTE: [StartupOverlayManager.updateOverlayInsets] also reads the WebView's - * geometry (`webView.height`) to align the native startup overlay. It only - * stays correct because it early-returns once its input bar is visible (the - * keyboard phase) — i.e. it never reads the height while this method is - * shrinking it. Keep that guard if you touch either side. + * API >= 30 and WebView >= 140 are strict no-ops. */ private fun adjustWebViewHeightForKeyboardBelowApi30(rect: Rect, isKeyboardOpen: Boolean) { if (android.os.Build.VERSION.SDK_INT >= 30) return + // Skip when SystemBars already handles the IME inset (WebView >= 140 + // passthrough pads the WebView parent itself). Unknown version (null) -> + // run the shim, the safe default on API < 30. + val wvMajor = webViewCompatibility?.majorVersion + if (wvMajor != null && wvMajor >= 140) return val webView = bridge?.webView ?: return val params = webView.layoutParams ?: return // Ignore stale/pre-layout geometry so the height is not set from a bad frame. diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/widget/StartupOverlayManager.kt b/android/app/src/main/java/com/superproductivity/superproductivity/widget/StartupOverlayManager.kt index 19632c5b64..1480f6a628 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/widget/StartupOverlayManager.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/widget/StartupOverlayManager.kt @@ -19,8 +19,9 @@ import android.widget.EditText import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat -import com.getcapacitor.BridgeActivity import com.superproductivity.superproductivity.R /** @@ -38,11 +39,12 @@ class StartupOverlayManager(private val activity: android.app.Activity) { private var insetLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null private var taskCount = 0 private var isBarVisible = false - // How far the WebView's bottom sits above the overlay's bottom (px) = the - // bottom inset the edge-to-edge plugin applied to the WebView. This is what - // the web UI's safe area actually is on this device — measured directly from - // the WebView's geometry rather than guessed from navigationBars(), which - // does not match the applied inset on gesture-nav devices. -1 = not measured. + // The bottom system-bar inset (nav/gesture bar height, px) that the web + // add-task bar pads itself above via the safe-area inset. The FAB/input bar + // are lifted by the same amount so they line up with the web bar we hand off + // to. Under Capacitor's built-in SystemBars the WebView draws edge-to-edge + // (no bottom margin), so this is read from the root window insets rather than + // measured from the WebView's geometry. -1 = not measured. private var webViewBottomInset = -1 private val isDarkMode: Boolean @@ -65,10 +67,11 @@ class StartupOverlayManager(private val activity: android.app.Activity) { // Edge-to-edge (Capacitor 8, targetSdk 36): this overlay lives on the // full-window android.R.id.content, behind the system navigation bar, - // while the web UI lives in a WebView that the edge-to-edge plugin insets - // above the nav bar. Lift the FAB/input bar by that same inset so they - // line up with the web add-task button/bar we hand off to. Re-measured on - // each layout so it catches the inset being applied late and on rotation. + // while the web UI draws edge-to-edge and pads its add-task bar above the + // nav bar by the bottom system-bar (safe-area) inset. Lift the FAB/input + // bar by that same inset so they line up with the web bar we hand off to. + // Re-measured on each layout so it catches the inset being applied late + // and on rotation. See updateOverlayInsets. insetLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { updateOverlayInsets() } rootView.viewTreeObserver.addOnGlobalLayoutListener(insetLayoutListener) updateOverlayInsets() @@ -96,35 +99,30 @@ class StartupOverlayManager(private val activity: android.app.Activity) { } /** - * Measure how far the WebView's bottom sits above the overlay's bottom (the - * inset the edge-to-edge plugin applied) and lift the FAB / input bar by it - * so they align with the web add-task button/bar. Mirroring the WebView's - * real geometry is device-independent: it works regardless of gesture vs - * 3-button navigation, where navigationBars() would not match the applied - * inset. No-op until the views are laid out and when the inset is unchanged. + * Lift the FAB / input bar above the navigation bar by the bottom system-bar + * inset, so they align with the web add-task button/bar (which pads itself by + * the same safe-area inset under SystemBars). Reads the root window insets + * directly — device-independent across gesture vs 3-button navigation. No-op + * until the insets are available and when the inset is unchanged. */ private fun updateOverlayInsets() { val overlay = overlayView ?: return - // Freeze the inset once the bar is expanded. The edge-to-edge plugin sets - // the WebView's bottom margin to 0 while the IME is visible (EdgeToEdge.java: - // "system already resizes the window for the keyboard"), so re-measuring - // during the keyboard phase would read ~0 and drop the bar behind the - // keyboard. The FAB-phase value (keyboard down) is the one the keyboard - // listener needs. This also stops dead work once the FAB is gone. - // - // This early-return is also what keeps us off CapacitorMainActivity's - // adjustWebViewHeightForKeyboardBelowApi30, which shrinks `webView.height` - // while the IME is up (API < 30): we only read the height in the keyboard- - // down FAB phase, never while it is being shrunk. Keep this guard. + // Freeze the inset once the bar is expanded: from then on the keyboard + // listener in expandToInputBar owns the bar's margin (it adds the IME + // height on top of this resting inset). This also stops dead work once + // the FAB is gone. if (isBarVisible) return - val webView = (activity as? BridgeActivity)?.bridge?.webView ?: return - if (overlay.height == 0 || webView.height == 0) return + if (overlay.height == 0) return - val overlayLoc = IntArray(2) - val webViewLoc = IntArray(2) - overlay.getLocationInWindow(overlayLoc) - webView.getLocationInWindow(webViewLoc) - val inset = ((overlayLoc[1] + overlay.height) - (webViewLoc[1] + webView.height)) + // Read the bottom system-bar inset (nav/gesture bar) directly. Under + // SystemBars the WebView draws edge-to-edge with no bottom margin, so the + // old (overlayBottom − webViewBottom) measurement reads ~0; the web bar + // pads itself by this same safe-area inset, so mirroring it here keeps the + // native overlay aligned with the web bar across gesture and 3-button nav. + val rootInsets = ViewCompat.getRootWindowInsets(overlay) ?: return + val inset = rootInsets + .getInsets(WindowInsetsCompat.Type.systemBars()) + .bottom .coerceAtLeast(0) if (inset == webViewBottomInset) return webViewBottomInset = inset diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index b5c77a9025..c601fe7995 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -26,8 +26,5 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh include ':capawesome-capacitor-android-dark-mode-support' project(':capawesome-capacitor-android-dark-mode-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-dark-mode-support/android') -include ':capawesome-capacitor-android-edge-to-edge-support' -project(':capawesome-capacitor-android-edge-to-edge-support').projectDir = new File('../node_modules/@capawesome/capacitor-android-edge-to-edge-support/android') - include ':capawesome-capacitor-background-task' project(':capawesome-capacitor-background-task').projectDir = new File('../node_modules/@capawesome/capacitor-background-task/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 0c4bf2fdda..7bde77074e 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -17,8 +17,8 @@ const config: CapacitorConfig = { // and uses JavaScriptInterface for keyboard visibility instead. // 'native' resizes the WKWebView so 100vh fits above the keyboard. resize: 'native', - // false is required when paired with @capawesome/capacitor-android-edge- - // to-edge-support; ignored on iOS where this key has no effect. + // iOS-only; ignored on Android (plugin excluded). Kept false so the + // WKWebView is not force-resized in fullscreen. resizeOnFullScreen: false, }, StatusBar: { @@ -27,20 +27,17 @@ const config: CapacitorConfig = { overlaysWebView: true, }, SystemBars: { - // Disable Capacitor's built-in inset handling so the edge-to-edge plugin - // can own it. With targetSdk 36 (Android 16) edge-to-edge is mandatory, - // and the two layers both applying insets fight each other — visible - // as fixed-position elements scrolling with content when the IME is up. - insetsHandling: 'disable', - }, - EdgeToEdge: { - // Initial status/navigation bar background color, shown before the theme - // service boots and calls EdgeToEdge.set{Status,Navigation}BarColor. - // Without this the plugin's overlay views default to transparent, so the - // bottom navigation/gesture area shows the bare window background. Dark to - // match the most common mobile theme (cf. the ios backgroundColor below). - statusBarColor: '#131314', - navigationBarColor: '#131314', + // Let Capacitor's built-in SystemBars own edge-to-edge inset handling on + // Android (replaces the @capawesome edge-to-edge plugin). 'css' enables + // SystemBars' Android inset handling: it *injects* --safe-area-inset-* CSS + // vars on API >= 35, and passes native env(safe-area-inset-*) through on + // WebView >= 140. The WebView <140 / API <35 tail gets neither and falls + // back to env() (plus the native keyboard shim in CapacitorMainActivity). + // See docs/plans/2026-06-22-android-systembars-migration-corrected.md. + insetsHandling: 'css', + // Initial bar icon style before the theme service boots; runtime updates + // go through StatusBar.setStyle / NavigationBar (global-theme.service.ts). + style: 'DARK', }, }, android: { @@ -56,7 +53,6 @@ const config: CapacitorConfig = { '@capacitor/local-notifications', '@capacitor/share', '@capawesome/capacitor-android-dark-mode-support', - '@capawesome/capacitor-android-edge-to-edge-support', '@capawesome/capacitor-background-task', ], }, diff --git a/docs/android-edge-to-edge-keyboard.md b/docs/android-edge-to-edge-keyboard.md index f429d37aeb..16e8e9878e 100644 --- a/docs/android-edge-to-edge-keyboard.md +++ b/docs/android-edge-to-edge-keyboard.md @@ -4,6 +4,18 @@ How the global add-task bar is positioned over the keyboard, and the full #8508 saga. **Read this before touching anything keyboard/IME-related on Android — this area has regressed repeatedly (#8295, then #8508).** +> **Update (2026-06-22): migrated off `@capawesome/...edge-to-edge-support` to +> Capacitor's built-in `SystemBars`** (`insetsHandling: 'css'`). Edge-to-edge +> insets + IME padding are now handled by SystemBars on **WebView ≥ 140** (or +> API ≥ 35); the **WebView < 140 / API < 35** tail is covered by env() + a native +> keyboard shim (`adjustWebViewHeightForKeyboardBelowApi30`, now gated to +> WebView < 140 so it never fights SystemBars). Bar backgrounds are no longer +> painted by a plugin (SystemBars has no color API) — the bars are transparent +> and the theme color shows through via `NavigationBarPlugin.setWebViewBackgroundColor` +> (window decor + WebView surface). The #8508 sections below describe the *former* +> `@capawesome` mechanics and are kept as history. Full rationale + device matrix: +> [`docs/plans/2026-06-22-android-systembars-migration-corrected.md`](plans/2026-06-22-android-systembars-migration-corrected.md). + > **⚠️ Do NOT inset the WebView for the IME based on an assumption that the > system "doesn't resize on Android 15/16."** Real devices (incl. a Pixel-class > Android 16 phone) still resize the window for the keyboard. Insetting on top of diff --git a/docs/plans/2026-06-22-android-systembars-migration-corrected.md b/docs/plans/2026-06-22-android-systembars-migration-corrected.md new file mode 100644 index 0000000000..87ad6327c9 --- /dev/null +++ b/docs/plans/2026-06-22-android-systembars-migration-corrected.md @@ -0,0 +1,247 @@ +# Android edge-to-edge: migrate `@capawesome` → Capacitor `SystemBars` (corrected plan) + +**Date:** 2026-06-22 +**Status:** PLAN ONLY — not started. Supersedes the migration section (§5) of the +PR-8528 handover, which was written before the `SystemBars` WebView/API gating +(below) was known. +**Prereq reading:** [`docs/android-edge-to-edge-keyboard.md`](../android-edge-to-edge-keyboard.md) +(the #8508 saga and the "never blindly inset the WebView for the IME" rule). + +--- + +## TL;DR + +- Dropping the thrice-regressed `@capawesome/capacitor-android-edge-to-edge-support` + plugin for Capacitor 8's built-in `SystemBars` is the right **long-term** + direction (one fewer fragile dependency in the area that regressed at #8295 and + twice in #8508). +- **It is a device-validated spike, not a config swap.** A naive "remove the plugin, + flip `insetsHandling` to `'css'`" **regresses a supported slice of the fleet** — + see the gating model below. +- **The reported API<30 keyboard bug should ship independently** via the native + height workaround (`adjustWebViewHeightForKeyboardBelowApi30`, PR #8528). It is + WebView-version-independent; the migration's keyboard handling is **not**. Keep + that workaround inside the migration too — do not bank on Capacitor core #8481. + +--- + +## Why the naive plan is wrong: the `SystemBars` inset model + +Verified by reading the bundled source +`node_modules/@capacitor/android/capacitor/src/main/java/com/getcapacitor/plugin/SystemBars.java` +(constants `WEBVIEW_VERSION_WITH_SAFE_AREA_FIX = 140`, +`WEBVIEW_VERSION_WITH_SAFE_AREA_KEYBOARD_FIX = 144`; logic in +`initWindowInsetsListener()` lines 177–231). + +`SystemBars` does **not** handle insets unconditionally. Its window-insets listener +branches on `shouldPassthroughInsets = (WebView major ≥ 140) && viewport-fit=cover` +(the app has `viewport-fit=cover`, `src/index.html:8`): + +| Device state | Static bar insets | Keyboard / IME inset | +| --- | --- | --- | +| **WebView ≥ 140** (passthrough) | native `env(safe-area-inset-*)` passed through, all API levels | `setPadding(…, imeInsets.bottom)` on the WebView parent — **works on all API levels** (incl. API 28) | +| **WebView < 140**, **API ≥ 35** | `setPadding` + injects `--safe-area-inset-*` CSS vars | handled | +| **WebView < 140**, **API < 35** | **nothing** (insets zeroed & consumed) | **nothing** | + +The killer mismatch: this app **officially supports WebView 107+** and only *warns* +below 110 (`WebViewCompatibilityChecker.kt`: `MIN_CHROMIUM_VERSION = 107`, +`RECOMMENDED_CHROMIUM_VERSION = 110`). `SystemBars` edge-to-edge only engages at +WebView **140**. So **WebView 107–139 on API < 35** is a *supported* band where +`SystemBars` is a no-op. + +The `@capawesome` plugin currently insets the WebView on **every** API/WebView +combination. Therefore **removing it without a replacement for the no-op band +regresses exactly the bug's device class** (Android 9 / API 28, `minSdkVersion 24`): +content slides under the bars and behind the keyboard, with zero inset +compensation. Note the app also force-enables edge-to-edge on API < 35 via +`@capacitor/status-bar`'s legacy `overlaysWebView: true` (see +`global-theme.service.ts:776–784`), so the OS will *not* fall back to insetting the +window for us there. + +**Corollary for the keyboard bug:** moving to `SystemBars` only fixes the API<30 +add-task-bar-behind-keyboard bug *if* the device has WebView ≥ 140. The reporter's +API 28 device may not. The native height workaround is pure geometry → reliable +regardless of WebView version. **Keep it.** + +--- + +## Decision gates — do NOT start the spike until all hold + +1. A **device/emulator matrix is actually runnable** for validation (it is not in + the current Claude sandbox: gradle + emulator are unavailable, so any migration + would land unvalidated on real devices — the precise failure mode of #8508). +2. A view on the **WebView-version distribution** of the Android user base (esp. + API < 35), or an accepted decision to regress the WebView 107–139 / API<35 band. +3. **Design sign-off** on the end-state bar appearance: `SystemBars` has **no color + API** (`setBackgroundColor()` = *Unsupported*, confirmed in + `node_modules/@capacitor/core/system-bars.md`), so the end state is transparent + bars with the web background showing through — a visible change from today's + opaque `#131314` / `#f8f8f7` overlays, across light/dark, cutout, gesture vs + 3-button nav. +4. The reported keyboard bug is **already shipped** independently (PR #8528), so it + does not ride on this risky rewrite. + +--- + +## Corrected migration steps (each device-validated before merge) + +1. **Spike on a fresh branch off master.** Keep the keyboard fix independent. + +2. **Config (`capacitor.config.ts`):** remove the `EdgeToEdge` config block, drop + `@capawesome/capacitor-android-edge-to-edge-support` from `android.includePlugins` + and from `package.json`, then `npx cap sync` (regenerates + `android/capacitor.settings.gradle` + `android/app/capacitor.build.gradle`; a + stale module reference is a hard build break, so don't hand-edit). + - Set `SystemBars.insetsHandling: 'css'`. + - **KEEP `windowOptOutEdgeToEdgeEnforcement=true`** (`values-v35/styles.xml`). + Removing it (the original plan's step 3) is **gratuitous extra risk**: on API + 35+ it turns `window.{status,navigation}BarColor` into hard no-ops (so even the + custom `NavigationBarPlugin.setColor` stops coloring) and forces transparent + bars — a separate behavioral change from the plugin swap. Do enforcement + removal, if ever, as its own follow-up with design sign-off. + - The `Keyboard.resizeOnFullScreen: false` comment ("required when paired with + @capawesome edge-to-edge") becomes stale — fix the comment; the key is + effectively iOS-only (Android excludes `@capacitor/keyboard`). + +3. **Resolve the `--safe-area-inset-*` writer collision (HIGH).** `insetsHandling: + 'css'` injects `--safe-area-inset-{top,right,bottom,left}` onto + `document.documentElement` — the **exact** vars the app already writes in + `GlobalThemeService._initSafeAreaInsets()` and reads in `_css-variables.scss:51`. + Two writers on the same inline style = last-writer-wins, OS/timing-dependent. + Pick **one owner per platform**: + - Android, API 35+ / WebView ≥ 140: let `SystemBars` own them → **stop the JS + writes on Android**. + - Android, the no-op band (WebView 107–139 / API<35): `SystemBars` injects + nothing, so an `env()` fallback source is still required (the existing + `var(--safe-area-inset-*, env(...))` chain in `_css-variables.scss` already + provides it; the app's current Android top-deferral to `env(safe-area-inset-top)` + for #8283 must be preserved). + - Re-check `_patchCdkViewportForSafeArea()`: it `parseInt`s these vars via + `getComputedStyle`. Today the top resolves to the literal `"env(...)"` string → + `parseInt` yields 0 by design. Under `'css'`, `SystemBars` writes numeric px → + overlay (menu/select/autocomplete) top positioning **changes** on Android. + Re-test the full overlay matrix. Same for `task-context-menu` / `context-menu` + readers of `--safe-area-inset-top`. + +4. **Cover the no-op band (the hard problem).** Decide explicitly — do not leave + implicit: + - (a) Accept the regression for WebView 107–139 / API<35 (only if that + population is negligible — needs data from gate #2). Simplest; risky. + - (b) Keep a **minimal native inset shim** for that band (partially defeats the + "drop the plugin" win, but bounded and self-owned). + - (c) Turn `overlaysWebView` **off on Android < 15** so the OS insets the window + normally there (opaque OS bars) — but this changes the look and re-enters #8283 + territory; verify the top-inset fallback. + +5. **Bar color.** Transparent bars + paint behind them: + `NavigationBarPlugin.setWebViewBackgroundColor` already sets the **window decor** + (`window.setBackgroundDrawable`) *and* the WebView surface — independent of + `@capawesome`, survives the migration, and is the lever for the color behind + transparent bars. Combined with the web `` background filling the + safe-area zones and `SystemBars.setStyle` for light/dark icon content. Verify no + white gap (the #8508 / capawesome-#725 failure) on every API/theme. + +6. **Keyboard.** Keep `adjustWebViewHeightForKeyboardBelowApi30` (PR #8528) — it + reads geometry only, so it is plugin-agnostic and WebView-version-independent. + Its height target (`rect.bottom − webViewTopOnScreen`) is independent of the + plugin, but `webViewTopOnScreen` shifts once the WebView is no longer natively + inset → re-validate on API 28/29. Do **not** assume core #8481 retires it. + +7. **`StartupOverlayManager.kt` (HIGH, native, not hotfixable).** It derives + `webViewBottomInset` by *measuring the @capawesome-applied WebView margin* + (lines 106–138). With the WebView no longer inset, that measures ~0 and the + native startup quick-add bar drops behind the nav bar. Re-derive from system + insets — but note the current code comment (lines 43–46) explicitly **rejected** + `navigationBars()` because it mismatches the applied inset on gesture-nav + devices; design the new source carefully. Update the now-wrong freeze-during-IME + comment (lines 108–114). + +8. **Keep for iOS:** `@capacitor/status-bar` (`StatusBar.setStyle` + + `overlaysWebView` + `contentInset:'never'`) and `capacitor-plugin-safe-area` + (the only iOS safe-area source). `SystemBars` `insetsHandling` is Android-only. + Don't let a cleanup pass remove the `StatusBar` import from + `global-theme.service.ts` (used on both platforms). Verify `SystemBars` does not + double-write CSS vars on iOS. + +--- + +## iOS invariants (must not break) + +The migration is Android-only but `global-theme.service.ts` + `_css-variables.scss` +are shared. Keep: `StatusBar.overlaysWebView:true` + `ios.contentInset:'never'` + +`ios.backgroundColor` (content-under-notch on iOS), the iOS branch of +`_initSafeAreaInsets()` (`SafeArea.getSafeAreaInsets()` + `safeAreaChanged`), and +the iOS keyboard path in `_patchCdkViewportForSafeArea()` +(`--keyboard-overlay-offset`, gated on `body.isIOS`). Add iOS keyboard + notch + +overlays to the test matrix. + +--- + +## Validation matrix (minimum, all UNVALIDATED in the dev sandbox) + +- API 28/29 **split by WebView <140 vs ≥140**, API 30–34, API 35, API 36. +- Light + dark theme; gesture + 3-button nav; a device with a display cutout. +- Per cell: status/nav bar color (no white gap), content not under bars, add-task + bar vs keyboard (open/close), native startup-overlay alignment, rotation, + typed characters appear in order (the #8508 reversal check). +- iOS: notch, home indicator, keyboard, connected overlays. + +--- + +## Multi-review findings to confirm on device (implementation, 2026-06-22) + +A 3-agent review of the implementation diff found the merge resolution correct and +the migration mechanically clean (no leftovers, deps/gradle consistent, iOS +untouched). The residual risks are all device-matrix items — listed here so they +are explicitly checked, NOT blind-fixed (a blind fix risks re-creating #8508): + +1. **API ≥ 35 + WebView < 140 double-count (narrow band).** In SystemBars' + non-passthrough branch (API ≥ 35) it `setPadding`s the WebView parent *and* + injects `--safe-area-inset-*`; if the web also pads via `var(--safe-area-*)` + that double-counts. The common API 36 case is WebView ≥ 140 = passthrough (no + static parent padding → no double-count), so this is the stale-WebView corner. + Verify on an API 35/36 device with an old WebView; if real, gate the web + padding off on that band rather than removing it globally. +2. **`env(safe-area-inset-bottom)` vs `var(--safe-area-bottom)` consumers diverge + on API ≥ 35.** Some SCSS (e.g. `mobile-bottom-nav`, `app.component`) reads raw + `env()`; others read `var(--safe-area-*)`. On API ≥ 35 SystemBars can zero the + passed-through insets while injecting real px into the vars, so the two + families disagree. Confirm the bottom-nav / add-task-bar spacing on API 35/36; + reconcile to one source per band if it's wrong. +3. **API 30–34 + WebView < 140 IME owner.** The native shim is gated `SDK_INT < 30` + (deliberate — newer APIs were observed to resize the window for the IME, and + insetting on top of that re-creates the #8508 squash). Under SystemBars, + WebView < 140 gets no IME padding below API 35. Verify whether the window still + resizes on API 30–34: if it does, no gap; if it does NOT, extend the shim to + `< 35 && WebView < 140` — but only after confirming on a device, never blind. +4. **CDK overlay / context-menu top position shifts on API ≥ 35.** `--safe-area- + inset-top` now resolves to real px there (was 0 on Android), so connected + overlays clamp below the status bar. Likely more correct; re-test the overlay + matrix. + +--- + +## Blast radius / rollback + +- **Native (NOT remotely hotfixable):** plugin removal, `StartupOverlayManager.kt`, + `NavigationBarPlugin.kt`, `styles.xml`. A wrong inset or white-bar regression + needs a full Play Store release + staged rollout — the slow loop that made + #8295/#8508 painful (the git log shows #8508 regressed, was "fixed", then + reverted: `2a0cc73507` → `c247bc541a`). +- **Web (hotfixable):** the CSS-var/SCSS changes — but only if the symptom is + genuinely web-side; many are coupled to the native inset model. +- **Rollback:** re-adding `@capawesome` is a multi-file native revert (re-pin dep, + re-sync gradle, restore color calls + `StartupOverlayManager` coupling), not a + one-line flip. Open a tracking issue and keep the revert documented. + +--- + +## References + +- Capacitor core: `ionic-team/capacitor#8466` (insetsHandling breaks WebView on + API 29 + keyboard), fixed for built-in `SystemBars` by core PR **#8481** (merged). +- Plugin: `capawesome-team/capacitor-plugins` #845/#490/#596/#725/#819/#812, + **#847 open**; plugin PR **#848** (open/unreleased; its "API 29 resizes via + adjustResize" premise did not match this app's on-device logcat). +- App history: #8295, #8508 (`docs/android-edge-to-edge-keyboard.md`); the keyboard + fix is PR **#8528**. diff --git a/package-lock.json b/package-lock.json index 761f2d5719..3990b11188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@capacitor/ios": "^8.3.4", "@capacitor/keyboard": "8.0.1", "@capacitor/status-bar": "^8.0.2", - "@capawesome/capacitor-android-edge-to-edge-support": "^8.0.8", "@material-symbols/font-400": "^0.44.10", "@noble/ciphers": "^2.2.0", "capacitor-plugin-safe-area": "^5.0.0", @@ -3045,25 +3044,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/@capawesome/capacitor-android-edge-to-edge-support": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/@capawesome/capacitor-android-edge-to-edge-support/-/capacitor-android-edge-to-edge-support-8.0.8.tgz", - "integrity": "sha512-+eKz15uhfOwft5IRZFL1QQZT/FHeeE5scNnlP9CIMrhW0Ctc9cJM13Ec2vOTd9oYGz3mCF3kf39C2spDDuSUVw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/capawesome-team/" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/capawesome" - } - ], - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capawesome/capacitor-background-task": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@capawesome/capacitor-background-task/-/capacitor-background-task-8.0.2.tgz", diff --git a/package.json b/package.json index 655c93417f..c13b47fb0d 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,6 @@ "@capacitor/ios": "^8.3.4", "@capacitor/keyboard": "8.0.1", "@capacitor/status-bar": "^8.0.2", - "@capawesome/capacitor-android-edge-to-edge-support": "^8.0.8", "@material-symbols/font-400": "^0.44.10", "@noble/ciphers": "^2.2.0", "capacitor-plugin-safe-area": "^5.0.0", diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index faca61ff11..2a6f4e1127 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -41,7 +41,6 @@ import { CapacitorPlatformService } from '../platform/capacitor-platform.service import { Keyboard, KeyboardInfo } from '@capacitor/keyboard'; import { PluginListenerHandle, registerPlugin } from '@capacitor/core'; import { StatusBar, Style } from '@capacitor/status-bar'; -import { EdgeToEdge } from '@capawesome/capacitor-android-edge-to-edge-support'; import { SafeArea } from 'capacitor-plugin-safe-area'; import { FlexibleConnectedPositionStrategy } from '@angular/cdk/overlay'; import { LS } from '../persistence/storage-keys.const'; @@ -759,49 +758,25 @@ export class GlobalThemeService { root.style.setProperty(CSS_VAR_SAFE_AREA_RIGHT, `${insets.right}px`); }; - // On Android (targetSdk 35+, edge-to-edge enforced) the - // @capawesome/capacitor-android-edge-to-edge-support plugin already insets - // the WebView below the status bar and above the navigation bar via native - // margins. capacitor-plugin-safe-area reports the decorView's full - // system-bar insets regardless, so applying them as CSS padding on top of - // the native margin double-counts the inset (visible as excessive padding - // above the top bar). The WebView interior is fully safe there, so keep the - // bottom/side safe-area CSS vars at 0; only iOS (contentInset: 'never') - // needs the WebView to pad itself. A few styles read env(safe-area-inset- - // bottom) directly (e.g. mobile-bottom-nav) rather than these vars; inside - // the natively-inset WebView that env value is expected to be ~0, keeping - // them consistent with the pinned vars here. - // - // The TOP is the exception. On Android < 15 the WebView is forced - // edge-to-edge by @capacitor/status-bar's legacy `overlaysWebView` - // fullscreen flag (a no-op on Android 15+), and the plugin's native top - // margin is not reliably applied on every OS/ROM — the header then draws - // behind the status bar (#8283, seen on Android 14). Pinning the top var to - // 0 leaves no fallback. Instead defer the top to the WebView's own - // env(safe-area-inset-top): with viewport-fit=cover it equals the status-bar - // height exactly when the WebView extends under it, and resolves to 0 once - // the WebView is already inset (Android 15+ / native margin applied) — so it - // self-corrects across OS versions without ever double-counting. - if (this._platformService.isAndroid()) { - applyInsets({ top: 0, right: 0, bottom: 0, left: 0 }); - // Override only the top with the env() fallback (see above). NOTE: this - // self-corrects only for *CSS* consumers — `padding-top: var(--safe-area-top)` - // resolves the nested env() at use-time, so the header padding is right. JS - // readers that parse this custom property via getComputedStyle (e.g. - // _patchCdkViewportForSafeArea, the context menus) get the *unresolved* - // "env(...)" token string back — env()/var() are only substituted when the - // property is actually used, not when a custom property's own value is read — - // so parseInt() yields 0, the same top inset those readers already used on - // Android before this change. Connected overlays are therefore not pushed - // below the status bar; only the header padding is. That is the scope of - // #8283 (the header was the reported regression). If overlay top-insets ever - // matter on Android, register the var via @property or read a probe element's - // resolved padding-top instead of the raw custom property. - this.document.documentElement.style.setProperty( - CSS_VAR_SAFE_AREA_TOP, - 'env(safe-area-inset-top, 0px)', - ); - } else { + // On Android the WebView now draws edge-to-edge (the @capawesome plugin that + // used to inset it via native margins was removed in favour of Capacitor's + // built-in SystemBars). The --safe-area-inset-* vars are no longer written + // from JS on Android — that would race SystemBars on the same documentElement + // inline style (last-writer-wins, OS/timing dependent). Each band resolves + // them on its own (verified against the bundled SystemBars.java): + // - API >= 35: SystemBars *injects* the real px into --safe-area-inset-*. + // - WebView >= 140 (any API): SystemBars passes the native insets through, + // so the WebView's own env(safe-area-inset-*) is correct (no injection + // below API 35). + // - WebView < 140 / API < 35 tail: SystemBars does nothing here. + // In every case the SCSS fallback `var(--safe-area-inset-*, env(...))` in + // _css-variables.scss resolves to the injected px when present, else to + // env(). With viewport-fit=cover env(safe-area-inset-top) equals the + // status-bar height when the WebView extends under it — exactly the #8283 top + // fallback, preserved automatically by not pinning the var here. + // Only iOS (contentInset: 'never') still needs JS-fed insets from + // capacitor-plugin-safe-area; SystemBars insetsHandling is Android-only. + if (!this._platformService.isAndroid()) { SafeArea.getSafeAreaInsets().then(({ insets }) => applyInsets(insets)); SafeArea.addListener('safeAreaChanged', ({ insets }) => applyInsets(insets)); } @@ -853,29 +828,27 @@ export class GlobalThemeService { }); if (this._platformService.isAndroid()) { const bgColor = isDark ? '#131314' : '#f8f8f7'; - // Under enforced edge-to-edge (targetSdk 35+) Window.setStatusBarColor / - // setNavigationBarColor are no-ops; the edge-to-edge support plugin owns - // the bar backgrounds via its own overlay views. Color them through it - // so the status bar and the bottom navigation/gesture area match the - // theme background. - EdgeToEdge.setStatusBarColor({ color: bgColor }).catch((err) => { - Log.warn('Failed to set status bar color', err); - }); - EdgeToEdge.setNavigationBarColor({ color: bgColor }).catch((err) => { - Log.warn('Failed to set navigation bar color', err); - }); - // The custom NavigationBar plugin still drives the nav bar icon/pill - // appearance (light vs dark) via setSystemBarsAppearance, which remains - // effective on Android 15+; the window.navigationBarColor it also sets - // is a harmless no-op there. + // The @capawesome edge-to-edge plugin (which painted opaque bar overlays + // via EdgeToEdge.set{Status,Navigation}BarColor) was removed in favour of + // Capacitor's built-in SystemBars. SystemBars has NO bar-color API — the + // edge-to-edge model is transparent bars with the web content drawn + // behind them. The bar backgrounds are therefore painted by: + // - setWebViewBackgroundColor below (window decor + WebView surface), + // which shows through the transparent bars (the color backstop on + // API 35+ where window.*BarColor is a no-op), and + // - NavigationBar.setColor's window.navigationBarColor, still effective + // on API < 35, plus its setSystemBarsAppearance which drives the nav + // bar icon light/dark on all versions. + // Status-bar icon light/dark is set via StatusBar.setStyle above. NavigationBar.setColor({ color: bgColor, style: isDark ? 'DARK' : 'LIGHT', }).catch((err) => { Log.warn('Failed to set navigation bar appearance', err); }); - // Keep the native WebView surface matched to the theme so the - // adjustResize keyboard animation can't flash white between frames. + // Paint the WebView surface and window decor with the theme background so + // the transparent system bars show the theme color behind them and the + // keyboard animation can't flash white between frames. NavigationBar.setWebViewBackgroundColor({ color: bgColor }).catch((err) => { Log.warn('Failed to set web view background color', err); });