Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c4b71cc
fix(android): rebind the test IME when its commit went to a stale inp…
okwasniewski Sep 29, 2026
7febfb4
fix(android): confirm the IME rebind and cover a pre-filled field
okwasniewski Sep 29, 2026
153e257
refactor(android): the IME retry preparation is its own step
okwasniewski Sep 29, 2026
8dd4668
fix(android): keep test IME ownership when the rebind cannot confirm …
thymikee Sep 30, 2026
bad3a6c
test(android): a fill that leaves a pre-filled field on its hint fails
thymikee Sep 30, 2026
f7e1e04
fix(android): serialize the IME rebind and record it on the device
thymikee Sep 30, 2026
580217b
fix(android): a canceled request is not an unconfirmed IME rebind
thymikee Sep 30, 2026
ad5683c
test(android): the IME lifecycle fake answers the rebind record
thymikee Sep 30, 2026
a258fbc
fix(android): fail closed when the IME rebind record cannot be read o…
thymikee Sep 30, 2026
63d6030
Merge branch 'main' into oskar/ime-rebinds-stale-input-session
thymikee Oct 2, 2026
d7f97bd
refactor(android): model the IME rebind as its own transaction over o…
thymikee Oct 2, 2026
f84df40
refactor(android): split the activation's restore-target persistence …
thymikee Oct 2, 2026
f12e917
test(android): the startup-race device double answers the rebind record
thymikee Oct 2, 2026
6622d34
fix(android): load the IME rebind on demand to keep the mechanics clo…
thymikee Oct 2, 2026
63f0ced
fix(android): fail closed on incomplete IME rebinds
thymikee Oct 6, 2026
b9b2d06
Merge remote-tracking branch 'origin/main' into fix/3061-ime-rebind
thymikee Oct 6, 2026
937eea9
refactor(android): simplify IME recovery ownership and tests
thymikee Oct 6, 2026
a8acc11
refactor(android): reduce IME lifecycle branching
thymikee Oct 6, 2026
b973ec7
fix(android): retain rebind record cleanup diagnostics
thymikee Oct 6, 2026
eef741a
test(android): synchronize competing IME restore
thymikee Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
// command-scoped capture stops the automation-helper session after every one of those reads and the
// next read pays a fresh `am instrument` start. These tests pin who owns the helper session across
// the samples and how long the samples keep coming — not what one sample concludes, which
// fill-diagnostics/input-actions-fill own.
// fill-diagnostics/input-actions-fill own. The one conclusion pinned here is whether any of a
// commit reached the field, which both the soft-success guard and the IME rebind share.

