Skip to content

Commit 3671340

Browse files
committed
fix: stabilize Android replay interactions
1 parent 308d2af commit 3671340

5 files changed

Lines changed: 97 additions & 10 deletions

File tree

src/platforms/android/__tests__/index.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,6 +1720,35 @@ test('getAndroidKeyboardState uses latest visibility value when dumpsys contains
17201720
);
17211721
});
17221722

1723+
test('getAndroidKeyboardState treats stale input view as hidden when the IME window is hidden', async () => {
1724+
await withMockedAdb(
1725+
'agent-device-android-keyboard-stale-input-view-',
1726+
[
1727+
'#!/bin/sh',
1728+
'if [ "$1" = "-s" ]; then',
1729+
' shift',
1730+
' shift',
1731+
'fi',
1732+
'if [ "$1" = "shell" ] && [ "$2" = "dumpsys" ] && [ "$3" = "input_method" ]; then',
1733+
' echo "mInputShown=false"',
1734+
' echo "mDecorViewVisible=false mWindowVisible=false mInShowWindow=false"',
1735+
' echo "mIsInputViewShown=true"',
1736+
' echo "inputType=0x21"',
1737+
' exit 0',
1738+
'fi',
1739+
'echo "unexpected args: $@" >&2',
1740+
'exit 1',
1741+
'',
1742+
].join('\n'),
1743+
async ({ device }) => {
1744+
const state = await getAndroidKeyboardState(device);
1745+
assert.equal(state.visible, false);
1746+
assert.equal(state.inputType, '0x21');
1747+
assert.equal(state.type, 'email');
1748+
},
1749+
);
1750+
});
1751+
17231752
test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => {
17241753
await withMockedAdb(
17251754
'agent-device-android-keyboard-dismiss-hidden-',

src/platforms/android/device-input-state.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,18 +185,28 @@ function parseLastDumpsysValue(stdout: string, pattern: RegExp): string | undefi
185185

186186
function parseAndroidKeyboardVisibility(stdout: string): boolean | null {
187187
const latestByKey = new Map<string, boolean>();
188-
const pattern = /\b(mInputShown|mIsInputViewShown|isInputViewShown)=([a-zA-Z]+)\b/g;
188+
const pattern =
189+
/\b(mInputShown|mIsInputViewShown|isInputViewShown|mDecorViewVisible|mWindowVisible|mInShowWindow)=([a-zA-Z]+)\b/g;
189190
for (const match of stdout.matchAll(pattern)) {
190191
const key = match[1];
191192
const value = match[2]?.toLowerCase();
192193
if (!key || (value !== 'true' && value !== 'false')) continue;
193194
latestByKey.set(key, value === 'true');
194195
}
195196
if (latestByKey.size === 0) return null;
196-
for (const visible of latestByKey.values()) {
197-
if (visible) return true;
198-
}
199-
return false;
197+
198+
const windowVisible =
199+
latestByKey.get('mWindowVisible') ??
200+
latestByKey.get('mDecorViewVisible') ??
201+
latestByKey.get('mInShowWindow');
202+
if (windowVisible !== undefined) return windowVisible;
203+
204+
const inputShown = latestByKey.get('mInputShown');
205+
if (inputShown !== undefined) return inputShown;
206+
207+
const inputViewShown =
208+
latestByKey.get('mIsInputViewShown') ?? latestByKey.get('isInputViewShown');
209+
return inputViewShown ?? null;
200210
}
201211

202212
function classifyAndroidKeyboardType(inputType: string): AndroidKeyboardType {

src/platforms/android/fill-verification.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { sleep } from './adb.ts';
1212
import { getAndroidKeyboardState } from './device-input-state.ts';
1313
import { isAndroidInputMethodOwnedNode } from './input-ownership.ts';
14-
import { dumpUiHierarchy } from './snapshot.ts';
14+
import { captureAndroidUiHierarchyXml } from './snapshot.ts';
1515
import { androidUiNodes, type AndroidUiNodeMetadata } from './ui-hierarchy.ts';
1616

1717
export type AndroidFillVerificationNode = FillDiagnosticNode & {
@@ -90,7 +90,7 @@ export async function readAndroidTextAtPoint(
9090
x: number,
9191
y: number,
9292
): Promise<string | null> {
93-
return readAndroidTextAtPointInHierarchy(await dumpUiHierarchy(device), x, y);
93+
return readAndroidTextAtPointInHierarchy(await captureAndroidUiHierarchyXml(device), x, y);
9494
}
9595

9696
export function verifyAndroidFilledTextInHierarchy(
@@ -154,7 +154,13 @@ async function inspectAndroidFilledText(
154154
expected: string,
155155
context: AndroidFillVerificationContext,
156156
): Promise<AndroidFillVerification> {
157-
return verifyAndroidFilledTextInHierarchy(await dumpUiHierarchy(device), x, y, expected, context);
157+
return verifyAndroidFilledTextInHierarchy(
158+
await captureAndroidUiHierarchyXml(device),
159+
x,
160+
y,
161+
expected,
162+
context,
163+
);
158164
}
159165

160166
function inspectAndroidTextAtPointInHierarchy(

src/platforms/android/input-actions.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-
66
import { runAndroidAdb, sleep } from './adb.ts';
77
import { resolveAndroidTextInjector } from './adb-executor.ts';
88
import { getAndroidKeyboardState, type AndroidKeyboardState } from './device-input-state.ts';
9+
import { captureAndroidUiHierarchyXml } from './snapshot.ts';
10+
import { androidUiNodes } from './ui-hierarchy.ts';
911
import {
1012
androidFillFailureDetails,
1113
androidFillFailureMessage,
@@ -206,7 +208,7 @@ export async function scrollAndroid(
206208
direction: ScrollDirection,
207209
options?: { amount?: number; pixels?: number },
208210
): Promise<Record<string, unknown>> {
209-
const size = await getAndroidScreenSize(device);
211+
const size = await getAndroidGestureViewportSize(device);
210212
const plan = buildScrollGesturePlan({
211213
direction,
212214
amount: options?.amount,
@@ -290,6 +292,38 @@ export async function getAndroidScreenSize(
290292
return { width: Number(match[1]), height: Number(match[2]) };
291293
}
292294

295+
async function getAndroidGestureViewportSize(
296+
device: DeviceInfo,
297+
): Promise<{ width: number; height: number }> {
298+
try {
299+
const xml = await captureAndroidUiHierarchyXml(device);
300+
const viewport = largestAndroidUiNodeRect(xml);
301+
if (viewport) return viewport;
302+
} catch (error) {
303+
emitDiagnostic({
304+
level: 'warn',
305+
phase: 'android_gesture_viewport_probe_failed',
306+
data: {
307+
error: error instanceof Error ? error.message : String(error),
308+
},
309+
});
310+
}
311+
return await getAndroidScreenSize(device);
312+
}
313+
314+
function largestAndroidUiNodeRect(xml: string): { width: number; height: number } | null {
315+
let largest: { width: number; height: number; area: number } | null = null;
316+
for (const node of androidUiNodes(xml)) {
317+
const rect = node.rect;
318+
if (!rect || rect.width <= 0 || rect.height <= 0) continue;
319+
const area = rect.width * rect.height;
320+
if (!largest || area > largest.area) {
321+
largest = { width: rect.x + rect.width, height: rect.y + rect.height, area };
322+
}
323+
}
324+
return largest ? { width: largest.width, height: largest.height } : null;
325+
}
326+
293327
const ANDROID_INPUT_TEXT_CHUNK_SIZE = 8;
294328

295329
async function typeAndroidShell(

src/platforms/android/snapshot.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,22 @@ const RETRYABLE_ADB_STDERR_PATTERNS = [
6060
'no such file or directory',
6161
] as const;
6262

63-
type AndroidSnapshotOptions = SnapshotOptions & {
63+
export type AndroidSnapshotOptions = SnapshotOptions & {
6464
helperArtifact?: AndroidSnapshotHelperArtifact;
6565
helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy;
6666
helperAdb?: AndroidAdbExecutor | AndroidAdbProvider;
6767
helperWaitForIdleTimeoutMs?: number;
6868
includeHiddenContentHints?: boolean;
6969
};
7070

71+
export async function captureAndroidUiHierarchyXml(
72+
device: DeviceInfo,
73+
options: AndroidSnapshotOptions = {},
74+
): Promise<string> {
75+
const adb = resolveAndroidAdbProvider(device, options.helperAdb).exec;
76+
return (await captureAndroidUiHierarchy(device, options, adb)).xml;
77+
}
78+
7179
export async function snapshotAndroid(
7280
device: DeviceInfo,
7381
options: AndroidSnapshotOptions = {},

0 commit comments

Comments
 (0)