From 7d4c4b18778df902eff547d8364b37b7e79edb93 Mon Sep 17 00:00:00 2001 From: Wadim Date: Mon, 15 Jun 2026 14:11:50 +0200 Subject: [PATCH] fix(android): keep add-task input above keyboard on Android 10 (#8295) * fix(android): keep add-task input above keyboard on Android 10 * fix(android): keep add-task input above keyboard on Android 10 - address comments --- .../CapacitorMainActivity.kt | 2 + .../superproductivity/FullscreenActivity.kt | 2 + .../core/theme/calc-keyboard-height.spec.ts | 118 ++++++++++++++++++ src/app/core/theme/calc-keyboard-height.ts | 40 ++++++ src/app/core/theme/global-theme.service.ts | 44 +++++-- src/app/features/android/android-interface.ts | 2 + 6 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 src/app/core/theme/calc-keyboard-height.spec.ts create mode 100644 src/app/core/theme/calc-keyboard-height.ts diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt index 6a60f24924..e960613d53 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/CapacitorMainActivity.kt @@ -185,9 +185,11 @@ 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") } } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt b/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt index d17ebe73e4..bbc0be4fe8 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/FullscreenActivity.kt @@ -153,9 +153,11 @@ 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") } } } diff --git a/src/app/core/theme/calc-keyboard-height.spec.ts b/src/app/core/theme/calc-keyboard-height.spec.ts new file mode 100644 index 0000000000..ff5e14366a --- /dev/null +++ b/src/app/core/theme/calc-keyboard-height.spec.ts @@ -0,0 +1,118 @@ +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); + }); + }); +}); diff --git a/src/app/core/theme/calc-keyboard-height.ts b/src/app/core/theme/calc-keyboard-height.ts new file mode 100644 index 0000000000..f7c267fbc3 --- /dev/null +++ b/src/app/core/theme/calc-keyboard-height.ts @@ -0,0 +1,40 @@ +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 24–48px 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); +}; diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index 055a1f065e..48a3a0476b 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -47,6 +47,7 @@ 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; @@ -649,7 +650,6 @@ 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,15 +665,28 @@ 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 obscured = window.innerHeight - vv.height; - const keyboardHeight = obscured > KEYBOARD_THRESHOLD_PX ? obscured : 0; + const raw = measureKeyboardHeight(); + const keyboardHeight = raw > KEYBOARD_THRESHOLD_PX ? raw : 0; root.style.setProperty(CSS_VAR_KEYBOARD_HEIGHT, `${keyboardHeight}px`); }; - const onViewportResize = (): void => { - const obscured = window.innerHeight - vv.height; - if (obscured <= KEYBOARD_THRESHOLD_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; if (resizeTimer !== null) { window.clearTimeout(resizeTimer); resizeTimer = null; @@ -691,9 +704,24 @@ export class GlobalThemeService { }; commit(); - vv.addEventListener('resize', onViewportResize, { passive: true }); + + 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(); + }); + } + this._destroyRef.onDestroy(() => { - vv.removeEventListener('resize', onViewportResize); + if (vv) { + vv.removeEventListener('resize', scheduleCommit); + } if (resizeTimer !== null) { window.clearTimeout(resizeTimer); } diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index d8af4fdd34..cafa2def06 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -109,6 +109,7 @@ export interface AndroidInterface { onPause$: Subject; isInBackground$: Observable; isKeyboardShown$: Subject; + keyboardHeightPx$: Subject; onShareWithAttachment$: Subject; @@ -168,6 +169,7 @@ 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)),