import { afterEach, beforeEach, test } from 'vitest';
import './test-utils/android-host-test-setup.ts';
import assert from 'node:assert/strict';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts';
import type { AndroidFillVerificationNode } from '../fill-diagnostics.ts';
import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts';
import {
isAndroidFillCommitDropped,
readAndroidFillTargetBeforeMutation,
verifyAndroidFilledText,
type FillVerificationClock,
Expand Down Expand Up @@ -228,3 +231,42 @@ function withKeyboardStateProbe(exec: AndroidAdbProvider['exec']): AndroidAdbPro
function filledFieldXml(text: string): string {
return `<?xml version="1.0" encoding="UTF-8"?><hierarchy><node package="com.example" class="android.widget.EditText" text="${text}" resource-id="com.example:id/field" focused="true" bounds="[0,0][200,100]"/></hierarchy>`;
}

function fillNode(text: string | null, hintShowing = false): AndroidFillVerificationNode {
return {
text,
className: 'android.widget.EditText',
resourceId: null,
packageName: 'com.example',
rect: { x: 0, y: 0, width: 200, height: 100 },
focused: true,
password: false,
inputMethodOwned: false,
area: 20_000,
hintShowing,
placeholder: null,
};
}

test('a commit counts as dropped when the field shows its hint or keeps its old value', () => {
const before = fillNode('Old');
const observed = (text: string | null, hintShowing = false) => ({
ok: false,
actual: text,
targetInput: fillNode(text, hintShowing),
actualInput: fillNode(text, hintShowing),
});

assert.equal(isAndroidFillCommitDropped(observed('e.g. Jane', true), before), true);
assert.equal(isAndroidFillCommitDropped(observed('Old'), before), true);
assert.equal(isAndroidFillCommitDropped(observed(null), before), true);
assert.equal(
isAndroidFillCommitDropped(
{ ok: false, actual: null, actualInput: null, targetInput: null },
before,
),
false,
);
assert.equal(isAndroidFillCommitDropped(observed('Jan'), before), false);
assert.equal(isAndroidFillCommitDropped(observed('Jan'), null), false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
const HELPER_SERVICE = 'com.callstack.agentdevice.imehelper/.TestInputMethodService';
const LATIN_IME = 'com.google.android.inputmethod.latin/.LatinIME';
const PREVIOUS_IME_KEY = 'agent_device_ime_helper_previous_ime';
const REBIND_DISPLACED_KEY = 'agent_device_ime_helper_rebind_displaced';
const STATE_DIR = '/ime-race-state';
const SERIAL = ANDROID_EMULATOR.id;

Expand Down Expand Up @@ -83,6 +84,9 @@ test('planted race: startup recovery cannot retire an activation marker before t
if (args.join(' ') === `shell settings get secure ${PREVIOUS_IME_KEY}`) {
return { exitCode: 0, stdout: `${previousIme ?? 'null'}\n`, stderr: '' };
}
if (args.join(' ') === `shell settings get secure ${REBIND_DISPLACED_KEY}`) {
return { exitCode: 0, stdout: 'null\n', stderr: '' };
}
if (args.slice(0, 5).join(' ') === `shell settings put secure ${PREVIOUS_IME_KEY}`) {
previousIme = args[5];
return { exitCode: 0, stdout: '', stderr: '' };
Expand Down
5 changes: 5 additions & 0 deletions packages/platform-android/src/__tests__/ime-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { bindAndroidAdbTestHost } from './test-utils/android-host-test-setup.ts'

const HELPER_SERVICE = 'com.callstack.agentdevice.imehelper/.TestInputMethodService';
const SETTINGS_KEY = 'agent_device_ime_helper_previous_ime';
const REBIND_DISPLACED_KEY = 'agent_device_ime_helper_rebind_displaced';
const PENDING_DIR = 'android-test-ime-pending';

// activateAndroidTestIme reads the bundled artifact for the service component; inject a fixture so
Expand Down Expand Up @@ -92,6 +93,7 @@ function ok(stdout = ''): FakeAdbResult {
function fakeDeviceState(initialIme: string) {
let defaultIme = initialIme;
let previousImeRecord: string | undefined;
let rebindDisplacedRecord: string | undefined;
let installed = false;
let failPersist = false;
let mismatchNextPersistReadback = false;
Expand Down Expand Up @@ -123,10 +125,12 @@ function fakeDeviceState(initialIme: string) {
return ok('com.example.stale/.Ime');
}
if (key === SETTINGS_KEY) return ok(previousImeRecord ?? 'null');
if (key === REBIND_DISPLACED_KEY) return ok(rebindDisplacedRecord ?? 'null');
throw new Error(`unexpected settings get key: ${String(key)}`);
}

function handleSettingsPut(args: readonly string[]): FakeAdbResult {
if (args[4] === REBIND_DISPLACED_KEY) rebindDisplacedRecord = args[5];
if (args[4] === SETTINGS_KEY) {
if (failPersist) return { exitCode: 1, stdout: '', stderr: 'rejected' };
previousImeRecord = args[5];
Expand All @@ -140,6 +144,7 @@ function fakeDeviceState(initialIme: string) {

function handleSettingsDelete(args: readonly string[]): FakeAdbResult {
if (args[4] === SETTINGS_KEY) previousImeRecord = undefined;
if (args[4] === REBIND_DISPLACED_KEY) rebindDisplacedRecord = undefined;
return ok();
}

Expand Down
27 changes: 27 additions & 0 deletions packages/platform-android/src/__tests__/text-input-fill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,33 @@ test('fillAndroid returns target-bound unconfirmed evidence when an input mask r
assert.equal(calls.length, 1, 'an ambiguous committed fill must not be repeated');
});

test('fillAndroid fails, not unconfirmed, when a fill leaves a pre-filled field on its hint', async () => {
let hintShowing = false;
await withAndroidAdbProvider(
{
snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT,
exec: createAndroidSnapshotHelperExecutor({
exec: async (args) => {
throw new Error(`unexpected adb call: ${args.join(' ')}`);
},
captureXml: () =>
hintShowing
? `<?xml version="1.0" encoding="UTF-8"?><hierarchy><node package="com.example" class="android.widget.EditText" text="Phone" hint="Phone" hint-showing="true" resource-id="com.example:id/phone" focused="true" bounds="[0,0][200,100]"/></hierarchy>`
: phoneInputXml('12 123 4567'),
}),
text: async () => {
hintShowing = true;
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
await assert.rejects(fillAndroid(ANDROID_EMULATOR, 100, 50, '0501234567'), {
code: 'COMMAND_FAILED',
});
},
);
});

test('fillAndroid waits for settled app text before reporting success', async () => {
let typed = '';
let dumpCount = 0;
Expand Down
161 changes: 161 additions & 0 deletions packages/platform-android/src/__tests__/text-input-test-ime.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import type { AppError } from '@agent-device/kernel/errors';

const PACKAGE = 'com.callstack.agentdevice.imehelper';

Expand Down Expand Up @@ -44,9 +45,17 @@ import {
import { fillAndroid, typeAndroid } from '../text-input.ts';
import { withAndroidAdbProvider, type AndroidAdbExecutor } from '../adb-executor.ts';
import {
isAndroidTestImeActive,
resetAndroidTestImeActivationCacheForTests,
restoreAndroidTestIme,
setAndroidTestImeActiveForTests,
} from '../ime-lifecycle.ts';
import { fakeImeDeviceAdb, type FakeImeDeviceState } from '../ime-device.fixtures.ts';
import { getAndroidTestImeOwnership } from '../ime-state.ts';
import {
readAndroidTestImeRecoveryMarkers,
writeAndroidTestImeRecoveryMarker,
} from '../ime-recovery-marker.ts';

afterEach(() => {
resetAndroidTestImeActivationCacheForTests();
Expand Down Expand Up @@ -230,6 +239,7 @@ test('fillAndroid re-focuses the target when the first helper attempt fails veri
const adb: AndroidAdbExecutor = createAndroidSnapshotHelperExecutor({
exec: async (args) => {
calls.push(args);
if (args[1] === 'settings' && args[2] === 'get') return helperSelected(args);
if (args[1] === 'am' && args[2] === 'broadcast') {
const action = args[args.indexOf('-a') + 1];
if (action === 'com.callstack.agentdevice.imehelper.ACTION_CLEAR_TEXT') {
Expand Down Expand Up @@ -257,6 +267,11 @@ test('fillAndroid re-focuses the target when the first helper attempt fails veri
);

assert.equal(currentText, 'filed the expense');
assert.deepEqual(
calls.filter((args) => args[1] === 'ime').map((args) => args[2]),
['disable', 'enable', 'set'],
'a commit that left the field on its old value may have gone to a stale session',
);
assert.equal(
calls.filter((args) => args[1] === 'input' && args[2] === 'tap').length,
2,
Expand All @@ -270,6 +285,143 @@ test('fillAndroid re-focuses the target when the first helper attempt fails veri
);
});

test.each([true, false])(
'fillAndroid recovers a cleared field with hint=%s',
async (hintShowing) => {
setAndroidTestImeActiveForTests(ANDROID_EMULATOR, true);
let rebound = false;
let currentText = 'old name';
const calls: (readonly string[])[] = [];
const adb: AndroidAdbExecutor = createAndroidSnapshotHelperExecutor({
exec: async (args) => {
calls.push(args);
if (args[1] === 'settings' && args[2] === 'get') return helperSelected(args);
if (args[1] === 'ime' && args[2] === 'set') rebound = true;
const action = args[args.indexOf('-a') + 1];
// The clear lands, then the session goes stale and drops the commit until the rebind.
if (action === 'com.callstack.agentdevice.imehelper.ACTION_CLEAR_TEXT') currentText = '';
if (rebound && action === 'com.callstack.agentdevice.imehelper.ACTION_INPUT_TEXT_B64') {
currentText += decodeBroadcastText(args);
}
return { exitCode: 0, stdout: '', stderr: '' };
},
captureXml: () =>
currentText
? androidInputXml({ text: currentText })
: hintShowing
? `<?xml version="1.0" encoding="UTF-8"?><hierarchy><node package="com.example" class="android.widget.EditText" text="e.g. Jane" hint="e.g. Jane" hint-showing="true" focused="true" bounds="[0,0][200,100]"/></hierarchy>`
: androidInputXml({ text: '' }),
});

const result = await withAndroidAdbProvider(
{ exec: adb, snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT },
{ serial: ANDROID_EMULATOR.id },
async () => await fillAndroid(ANDROID_EMULATOR, 10, 10, 'Jane'),
);

assert.equal(result, undefined, 'a verified fill, not unconfirmed evidence');
assert.equal(currentText, 'Jane');
assert.deepEqual(
calls.filter((args) => args[1] === 'ime').map((args) => args[2]),
['disable', 'enable', 'set'],
);
const commands = calls.map((args) => `${args[1]} ${args[2]}`);
assert.ok(commands.lastIndexOf('ime set') < commands.lastIndexOf('input tap'));
},
);

test('fillAndroid stops on an unconfirmed rebind and leaves the helper for close-time restore', async () => {
const stateDir = await mkdtempForTest('agent-device-ime-rebind-');
setAndroidTestImeActiveForTests(ANDROID_EMULATOR, true, stateDir);
await writeAndroidTestImeRecoveryMarker(stateDir, ANDROID_EMULATOR.id);
const device: FakeImeDeviceState = {
settings: new Map([
['default_input_method', 'com.callstack.agentdevice.imehelper/.TestInputMethodService'],
['agent_device_ime_helper_previous_ime', 'com.samsung/.Keyboard'],
]),
// `settings get` timing out under load: the rebind cannot read back which IME is selected.
inputMethodReadFails: true,
};
const deviceAdb = fakeImeDeviceAdb(device);
let commits = 0;
const adb: AndroidAdbExecutor = createAndroidSnapshotHelperExecutor({
exec: async (args) => {
if (args[1] === 'settings' || args[1] === 'ime') return await deviceAdb(args);
if (args.includes('com.callstack.agentdevice.imehelper.ACTION_INPUT_TEXT_B64')) commits += 1;
return { exitCode: 0, stdout: '', stderr: '' };
},
captureXml: () =>
`<?xml version="1.0" encoding="UTF-8"?><hierarchy><node package="com.example" class="android.widget.EditText" text="e.g. Jane" hint="e.g. Jane" hint-showing="true" focused="true" bounds="[0,0][200,100]"/></hierarchy>`,
});

await withAndroidAdbProvider(
{ exec: adb, snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT },
{ serial: ANDROID_EMULATOR.id },
async () => {
await assert.rejects(fillAndroid(ANDROID_EMULATOR, 10, 10, 'Jane'), (error: AppError) => {
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.reason, 'android_test_ime_rebind_unconfirmed');
assert.equal(error.details?.rebindCause, 'read-failed');
return true;
});
},
);

assert.equal(commits, 1, 'no commit goes to an IME the rebind could not confirm');
assert.equal(isAndroidTestImeActive(ANDROID_EMULATOR), true, 'ownership stays with restore');
assert.equal(
device.settings.get('agent_device_ime_helper_previous_ime'),
'com.samsung/.Keyboard',
'the rebind leaves the durable restore record alone',
);

device.inputMethodReadFails = false;
const restored = await withAndroidAdbProvider(
{ exec: adb, snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT },
{ serial: ANDROID_EMULATOR.id },
async () => await restoreAndroidTestIme(ANDROID_EMULATOR, { stateDir }),
);

assert.equal(restored.reason, 'ok');
assert.equal(device.settings.get('default_input_method'), 'com.samsung/.Keyboard');
assert.deepEqual(await readAndroidTestImeRecoveryMarkers(stateDir), []);
});

test('typeAndroid never broadcasts into a helper whose rebind went unconfirmed', async () => {
setAndroidTestImeActiveForTests(ANDROID_EMULATOR, true);
const ownership = getAndroidTestImeOwnership(ANDROID_EMULATOR);
assert.ok(ownership);
ownership.rebindUnconfirmed = true;
const device: FakeImeDeviceState = {
settings: new Map([['default_input_method', 'com.android.inputmethod.latin/.LatinIME']]),
imeSetFails: true,
};
const deviceAdb = fakeImeDeviceAdb(device);
const broadcasts: (readonly string[])[] = [];

await withAndroidAdbProvider(
async (args) => {
if (args[1] === 'am') {
broadcasts.push(args);
return { exitCode: 0, stdout: '', stderr: '' };
}
return await deviceAdb(args);
},
{ serial: ANDROID_EMULATOR.id },
async () => {
await assert.rejects(
typeAndroid(ANDROID_EMULATOR, 'Jane'),
(error: { code?: string; details?: { reason?: string } }) =>
error.code === 'COMMAND_FAILED' &&
error.details?.reason === 'android_test_ime_rebind_unconfirmed',
);
},
);

assert.deepEqual(broadcasts, []);
assert.equal(device.settings.get('agent_device_ime_helper_rebind_displaced'), '1');
});

// Unicode is only beyond the *shell* path. Refusing it before reading which IME is active denied
// the broadcast channel to exactly the devices that could serve it: helper active, cache empty.

Expand Down Expand Up @@ -418,3 +570,12 @@ function helperImeInputMethodDump(): string {
function androidInputXml(options: { text: string }): string {
return `<?xml version="1.0" encoding="UTF-8"?><hierarchy><node package="com.example" class="android.widget.EditText" text="${options.text}" focused="true" bounds="[0,0][200,100]"/></hierarchy>`;
}

/** Settings reads after a rebind: its device record reads back, and the helper is still selected. */
function helperSelected(args: readonly string[]) {
const stdout =
args[4] === 'agent_device_ime_helper_rebind_displaced'
? '1\n'
: 'com.callstack.agentdevice.imehelper/.TestInputMethodService\n';
return { exitCode: 0, stdout, stderr: '' };
}
15 changes: 13 additions & 2 deletions packages/platform-android/src/fill-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ export function completeAndroidFillVerification(
);
}

/** Whether a failed fill left an observed field empty, showing its hint, or holding its old value. */
export function isAndroidFillCommitDropped(
verification: AndroidFillVerification,
beforeTarget: AndroidFillVerification['targetInput'],
): boolean {
if (verification.ok) return false;
const input = verification.actualInput;
if (input && (input.hintShowing === true || input.text === null)) return true;
return beforeTarget?.text != null && verification.actual === beforeTarget.text;
}

export function buildAndroidFillUnconfirmedVerification(
requested: string,
beforeTarget: AndroidFillVerification['targetInput'],
Expand All @@ -164,15 +175,15 @@ export function buildAndroidFillUnconfirmedVerification(
// and the soft-success would also skip the second, bigger delete burst.
requested.length === 0 ||
verification.reason === 'ime_capture' ||
isAndroidFillCommitDropped(verification, beforeTarget) ||
!beforeTarget ||
!afterTarget ||
!actualInput ||
isSensitiveFillDiagnosticNode(beforeTarget) ||
isSensitiveFillDiagnosticNode(afterTarget) ||
isSensitiveFillDiagnosticNode(actualInput) ||
!sameAndroidFillTarget(beforeTarget, afterTarget) ||
!sameAndroidFillTarget(beforeTarget, actualInput) ||
beforeTarget.text === verification.actual
!sameAndroidFillTarget(beforeTarget, actualInput)
) {
return null;
}
Expand Down
Loading
Loading