mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(android): size fullscreen markdown/notes dialog above the keyboard (#8508) The fullscreen markdown editor (project & task notes) is position:fixed; height:100% but its keyboard rule subtracted --keyboard-overlay-offset, which is set only on iOS. On Android the rule was a no-op, so with the IME open the dialog kept full height (content behind the keyboard) or inherited the squashed sliver, leaving the toolbar + textarea + Close/Save mashed to the top. Use the resize-detecting --keyboard-height for the Android/mobile-web case (0 once the window resized, the obscured amount otherwise), mirroring the add-task bar and .app-container; keep the iOS --keyboard-overlay-offset path as a second, source-order-later rule (equal specificity, wins on iOS). On a device that resizes (--keyboard-height == 0) it is identical to the old rule, so it is never worse than before; it fixes the API >= 30 no-resize/VisualViewport-shrink case and composes with the SDK < 30 native fix (#8528). * fix(android): inset the header below the status bar on API < 30 (#8508) Under enforced edge-to-edge (targetSdk 36) the WebView extends under the status bar, but on the API < 30 WebView `env(safe-area-inset-top)` resolves to 0 (old WebViews map only display cutouts into safe-area insets, not the status bar), so `--safe-area-top` was 0 and the web header overlapped the status bar on Android 9. The web side cannot tell "edge-to-edge under the status bar" from "already natively inset" (env() is 0 in both), so a web-only fallback would double-count. Measure the overlap natively instead: in the existing keyboard layout listener (gated SDK < 30) compute max(0, rect.top - webViewTopOnScreen) — visible-frame top (status-bar height, reliable on API 28) minus the WebView's on-screen top (0 when edge-to-edge, == status-bar height once inset) — and publish it as the `--android-status-bar-overlap` CSS var (physical px -> CSS px, deduped). The web folds it in with max(env(safe-area-inset-top, 0px), var(--android-status-bar- overlap, 0px)): never a sum so it can't double-count, and on API >= 30 the var is never set so `max(env, 0) = env` keeps the verified behavior. JS readers still parse the token to 0, preserving the #8283 overlay scoping. Needs on-device validation on API < 30 and a no-op check on API >= 30. * fix(android): stop double-counting safe-area-top in note dialog height (#8508) The fullscreen note/markdown dialog subtracted --safe-area-top from its keyboard-open height while ALSO applying padding-top: --safe-area-top. Since :host is border-box (global * { box-sizing: border-box }), the padding is already inside height: 100%, so subtracting --safe-area-top again double-removed the top inset and left a --safe-area-top-sized gap between the Close/Save controls and the keyboard. Invisible while --safe-area-top was 0 on API < 30; it surfaced once the status-bar fix made it non-zero (and was latent on API >= 30 where env() already gave a non-zero value). Drop the - --safe-area-top term from the Android rule so the controls sit flush above the keyboard while the toolbar stays below the status bar. iOS keeps its own override (different keyboard runtime, unverified on device) and is flagged for separate checking. * fix(android): re-publish status-bar overlap after web reload (#8508) Review follow-ups to the #8508 keyboard/status-bar fixes: - The native --android-status-bar-overlap lives only as an inline style on the document, so a web-side window.location.reload() (language change, PWA update, sync-conflict recovery) wipes it while the dedupe field survives on the Activity -> the unchanged value is skipped and the header overlaps the status bar again on the WebView < 140 / API < 30 tail. Reset lastStatusBarOverlapCssPx in flushPendingShareIntent() (runs on every frontend (re)load) so the next layout pass re-publishes it. - Dialog keyboard rule: scope the Android rule with :not(.isIOS). iOS carries both isNativeMobile and isIOS and sets --keyboard-height non-zero, so the two equal-specificity rules both matched and iOS correctness depended on source order; they are now mutually exclusive and order-independent. - Declare --android-status-bar-overlap: 0px in :root for discoverability, alongside the other dynamic vars.
352 lines
21 KiB
Markdown
352 lines
21 KiB
Markdown
# Android edge-to-edge + soft keyboard (IME)
|
||
|
||
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
|
||
> that double-counts and squashes the WebView. See #8508 below. Any future inset
|
||
> must _detect_ whether the window already resized.
|
||
|
||
## How the bar is positioned
|
||
|
||
The global add-task bar is `position: fixed` and lifted off the bottom by a CSS
|
||
variable only:
|
||
|
||
```scss
|
||
// add-task-bar.component.scss
|
||
:host-context(.isTouchOnly).global {
|
||
bottom: calc(var(--keyboard-height) + var(--s2));
|
||
}
|
||
```
|
||
|
||
`--keyboard-height` defaults to `0px`. On Android/web it is set by
|
||
`GlobalThemeService._initVisualViewportKeyboardTracking()`
|
||
(`src/app/core/theme/global-theme.service.ts`) from
|
||
`obscured = window.innerHeight - visualViewport.height`, with a 100px floor
|
||
(`KEYBOARD_THRESHOLD_PX` — `obscured <= 100` is treated as `0`). On iOS the
|
||
Capacitor Keyboard plugin sets it.
|
||
|
||
So the bar floats above the keyboard if **either** the window/WebView shrinks
|
||
(then `bottom: 0` is already above the IME) **or** the visual viewport shrinks
|
||
(then `--keyboard-height` lifts the bar). On the devices we have tested, the
|
||
window **does** shrink (the system resizes for the IME), so `--keyboard-height`
|
||
stays `0` and the bar sits correctly at `bottom: var(--s2)`.
|
||
|
||
## #8508 — reversed / invisible characters (the actual root cause)
|
||
|
||
**Symptom.** On Android, the add-task bar (and search) showed reversed or
|
||
invisible characters; some users reported "I can't see what I'm writing and Enter
|
||
does nothing." Reported on v18.11.0 only (Pixel 10/Android 17, Galaxy S23 Ultra,
|
||
Pixel 8a, Tab S5e/Android 15).
|
||
|
||
**Root cause.** v18.11.0 shipped a `patch-package` patch (commit `5497212b9`) to
|
||
`@capawesome/capacitor-android-edge-to-edge-support` that **always** inset the
|
||
WebView by the IME height (`bottomMargin = max(imeInsets.bottom, …)` on every
|
||
`OnApplyWindowInsetsListener` callback), to fix the bar sitting _behind_ the
|
||
keyboard under _assumed_ enforced edge-to-edge.
|
||
|
||
On real devices the assumption is false: the system **still resizes the window
|
||
for the IME** even on Android 16. Measured on an Android 16 phone with the
|
||
keyboard up: `window.innerHeight` went **732 → 141** (and `--keyboard-height`
|
||
stayed `0`). The patch then added **another ~909px** inset on top of the already
|
||
shrunk window → the WebView was squashed to a ~141px sliver with a huge blank
|
||
gap above the keyboard. That squashed layout is almost certainly the
|
||
"can't see what I'm writing" report.
|
||
|
||
**Fix (this change).** The patch was **removed entirely**. The plugin's stock
|
||
behavior — `bottomMargin = keyboardVisible ? 0 : max(imeInsets.bottom, …)`, i.e.
|
||
no inset while the keyboard is up — lets the system handle the keyboard.
|
||
**Verified on an Android 16 phone: gap gone, WebView fills the resized window,
|
||
bar sits just above the keyboard (no behind-keyboard regression).**
|
||
|
||
## Theories that were RULED OUT (don't re-chase)
|
||
|
||
- **"Angular `ngModel` `writeValue` resets the caret during composition."**
|
||
REFUTED. `NgModel`'s `isPropertyUpdated` guard skips `writeValue` while the
|
||
model equals the just-typed value, and the add-task bar never touches
|
||
`value`/`setSelectionRange`/`focus` mid-composition. Proven with an e2e CDP IME
|
||
probe (since removed) and the unit specs.
|
||
- **"Per-keystroke DOM churn (signal updates) during composition."** Not the
|
||
cause. On-device logging showed the WebView does **not** relayout during steady
|
||
typing.
|
||
- **An SDK-version gate (inset only on API 36+) and an inset "latch."** Both
|
||
tried and reverted. The gate is wrong because the Android 16 phone _resizes_
|
||
(so it still double-counted there); the latch held a stale keyboard height and
|
||
produced its own gap.
|
||
|
||
## Open items — if this is NOT fixed for the reporters
|
||
|
||
1. **Confirm the "reversed characters" symptom on the reporters' devices.** The
|
||
squashed-WebView / gap is verified fixed on the maintainer's Android 16 phone.
|
||
It is **not yet confirmed** that the _reversal_ is gone for all reporters
|
||
(Pixel 10/A17, S23, Pixel 8a, Tab S5e). Ask them to test the next build.
|
||
2. **Residual: the system itself resizes on suggestion-strip changes.** Even with
|
||
the patch gone, the logs show the IME inset oscillating (`imeBottom 909↔996`)
|
||
as the suggestion strip toggles — and the _system_ resizes the window each
|
||
time. Typing during that system resize could still disrupt composition. This
|
||
is Android's own `adjustResize`, not our code. If reports persist, this is the
|
||
next lead (e.g. a content-stable layout, or debouncing).
|
||
3. **The other v18.11.0 change.** If the reversal persists with the patch gone,
|
||
re-examine the `@angular/* 21.2.11 → 21.2.17` bump (commit `f51954f80`) — the
|
||
only other IME-adjacent change in the release.
|
||
4. **Long-term proper fix.** Removing the patch only puts the bar behind the
|
||
keyboard on a device that enforces edge-to-edge **and** whose _visual_ viewport
|
||
also fails to shrink for the IME — otherwise `--keyboard-height` still lifts
|
||
the bar. That is cosmetic and likely rare, vs. the squashed layout on every
|
||
real device tested. The correct inset would be **resize-detecting**: only inset
|
||
when the window did not already shrink for the IME. Web-side detection already
|
||
exists — `GlobalThemeService._isVisualViewportResizedForKeyboard()` — so a
|
||
future native inset can reuse that logic rather than re-derive it. Validate on
|
||
the device matrix below.
|
||
5. **Re-enable diagnostics.** Add `android.util.Log.d("SP8508", …)` in
|
||
`EdgeToEdge.applyInsetsInternal` logging `kbVisible` / `imeBottom` /
|
||
`bottomMargin` / whether a relayout fired, then `adb -d logcat -s SP8508`.
|
||
On the web side, `chrome://inspect` →
|
||
`{innerH: innerHeight, vvH: visualViewport.height, kb: getComputedStyle(document.documentElement).getPropertyValue('--keyboard-height')}`.
|
||
|
||
## #8508 follow-up — SDK 28 (Android 9): add-task bar sits BEHIND the keyboard
|
||
|
||
**Status: fix implemented (`CapacitorMainActivity.adjustWebViewForKeyboardBelowApi30`),
|
||
PENDING ON-DEVICE VALIDATION across the matrix below.** After 18.12.0 (patch
|
||
removed) a user on **Android 9 / API 28** reports the global add-task bar sits
|
||
_below / behind_ the soft keyboard. This is the realization of open item #4
|
||
above, and the device class it predicted.
|
||
|
||
**Why API 28 specifically.** The bar is positioned _only_ from
|
||
`--keyboard-height`, which `GlobalThemeService._initVisualViewportKeyboardTracking()`
|
||
derives from `obscured = window.innerHeight - visualViewport.height`. It is
|
||
correct iff **either** the window resized for the IME **or** the VisualViewport
|
||
shrank. On API 28 _neither_ does:
|
||
|
||
1. `targetSdk 36` + the `@capawesome` edge-to-edge plugin call
|
||
`setDecorFitsSystemWindows(window, false)` on **all** API levels → the window
|
||
goes edge-to-edge → the system stops resizing for the IME.
|
||
2. The plugin _does_ detect the IME on this device
|
||
(`WindowInsetsCompat.Type.ime()` reports visible) and sets WebView
|
||
`bottomMargin = 0` while the keyboard is up — `EdgeToEdge.applyInsetsInternal`:
|
||
"the system already resizes the window for the keyboard". But it does **not**
|
||
resize (point 1), so the WebView keeps its full height and the bar stays put.
|
||
_(An on-device logcat confirmed `keyboardVisible == true` here; the earlier
|
||
guess that `Type.ime()` is simply unreliable < 30 was wrong for this device.)_
|
||
3. The WebView's VisualViewport doesn't shrink either →
|
||
`obscured ≈ 0` → `--keyboard-height = 0` → the `position: fixed` bar sits
|
||
behind the keyboard.
|
||
|
||
**Do NOT "fix" this on the web side.** It is tempting to feed `--keyboard-height`
|
||
from a native height fallback (the activity already measures the IME on every
|
||
layout pass — `CapacitorMainActivity` `OnGlobalLayoutListener`:
|
||
`keypadHeight = screenHeight - rect.bottom`, reliable on every API level). The
|
||
trap: `obscured` is `≈0` in **both** the working case (window resized 732→141)
|
||
and this broken case (nothing resized), so the web side cannot tell them apart
|
||
without tracking a baseline `innerHeight` and computing
|
||
`max(obscured, nativeKbHeight - layoutShrink)` — which is **precisely the
|
||
reverted #8295 formula in "What NOT to do" below**. On a device that _does_
|
||
resize, that double-counts and floats the bar mid-screen. The web layer lacks
|
||
the signal to disambiguate; native has it unambiguously.
|
||
|
||
**Implemented fix (native, explicit WebView height while the IME is up, scoped to
|
||
API < 30) — `CapacitorMainActivity.adjustWebViewHeightForKeyboardBelowApi30`.**
|
||
Driven from the existing keyboard `OnGlobalLayoutListener`:
|
||
|
||
- while the keyboard is up: set an explicit WebView **layout height** to the
|
||
keyboard top, `height = rect.bottom − webViewTopOnScreen`
|
||
(`getWindowVisibleDisplayFrame`, reliable on API 28). 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.
|
||
- while the keyboard is down: restore the resting height
|
||
(`webViewLayoutHeightDefault`, captured at startup, e.g. `MATCH_PARENT`), so the
|
||
plugin's normal margin-based layout applies unchanged.
|
||
- gated `Build.VERSION.SDK_INT < 30`, so on API >= 30 it is a strict no-op and the
|
||
behavior verified in 18.12.0 is **untouched**.
|
||
|
||
> **Why height, not `bottomMargin` and not the plugin's listener.** The plugin owns
|
||
> `webView.bottomMargin` and rewrites it to 0 on every inset dispatch while the IME
|
||
> is visible (`EdgeToEdge.applyInsetsInternal`, because it expects the system to
|
||
> resize — which enforced edge-to-edge prevents on API < 30). Correcting the margin
|
||
> from a second writer made the bar **flicker constantly** (on-device logcat showed
|
||
> the margin alternating `0 ↔ lift` every frame); WebView bottom _padding_ doesn't
|
||
> move the web layout viewport; and fully replacing the plugin's listener fixed the
|
||
> flicker but stopped the plugin re-sizing its status/nav **color overlays**, so the
|
||
> navbar showed a **white gap**. Setting an explicit `layout_height` is the way out:
|
||
> it 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_ (insets + color overlays,
|
||
> no white gap). The target is read from the visible frame and does not depend on the
|
||
> WebView's own height, so it is stable pass-to-pass (no feedback loop).
|
||
|
||
**Upstream status (why a local workaround at all).** This is a known, repeatedly
|
||
regressed area in `@capawesome/capacitor-android-edge-to-edge-support` (pinned
|
||
8.0.8): see `capawesome-team/capacitor-plugins` #845/#490/#596/#725/#819 (closed)
|
||
and #847 (open). The buggy `keyboardVisible ? 0 : max(ime, navbar)` ternary in
|
||
`EdgeToEdge.applyInsetsInternal` is acknowledged — the maintainer redirects to
|
||
Capacitor core `ionic-team/capacitor#8466` (fixed for the **built-in** `SystemBars`
|
||
by core PR #8481, merged), and plugin PR #848 ("correct WebView margin
|
||
calculation") would fix the ternary but is **still open/unreleased**. So there is no
|
||
shipped fix on the plugin path we use; this native workaround is independent of that
|
||
timeline. Longer term, migrating to Capacitor 8's built-in `SystemBars`
|
||
(`insetsHandling`) + dropping the plugin is the maintainer's implied direction.
|
||
|
||
**Why not the web side:** `obscured` cannot distinguish "window resized" from
|
||
"nothing resized", so a web `--keyboard-height` fallback is the reverted #8295
|
||
formula. Native has the unambiguous geometry.
|
||
|
||
**Still REQUIRED before release:** validate across the device matrix below — this
|
||
area has silently regressed at #8295 and twice at #8508. Confirm on a real
|
||
API < 30 device that the bar lands flush on the keyboard top (no white gap, no
|
||
flicker) and that the status/nav-bar layout is unchanged with the keyboard down,
|
||
and on an API >= 30 device that nothing changed at all. A debug-only
|
||
`Log.d("SUPKeyboard", "webView height …")` reports each height write — in steady
|
||
state expect one per show/hide, not a stream. Remove that log before merge.
|
||
|
||
## #8508 follow-up — fullscreen markdown / notes editor squashed
|
||
|
||
**Status: CSS fix implemented, PENDING ON-DEVICE VALIDATION.** Reported on #8508:
|
||
editing a project (or task) note on Android with the keyboard up, the
|
||
`DialogFullscreenMarkdownComponent` toolbar + textarea + Close/Save controls were
|
||
squashed into the top of the screen with a large blank gap down to the keyboard.
|
||
|
||
**Why.** The bar is not the only `position: fixed` surface that must clear the
|
||
keyboard — this dialog is `position: fixed; height: 100%` too. Its keyboard rule
|
||
subtracted `--keyboard-overlay-offset`, which is set **only on iOS**, so on
|
||
Android it was a no-op. With the keyboard up the dialog therefore kept whatever
|
||
height `100%` resolved to: full (content behind the keyboard) on a non-resizing
|
||
device, or the squashed sliver on the buggy v18.11.0 WebView.
|
||
|
||
**Fix (`dialog-fullscreen-markdown.component.scss`).** Use the same
|
||
resize-detecting `--keyboard-height` the add-task bar uses for the
|
||
Android / mobile-web case; keep the iOS `--keyboard-overlay-offset` path in a
|
||
separate rule. iOS carries **both** `isNativeMobile` and `isIOS` (and sets
|
||
`--keyboard-height` non-zero), so the Android rule excludes iOS with
|
||
`:not(.isIOS)` — the two rules are mutually exclusive and order-independent
|
||
(rather than relying on equal-specificity source order):
|
||
|
||
```scss
|
||
:host-context(body.isNativeMobile:not(.isIOS).isKeyboardVisible) {
|
||
height: calc(100% - var(--keyboard-height, 0px));
|
||
}
|
||
:host-context(body.isIOS.isKeyboardVisible) {
|
||
height: calc(100% - var(--keyboard-overlay-offset, 0px) - var(--safe-area-top));
|
||
}
|
||
```
|
||
|
||
This is **not** the reverted-#8295 trap above: it reads the pure VisualViewport
|
||
`--keyboard-height`, never augments it with native data. Coverage across the
|
||
device classes this doc tracks:
|
||
|
||
- **API < 30** — the SDK 28 native fix shrinks the WebView layout height, so
|
||
`100%` is already above the keyboard and `--keyboard-height == 0`; the rule is
|
||
`100% - 0`. Works.
|
||
- **API >= 30, window resizes** (verified 18.12.0) — `--keyboard-height == 0`,
|
||
so `100% - 0`. Works.
|
||
- **API >= 30, no resize but VisualViewport shrinks** (open item #4) —
|
||
`--keyboard-height > 0` lifts the dialog above the keyboard, on par with the
|
||
add-task bar.
|
||
|
||
**Do NOT also subtract `--safe-area-top` here.** An earlier version of this fix
|
||
did (`100% - --keyboard-height - --safe-area-top`). That is a double-count:
|
||
`:host` is `border-box` (global `* { box-sizing: border-box }`) and already has
|
||
`padding-top: var(--safe-area-top)`, so the top inset is _inside_ `height: 100%`.
|
||
Subtracting it again left a `--safe-area-top`-sized gap between the Close/Save
|
||
controls and the keyboard. It was invisible while `--safe-area-top` was 0 on
|
||
API < 30, then surfaced the moment the status-bar fix above made it non-zero
|
||
(also latent on API >= 30, where env() already gave a non-zero `--safe-area-top`).
|
||
The iOS rule keeps its `- --safe-area-top` term for now — its keyboard runtime
|
||
differs (the WebView does not resize) and it is unverified on an iOS device; if
|
||
an iOS bottom gap appears, drop the term there too.
|
||
|
||
## #8508 follow-up — SDK 28 (Android 9): header draws BEHIND the status bar
|
||
|
||
**Status: fix implemented (`CapacitorMainActivity.pushStatusBarOverlapBelowApi30`),
|
||
PENDING ON-DEVICE VALIDATION.** Separate from the keyboard — on API 28 the web
|
||
header overlaps the **status bar** (no top gap), reported on #8508.
|
||
|
||
**Root cause.** Post the SystemBars migration, Android no longer writes
|
||
`--safe-area-inset-*` from JS; `--safe-area-top` resolves via the SCSS fallback
|
||
`var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` (`_css-variables.scss`).
|
||
On **API >= 35** SystemBars injects `--safe-area-inset-top`, and on **WebView >= 140**
|
||
the WebView's own `env(safe-area-inset-top)` is correct — but on the
|
||
**WebView < 140 tail** under enforced edge-to-edge the WebView extends under the
|
||
status bar while `env(safe-area-inset-top)` resolves to **0** (old WebViews map
|
||
only display _cutouts_ into safe-area insets, not the status bar). So
|
||
`--safe-area-top == 0` and content draws under the status bar (Android 9 / API 28).
|
||
|
||
**Why not a pure web-side fallback.** The web side cannot tell "WebView is
|
||
edge-to-edge under the status bar" from "WebView is already inset below it" —
|
||
`env()` is 0 in both, and adding the status-bar height blindly would double-count
|
||
in the inset case. Native has the geometry.
|
||
|
||
**Fix (native overlap → SCSS fallback) — `pushStatusBarOverlapBelowApi30`.** From
|
||
the existing keyboard `OnGlobalLayoutListener`, measure the overlap
|
||
`max(0, rect.top − webViewTopOnScreen)` — `rect.top` is the visible-frame top
|
||
(= status-bar height, reliable on API 28; the same frame the keyboard path reads)
|
||
and `getLocationOnScreen` is the WebView's top (0 edge-to-edge, == status-bar
|
||
height once inset). Publish it (physical px → CSS px, deduped) as the
|
||
`--android-status-bar-overlap` CSS var, gated **SDK < 30 AND WebView < 140**
|
||
(mirrors the keyboard shim, never fights SystemBars). The var is folded into the
|
||
SCSS fallback (`_css-variables.scss`) — NOT written from JS, so it never races
|
||
SystemBars on `--safe-area-inset-*`:
|
||
|
||
```scss
|
||
--safe-area-top: var(
|
||
--safe-area-inset-top,
|
||
max(env(safe-area-inset-top, 0px), var(--android-status-bar-overlap, 0px))
|
||
);
|
||
```
|
||
|
||
- `max()`, not a sum, so it never double-counts: WebView < 140 edge-to-edge →
|
||
env 0, overlap = status bar → status bar; once inset → env 0, overlap 0 → 0.
|
||
- On **API >= 35 / WebView >= 140** `--safe-area-inset-top` is set (SystemBars) or
|
||
env() is correct, so `var()` precedence / `max()` ignore the overlap entirely —
|
||
verified behavior untouched.
|
||
- JS readers (`_patchCdkViewportForSafeArea`) still parse the `var(max(...))`
|
||
token to 0, so overlay positioning is unchanged — preserving #8283 scoping
|
||
(only the header padding is affected).
|
||
- Known small gap: an **API 30–34** device on an **old WebView < 140** also has
|
||
env()==0 but is excluded by the SDK < 30 gate; rare (WebView auto-updates above
|
||
API 30) — broaden the gate to WebView-only if it ever surfaces.
|
||
- The var lives only as an inline style on the document, so a web-side reload
|
||
(`window.location.reload()` — language change, PWA update, sync-conflict
|
||
recovery) wipes it. The native dedupe (`lastStatusBarOverlapCssPx`) is reset in
|
||
`flushPendingShareIntent()` (runs on every frontend (re)load) so the next layout
|
||
pass re-publishes it; without the reset the unchanged value would be skipped and
|
||
the overlap would regress after a reload.
|
||
|
||
## What NOT to do
|
||
|
||
Do not stack a second/third keyboard-height source on top of the VisualViewport
|
||
signal (native physical-px height + a `baseInnerHeight`-tracking path combined as
|
||
`max(obscured, nativeKeyboardHeight - layoutShrink)`). That was #8295; the
|
||
sources race on separate async events, the baseline gets reset to the shrunk
|
||
`innerHeight` mid-animation, the double-count guard collapses, and the bar is
|
||
mispositioned. It was reverted. Fix the inset at the source, and **only after
|
||
detecting** whether the system already resized.
|
||
|
||
## Device test matrix (required before merging IME changes)
|
||
|
||
Behavior differs across devices — test the add-task bar opening the keyboard, and
|
||
typing a word fast right after tapping +, on:
|
||
|
||
- Android 10 (API 29) — pre-edge-to-edge; `Type.ime()` insets are unreliable here
|
||
- Android 14 (API 34) — edge-to-edge opt-out still possible
|
||
- Android 15 (API 35) — we opt out via `windowOptOutEdgeToEdgeEnforcement`
|
||
- Android 16 (API 36) — our target; the system was observed to still resize for
|
||
the IME on a real device
|
||
|
||
Both gesture-nav and 3-button-nav, light and dark. Confirm: no blank gap above
|
||
the keyboard, bar visible just above the keyboard, and typed characters appear in
|
||
order (not reversed).
|