Skip to content

Commit ddfeb65

Browse files
authored
fix: harden android non-ascii text input and document IME workaround (#118)
1 parent 20aa005 commit ddfeb65

6 files changed

Lines changed: 192 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,13 @@ Android fill reliability:
287287
- `fill` now verifies the entered value on Android.
288288
- If value does not match, agent-device clears the field and retries once with slower typing.
289289
- This reduces IME-related character swaps on long strings (e.g. emails and IDs).
290+
- Some Android system images cannot inject non-ASCII text (for example Chinese or emoji) through shell input.
291+
- If this occurs, install an ADB keyboard IME from a trusted source, verify checksum/signature, and enable it only for test sessions:
292+
- Trusted sources: https://github.com/senzhk/ADBKeyBoard or https://f-droid.org/packages/com.android.adbkeyboard/
293+
- `adb -s <serial> install <path-to-adbkeyboard.apk>`
294+
- `adb -s <serial> shell ime enable com.android.adbkeyboard/.AdbIME`
295+
- `adb -s <serial> shell ime set com.android.adbkeyboard/.AdbIME`
296+
- `adb -s <serial> shell ime list -s` (verify current/default IME)
290297

291298
Settings helpers:
292299
- `settings wifi on|off`

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
111111
- Permission settings are app-scoped and require an active session app:
112112
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
113113
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.
114+
- On Android, non-ASCII `fill/type` may require an ADB keyboard IME on some system images; only install IME APKs from trusted sources and verify checksum/signature.
114115
- If using `--save-script`, prefer explicit path syntax (`--save-script=flow.ad` or `./flow.ad`).
115116

116117
## Security and Trust Notes

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parseAndroidLaunchComponent,
1212
setAndroidSetting,
1313
swipeAndroid,
14+
typeAndroid,
1415
} from '../index.ts';
1516
import type { DeviceInfo } from '../../../utils/device.ts';
1617
import { AppError } from '../../../utils/errors.ts';
@@ -425,6 +426,97 @@ test('parseAndroidLaunchComponent handles multi-entry resolve output', () => {
425426
);
426427
});
427428

