fix(app): reveal Android settings inputs above the keyboard

PageLayout kept its full-height scroll viewport while the keyboard controller handled Android insets, leaving Code size covered. Use the existing keyboard-aware scroller for Android pages and cover the real focused field and keyboard geometry in the mobile regression harness.
This commit is contained in:
paseo-bot[bot]
2026-10-19 05:52:55 +02:00
parent 727d5eed59
commit 4757c0676e
2 changed files with 110 additions and 3 deletions
@@ -0,0 +1,100 @@
// Run against an Android dev client serving this checkout, with a real software keyboard.
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> [app-id]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl, appId = "sh.paseo.debug"] = process.argv;
assert(session && metroUrl, "Pass the agent-device session and Metro URL");
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
}
// Read the mounted app's public React Native APIs through Metro. Accessibility
// snapshots with Gboard open can block while waiting for an idle hierarchy.
const reactNative = `Array.from(__r.getModules().values()).find(
m => m.verboseName?.endsWith("/react-native/index.js")
).publicModule.exports`;
function measureFocusedInput() {
evaluate(`(() => {
const rn = ${reactNative};
globalThis.__appearanceKeyboardMeasurement = null;
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
x, y, width, height,
statusBarHeight: rn.StatusBar.currentHeight,
scale: rn.PixelRatio.get(),
keyboard: rn.Keyboard.metrics(),
});
});
})()`);
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
return value ? JSON.parse(value) : null;
}
ad("open", appId, "--platform", "android", "--no-test-ime");
const target = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.find((line) => line.includes(appId))
?.split(/\s+/)[0];
assert(target, "The app must be connected to this Metro server");
ad("cdp", "target", "select", target);
try {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Appearance", "5000");
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
ad("wait", "text", "Code size", "5000");
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
const numericFields = snapshot.data.nodes.filter(
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
);
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
// Appearance orders Interface size, Content size, then Code size.
ad("press", `@${numericFields[2].ref}`);
let measurement;
let previous;
let stable = 0;
const deadline = Date.now() + 30_000;
do {
measurement = measureFocusedInput();
const current = measurement?.keyboard
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
: null;
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
previous = current;
if (stable >= 3) break;
await delay(150);
} while (Date.now() < deadline);
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
const keyboardTop = measurement.keyboard.screenY;
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
ad("screenshot", resolve(artifacts, "keyboard.png"));
assert(
inputBottom <= keyboardTop + 1 / measurement.scale,
"Code size must be fully visible above the number keyboard (within one physical pixel)",
);
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
} finally {
evaluate(`(${reactNative}).Keyboard.dismiss()`);
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
}
@@ -1,11 +1,14 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
const PageScrollView = Platform.OS === "android" ? KeyboardAwareScrollView : ScrollView;
interface PageLayoutProps {
title?: string;
/** Compact back button. Defaults to navigating back. */
@@ -42,7 +45,11 @@ export function PageLayout({
) : (
<MenuHeader borderless />
)}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<PageScrollView
style={styles.scroll}
contentContainerStyle={scrollContentStyle}
testID={testID}
>
<View style={styles.content}>
{showTitle ? (
<View style={styles.titleRow} testID={titleTestID}>
@@ -54,7 +61,7 @@ export function PageLayout({
) : null}
{children}
</View>
</ScrollView>
</PageScrollView>
</View>
);
}