docs/android-edge-to-edge-keyboard.md
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-supportto Capacitor's built-inSystemBars(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 viaNavigationBarPlugin.setWebViewBackgroundColor(window decor + WebView surface). The #8508 sections below describe the former@capawesomemechanics and are kept as history. Full rationale + device matrix:docs/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.
The global add-task bar is position: fixed and lifted off the bottom by a CSS
variable only:
// 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).
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).
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.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).@angular/* 21.2.11 → 21.2.17 bump (commit f51954f80) — the
only other IME-adjacent change in the release.--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.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')}.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:
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.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.)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:
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.webViewLayoutHeightDefault, captured at startup, e.g. MATCH_PARENT), so the
plugin's normal margin-based layout applies unchanged.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
bottomMarginand not the plugin's listener. The plugin ownswebView.bottomMarginand 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 alternating0 ↔ liftevery 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 explicitlayout_heightis 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.
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):
: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:
100% is already above the keyboard and --keyboard-height == 0; the rule is
100% - 0. Works.--keyboard-height == 0,
so 100% - 0. Works.--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.
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-*:
--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.--safe-area-inset-top is set (SystemBars) or
env() is correct, so var() precedence / max() ignore the overlap entirely —
verified behavior untouched._patchCdkViewportForSafeArea) still parse the var(max(...))
token to 0, so overlay positioning is unchanged — preserving #8283 scoping
(only the header padding is affected).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.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.
Behavior differs across devices — test the add-task bar opening the keyboard, and typing a word fast right after tapping +, on:
Type.ime() insets are unreliable herewindowOptOutEdgeToEdgeEnforcementBoth 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).