fix(android): revert #8295 keyboard tracking that mispositioned add-task bar

The store v18.10.0 build positions the global add-task bar correctly above
the soft keyboard and resizes normally. #8295 (merged after v18.10.0) is the
only keyboard-logic change since, and it regressed both: on this branch the
bar sits behind the IME and the view no longer appears to resize.

#8295 stacked two extra keyboard-height signals (a native physical-px height
via keyboardHeightPx$ and a baseInnerHeight-tracking VisualViewport path) on
top of the single VisualViewport `obscured` signal that shipped in v18.10.0,
combined as max(obscured, nativeKeyboardHeight - layoutShrink). The sources
fire on separate async events and race; scheduleCommit() resets
baseInnerHeight to the already-shrunk innerHeight mid-IME-animation, which
zeroes layoutShrink and defeats the double-count guard, so --keyboard-height
lands on a wrong value on devices that already handle the IME.

Revert to the known-good single-signal logic. The narrow Android 10 case
#8295 targeted will be re-addressed via a single WindowInsets IME listener
that is verified on a real device matrix.

Reverts #8295. Drops the orphaned calc-keyboard-height spec (its unrelated
#8421 backup-ring/state changes are kept).
This commit is contained in:
Johannes Millan 2026-06-16 15:14:26 +02:00
parent 4fc106890a
commit 43444e6b53
6 changed files with 8 additions and 228 deletions

View file

@ -185,11 +185,9 @@ class CapacitorMainActivity : BridgeActivity() {
if (keypadHeight > screenHeight * 0.15) {
// keyboard is opened
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "true")
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", keypadHeight.toString())
} else {
// keyboard is closed
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "false")
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", "0")
}
}

View file

@ -153,11 +153,9 @@ class FullscreenActivity : AppCompatActivity() {
if (keypadHeight > screenHeight * 0.15) {
// keyboard is opened
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "true")
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", keypadHeight.toString())
} else {
// keyboard is closed
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "false")
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", "0")
}
}
}

View file

@ -1,146 +0,0 @@
import { calcKeyboardHeight } from './calc-keyboard-height';
describe('calcKeyboardHeight', () => {
describe('visualViewport reports the IME (modern WebView, e.g. Android 14)', () => {
it('returns the obscured area', () => {
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: 500,
nativeKeyboardHeight: 0,
baseInnerHeight: 800,
}),
).toBe(300);
});
});
describe('visualViewport does NOT shrink for the IME (e.g. Android 10)', () => {
it('falls back to the native height when the viewport did not shrink', () => {
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: 800, // no shrink → obscured 0
nativeKeyboardHeight: 300,
baseInnerHeight: 800,
}),
).toBe(300);
});
it('returns 0 while the keyboard is closed', () => {
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: 800,
nativeKeyboardHeight: 0,
baseInnerHeight: 800,
}),
).toBe(0);
});
});
describe('layout viewport shrinks for the IME (adjustResize)', () => {
it('cancels the native height against the shrink to avoid a double offset', () => {
// innerHeight already shrank by 300 → bar is already above the IME → 0
expect(
calcKeyboardHeight({
innerHeight: 500,
visualViewportHeight: 500,
nativeKeyboardHeight: 300,
baseInnerHeight: 800,
}),
).toBe(0);
});
it('reports only the residual when the shrink is partial', () => {
expect(
calcKeyboardHeight({
innerHeight: 700, // shrank 100 of a 300px keyboard
visualViewportHeight: 700,
nativeKeyboardHeight: 300,
baseInnerHeight: 800,
}),
).toBe(200);
});
});
describe('rotation while the keyboard is closed (regression guard)', () => {
it('does not report a phantom keyboard from a stale portrait baseline once the baseline is refreshed', () => {
// Portrait baseline 800, rotated to landscape innerHeight 400, keyboard
// closed. With a fresh baseline (= current innerHeight) there is no
// phantom layoutShrink, so the height stays 0.
expect(
calcKeyboardHeight({
innerHeight: 400,
visualViewportHeight: 400,
nativeKeyboardHeight: 0,
baseInnerHeight: 400,
}),
).toBe(0);
});
it('reports the keyboard correctly in landscape after the baseline is refreshed', () => {
expect(
calcKeyboardHeight({
innerHeight: 400,
visualViewportHeight: 400, // no viewport shrink for IME
nativeKeyboardHeight: 250,
baseInnerHeight: 400, // refreshed on the close→landscape transition
}),
).toBe(250);
});
it('a STALE portrait baseline would have hidden the keyboard (demonstrates the bug the refresh fixes)', () => {
// baseInnerHeight still 800 (portrait) but we are in landscape (400) with
// the keyboard open → layoutShrink 400 wipes out the 250px keyboard.
expect(
calcKeyboardHeight({
innerHeight: 400,
visualViewportHeight: 400,
nativeKeyboardHeight: 250,
baseInnerHeight: 800,
}),
).toBe(0);
});
});
describe('visualViewport API absent', () => {
it('relies solely on the native height', () => {
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: null,
nativeKeyboardHeight: 300,
baseInnerHeight: 800,
}),
).toBe(300);
});
});
describe('defensive guards', () => {
it('clamps a negative obscured area to 0 (visual viewport taller than the layout viewport, e.g. pinch-zoom)', () => {
// visualViewportHeight > innerHeight → obscured is negative; must not
// report a negative keyboard height.
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: 820,
nativeKeyboardHeight: 0,
baseInnerHeight: 800,
}),
).toBe(0);
});
it('passes through a native height that exceeds the base inner height without flipping negative', () => {
// nativeKeyboardHeight > baseInnerHeight (e.g. native measurement carries
// an extra inset): nativeCovered must stay positive, not clamp to 0.
expect(
calcKeyboardHeight({
innerHeight: 800,
visualViewportHeight: 800,
nativeKeyboardHeight: 900,
baseInnerHeight: 800,
}),
).toBe(900);
});
});
});

