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.
This commit is contained in:
Johannes Millan 2026-06-22 16:07:06 +02:00 committed by GitHub
parent 20e85a2a19
commit bb3ed74480
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 364 additions and 168 deletions

View file

@ -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')
}

View file

@ -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.

View file

@ -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

View file

@ -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')

View file

@ -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',
],
},

View file

@ -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

View file

@ -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 177231).
`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 107139 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:776784`), 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 107139 / 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 107139 / 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 107139 / 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 `<body>` 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 106138). 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 4346) 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 108114).
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 3034, 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 3034 + 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 3034: 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**.

20
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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);
});