429+
test('typeAndroid uses clipboard paste for unicode text', async () => {
430+
await withMockedAdb(
431+
'agent-device-android-type-unicode-',
432+
[
433+
'#!/bin/sh',
434+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
435+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
436+
'if [ "$1" = "-s" ]; then',
437+
' shift',
438+
' shift',
439+
'fi',
440+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "clipboard" ] && [ "$4" = "set" ] && [ "$5" = "text" ]; then',
441+
' exit 0',
442+
'fi',
443+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_PASTE" ]; then',
444+
' exit 0',
445+
'fi',
446+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "text" ]; then',
447+
' echo "unexpected fallback to input text" >&2',
448+
' exit 1',
449+
'fi',
450+
'exit 1',
451+
'',
452+
].join('\n'),
453+
async ({ argsLogPath, device }) => {
454+
await typeAndroid(device, '很 ☝ 😀');
455+
const logged = await fs.readFile(argsLogPath, 'utf8');
456+
assert.match(logged, /shell\ncmd\nclipboard\nset\ntext\n 😀/);
457+
assert.match(logged, /shell\ninput\nkeyevent\nKEYCODE_PASTE/);
458+
assert.doesNotMatch(logged, /shell\ninput\ntext/);
459+
},
460+
);
461+
});
462+
463+
test('typeAndroid uses adb input text for ascii text', async () => {
464+
await withMockedAdb(
465+
'agent-device-android-type-ascii-',
466+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
467+
async ({ argsLogPath, device }) => {
468+
await typeAndroid(device, 'hello world');
469+
const args = (await fs.readFile(argsLogPath, 'utf8'))
470+
.trim()
471+
.split('\n')
472+
.filter(Boolean);
473+
assert.deepEqual(args, [
474+
'-s',
475+
'emulator-5554',
476+
'shell',
477+
'input',
478+
'text',
479+
'hello%sworld',
480+
]);
481+
},
482+
);
483+
});
484+
485+
test('typeAndroid reports clear error when unicode input is unsupported', async () => {
486+
await withMockedAdb(
487+
'agent-device-android-type-unicode-unsupported-',
488+
[
489+
'#!/bin/sh',
490+
'if [ "$1" = "-s" ]; then',
491+
' shift',
492+
' shift',
493+
'fi',
494+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "clipboard" ] && [ "$4" = "set" ] && [ "$5" = "text" ]; then',
495+
' echo "No shell command implementation."',
496+
' exit 0',
497+
'fi',
498+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "text" ]; then',
499+
" echo \"Exception occurred while executing 'text':\" >&2",
500+
' echo "java.lang.NullPointerException" >&2',
501+
' exit 255',
502+
'fi',
503+
'echo "unexpected args: $@" >&2',
504+
'exit 1',
505+
'',
506+
].join('\n'),
507+
async ({ device }) => {
508+
await assert.rejects(
509+
() => typeAndroid(device, '很'),
510+
(error: unknown) => {
511+
assert.equal(error instanceof AppError, true);
512+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
513+
assert.match((error as AppError).message, /non-ascii text input is not supported/i);
514+
return true;
515+
},
516+
);
517+
},
518+
);
519+
});
428520
test('setAndroidSetting permission grant camera uses pm grant', async () => {
429521
await withMockedAdb(
430522
'agent-device-android-permission-camera-',

src/platforms/android/index.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -446,8 +446,24 @@ export async function longPressAndroid(
446446
}
447447

448448
export async function typeAndroid(device: DeviceInfo, text: string): Promise<void> {
449-
const encoded = text.replace(/ /g, '%s');
450-
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
449+
if (shouldUseClipboardTextInjection(text)) {
450+
const clipboardResult = await typeAndroidViaClipboard(device, text);
451+
if (clipboardResult === 'ok') return;
452+
}
453+
try {
454+
const encoded = text.replace(/ /g, '%s');
455+
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
456+
} catch (error) {
457+
if (shouldUseClipboardTextInjection(text) && isAndroidInputTextUnsupported(error)) {
458+
throw new AppError(
459+
'COMMAND_FAILED',
460+
'Non-ASCII text input is not supported on this Android shell. Install an ADB keyboard IME or use ASCII input.',
461+
{ textPreview: text.slice(0, 32) },
462+
error instanceof Error ? error : undefined,
463+
);
464+
}
465+
throw error;
466+
}
451467
}
452468

453469
export async function focusAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
@@ -460,6 +476,7 @@ export async function fillAndroid(
460476
y: number,
461477
text: string,
462478
): Promise<void> {
479+
const textCodePointLength = Array.from(text).length;
463480
const attempts = [
464481
{ clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
465482
{ clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
@@ -470,7 +487,7 @@ export async function fillAndroid(
470487

471488
for (const attempt of attempts) {
472489
const clearCount = clampCount(
473-
text.length + attempt.clearPadding,
490+
textCodePointLength + attempt.clearPadding,
474491
attempt.minClear,
475492
attempt.maxClear,
476493
);
@@ -845,15 +862,64 @@ async function typeAndroidChunked(
845862
delayMs: number,
846863
): Promise<void> {
847864
const size = Math.max(1, Math.floor(chunkSize));
848-
for (let i = 0; i < text.length; i += size) {
849-
const chunk = text.slice(i, i + size);
865+
const chars = Array.from(text);
866+
for (let i = 0; i < chars.length; i += size) {
867+
const chunk = chars.slice(i, i + size).join('');
850868
await typeAndroid(device, chunk);
851-
if (delayMs > 0 && i + size < text.length) {
869+
if (delayMs > 0 && i + size < chars.length) {
852870
await sleep(delayMs);
853871
}
854872
}
855873
}
856874

875+
function shouldUseClipboardTextInjection(text: string): boolean {
876+
for (const char of text) {
877+
const code = char.codePointAt(0);
878+
if (code === undefined) continue;
879+
if (code < 0x20 || code > 0x7e) return true;
880+
}
881+
return false;
882+
}
883+
884+
async function typeAndroidViaClipboard(
885+
device: DeviceInfo,
886+
text: string,
887+
): Promise<'ok' | 'unsupported' | 'failed'> {
888+
const setClipboard = await runCmd(
889+
'adb',
890+
adbArgs(device, ['shell', 'cmd', 'clipboard', 'set', 'text', text]),
891+
{ allowFailure: true },
892+
);
893+
if (setClipboard.exitCode !== 0) return 'failed';
894+
if (isClipboardShellUnsupported(setClipboard.stdout, setClipboard.stderr)) return 'unsupported';
895+
896+
const pasteByName = await runCmd(
897+
'adb',
898+
adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_PASTE']),
899+
{ allowFailure: true },
900+
);
901+
if (pasteByName.exitCode === 0) return 'ok';
902+
903+
const pasteByCode = await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '279']), {
904+
allowFailure: true,
905+
});
906+
return pasteByCode.exitCode === 0 ? 'ok' : 'failed';
907+
}
908+
909+
function isClipboardShellUnsupported(stdout: string, stderr: string): boolean {
910+
const haystack = `${stdout}\n${stderr}`.toLowerCase();
911+
return haystack.includes('no shell command implementation') || haystack.includes('unknown command');
912+
}
913+
914+
function isAndroidInputTextUnsupported(error: unknown): boolean {
915+
if (!(error instanceof AppError)) return false;
916+
if (error.code !== 'COMMAND_FAILED') return false;
917+
const stderr = String((error.details as any)?.stderr ?? '').toLowerCase();
918+
if (stderr.includes("exception occurred while executing 'text'")) return true;
919+
if (stderr.includes('nullpointerexception') && stderr.includes('inputshellcommand.sendtext')) return true;
920+
return false;
921+
}
922+
857923
async function clearFocusedText(device: DeviceInfo, count: number): Promise<void> {
858924
const deletes = Math.max(0, count);
859925
await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']), {

website/docs/docs/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ agent-device pinch 0.5 200 400 # zoom out at coordinates (iOS simulator)
6666

6767
`fill` clears then types. `type` does not clear.
6868
On Android, `fill` also verifies text and performs one clear-and-retry pass on mismatch.
69+
Some Android images cannot enter non-ASCII text over shell input; in that case use a trusted ADB keyboard IME and verify APK checksum/signature before install.
6970
`swipe` accepts an optional `durationMs` argument (default `250ms`, range `16..10000`).
7071
On iOS, swipe duration is clamped to a safe range (`16..60ms`) to avoid longpress side effects.
7172
`scrollintoview` accepts plain text or a snapshot ref (`@eN`); ref mode uses best-effort geometry-based scrolling without post-scroll verification. Run `snapshot` again before follow-up `@ref` commands.

website/docs/docs/known-limitations.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,22 @@ This is an Apple platform constraint that affects all XCUITest-based automation
1919
echo "some text" | xcrun simctl pbcopy booted
2020
```
2121
- **Test the dialog manually** — the "Allow Paste" UX cannot be exercised through XCUITest-based automation.
22+
23+
## Android: non-ASCII text over `adb shell input text`
24+
25+
Some Android system images fail to inject non-ASCII text (for example Chinese characters or emoji) through `adb shell input text`.
26+
27+
**Workarounds:**
28+
29+
- **Use an ADB keyboard IME for test runs**:
30+
```bash
31+
adb -s <serial> install <path-to-adbkeyboard.apk>
32+
adb -s <serial> shell ime enable com.android.adbkeyboard/.AdbIME
33+
adb -s <serial> shell ime set com.android.adbkeyboard/.AdbIME
34+
```
35+
- **Use trusted APK sources only** (official project: https://github.com/senzhk/ADBKeyBoard or F-Droid: https://f-droid.org/packages/com.android.adbkeyboard/), and verify checksum/signature before installing.
36+
- **Revert to your normal IME after automation**:
37+
```bash
38+
adb -s <serial> shell ime list -s
39+
adb -s <serial> shell ime set <previous-ime-id>
40+
```

0 commit comments

Comments
 (0)