View file

@ -1,40 +0,0 @@
export interface KeyboardHeightInput {
/** Current layout viewport height (window.innerHeight), CSS px. */
innerHeight: number;
/** window.visualViewport.height, CSS px, or null when the API is absent. */
visualViewportHeight: number | null;
/** Native-measured keyboard height (CSS px), 0 when closed. */
nativeKeyboardHeight: number;
/** window.innerHeight captured while the keyboard was last closed, CSS px. */
baseInnerHeight: number;
}
/**
* Resolve the on-screen keyboard height (CSS px) from two independent signals,
* whichever reports coverage:
*
* - `obscured` the part of the layout viewport hidden by the IME according to
* the Visual Viewport API. Correct on modern WebViews; reads 0 on e.g.
* Android 10 where the IME does not shrink the visual viewport.
* - `nativeCovered` the natively measured keyboard height, minus however much
* the layout viewport already shrank (`layoutShrink`). The subtraction keeps
* devices where `adjustResize` genuinely shrinks the viewport from being
* pushed up by a double offset (the fixed bar already sits above the IME).
*
* NOTE: `nativeKeyboardHeight` (screenHeight visibleFrame.bottom on the native
* side) includes the navigation-bar inset, not just the IME. The caller's
* threshold (~100px, larger than a typical 2448px navbar) is what discards that
* phantom offset when the keyboard is closed do not lower it without
* subtracting the navbar inset here.
*/
export const calcKeyboardHeight = ({
innerHeight,
visualViewportHeight,
nativeKeyboardHeight,
baseInnerHeight,
}: KeyboardHeightInput): number => {
const obscured = visualViewportHeight !== null ? innerHeight - visualViewportHeight : 0;
const layoutShrink = Math.max(0, baseInnerHeight - innerHeight);
const nativeCovered = Math.max(0, nativeKeyboardHeight - layoutShrink);
return Math.max(obscured, nativeCovered);
};

View file

@ -47,7 +47,6 @@ import { FlexibleConnectedPositionStrategy } from '@angular/cdk/overlay';
import { LS } from '../persistence/storage-keys.const';
import { Log } from '../log';
import { LayoutService } from '../../core-ui/layout/layout.service';
import { calcKeyboardHeight } from './calc-keyboard-height';
interface NavigationBarPlugin {
setColor(options: { color: string; style: 'LIGHT' | 'DARK' }): Promise<void>;
@ -650,6 +649,7 @@ export class GlobalThemeService {
*/
private _initVisualViewportKeyboardTracking(): void {
const vv = window.visualViewport;
if (!vv) return;
const root = this.document.documentElement;
// Filter out small differences from URL bar / overlay UI rather than the
// IME — keeps us from setting a phantom keyboard offset.
@ -665,28 +665,15 @@ export class GlobalThemeService {
const KEYBOARD_RESIZE_DEBOUNCE_MS = 200;
let resizeTimer: number | null = null;
let nativeKeyboardHeight = 0;
let baseInnerHeight = window.innerHeight;
const measureKeyboardHeight = (): number =>
calcKeyboardHeight({
innerHeight: window.innerHeight,
visualViewportHeight: vv ? vv.height : null,
nativeKeyboardHeight,
baseInnerHeight,
});
const commit = (): void => {
const raw = measureKeyboardHeight();
const keyboardHeight = raw > KEYBOARD_THRESHOLD_PX ? raw : 0;
const obscured = window.innerHeight - vv.height;
const keyboardHeight = obscured > KEYBOARD_THRESHOLD_PX ? obscured : 0;
root.style.setProperty(CSS_VAR_KEYBOARD_HEIGHT, `${keyboardHeight}px`);
};
const scheduleCommit = (): void => {
if (measureKeyboardHeight() <= KEYBOARD_THRESHOLD_PX) {
// Keyboard is (or just became) closed — keep the baseline fresh so a
// later rotation/resize doesn't leave a stale value behind.
baseInnerHeight = window.innerHeight;
const onViewportResize = (): void => {
const obscured = window.innerHeight - vv.height;
if (obscured <= KEYBOARD_THRESHOLD_PX) {
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
resizeTimer = null;
@ -704,24 +691,9 @@ export class GlobalThemeService {
};
commit();
if (vv) {
vv.addEventListener('resize', scheduleCommit, { passive: true });
}
if (IS_ANDROID_WEB_VIEW && androidInterface.keyboardHeightPx$) {
androidInterface.keyboardHeightPx$
.pipe(distinctUntilChanged(), takeUntilDestroyed(this._destroyRef))
.subscribe((physicalPx) => {
nativeKeyboardHeight = Math.max(0, physicalPx / (window.devicePixelRatio || 1));
scheduleCommit();
});
}
vv.addEventListener('resize', onViewportResize, { passive: true });
this._destroyRef.onDestroy(() => {
if (vv) {
vv.removeEventListener('resize', scheduleCommit);
}
vv.removeEventListener('resize', onViewportResize);
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}

View file

@ -109,7 +109,6 @@ export interface AndroidInterface {
onPause$: Subject<void>;
isInBackground$: Observable<boolean>;
isKeyboardShown$: Subject<boolean>;
keyboardHeightPx$: Subject<number>;
onShareWithAttachment$: Subject<AndroidShareData>;
@ -169,7 +168,6 @@ if (IS_ANDROID_WEB_VIEW) {
androidInterface.onReminderSnooze$ = new ReplaySubject(20);
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
androidInterface.keyboardHeightPx$ = new BehaviorSubject(0);
androidInterface.isInBackground$ = merge(
androidInterface.onResume$.pipe(mapTo(false)),