mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
20e85a2a19
commit
bb3ed74480
10 changed files with 364 additions and 168 deletions
|
|
@ -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')
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
247
docs/plans/2026-06-22-android-systembars-migration-corrected.md
Normal file
247
docs/plans/2026-06-22-android-systembars-migration-corrected.md
Normal 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 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 `<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 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**.
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue