Skip to content

Commit 0c4b125

Browse files
authored
fix: prevent Android fill/type truncation with + and @ (#142)
* fix: encode Android shell text input for special ASCII * fix: harden android fill fallback strategy * chore: simplify android fill fallback flow
1 parent 2a86f52 commit 0c4b125

2 files changed

Lines changed: 141 additions & 9 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import {
7+
fillAndroid,
78
inferAndroidAppName,
89
isAmStartError,
910
listAndroidApps,
@@ -737,6 +738,115 @@ test('typeAndroid uses adb input text for ascii text', async () => {
737738
);
738739
});
739740

741+
test('typeAndroid passes shell-sensitive ascii text to adb input text', async () => {
742+
await withMockedAdb(
743+
'agent-device-android-type-ascii-special-',
744+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
745+
async ({ argsLogPath, device }) => {
746+
await typeAndroid(device, 'curtis.layne+test+73kmc@uber.com');
747+
const args = (await fs.readFile(argsLogPath, 'utf8'))
748+
.trim()
749+
.split('\n')
750+
.filter(Boolean);
751+
assert.deepEqual(args, [
752+
'-s',
753+
'emulator-5554',
754+
'shell',
755+
'input',
756+
'text',
757+
'curtis.layne+test+73kmc@uber.com',
758+
]);
759+
},
760+
);
761+
});
762+
763+
test('typeAndroid preserves percent signs while encoding spaces', async () => {
764+
await withMockedAdb(
765+
'agent-device-android-type-ascii-percent-',
766+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
767+
async ({ argsLogPath, device }) => {
768+
await typeAndroid(device, '50% complete');
769+
const args = (await fs.readFile(argsLogPath, 'utf8'))
770+
.trim()
771+
.split('\n')
772+
.filter(Boolean);
773+
assert.deepEqual(args, [
774+
'-s',
775+
'emulator-5554',
776+
'shell',
777+
'input',
778+
'text',
779+
'50%%scomplete',
780+
]);
781+
},
782+
);
783+
});
784+
785+
test('fillAndroid falls back to clipboard paste when adb input text truncates', async () => {
786+
await withMockedAdb(
787+
'agent-device-android-fill-fallback-',
788+
[
789+
'#!/bin/sh',
790+
'STATE_FILE="$(dirname "$AGENT_DEVICE_TEST_ARGS_FILE")/fill_state.txt"',
791+
'CLIP_FILE="$(dirname "$AGENT_DEVICE_TEST_ARGS_FILE")/clipboard_state.txt"',
792+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
793+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
794+
'if [ "$1" = "-s" ]; then',
795+
' shift',
796+
' shift',
797+
'fi',
798+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "tap" ]; then',
799+
' exit 0',
800+
'fi',
801+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_MOVE_END" ]; then',
802+
' exit 0',
803+
'fi',
804+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_DEL" ]; then',
805+
' : > "$STATE_FILE"',
806+
' exit 0',
807+
'fi',
808+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "text" ]; then',
809+
' # Simulate WebView truncation on shell text input with special chars.',
810+
' if [ "$4" = "curtis.layne+test+73kmc@uber.com" ]; then',
811+
' printf "curti" > "$STATE_FILE"',
812+
' else',
813+
' printf "%s" "$4" > "$STATE_FILE"',
814+
' fi',
815+
' exit 0',
816+
'fi',
817+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "clipboard" ] && [ "$4" = "set" ] && [ "$5" = "text" ]; then',
818+
' printf "%s" "$6" > "$CLIP_FILE"',
819+
' exit 0',
820+
'fi',
821+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_PASTE" ]; then',
822+
' cat "$CLIP_FILE" > "$STATE_FILE"',
823+
' exit 0',
824+
'fi',
825+
'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "279" ]; then',
826+
' cat "$CLIP_FILE" > "$STATE_FILE"',
827+
' exit 0',
828+
'fi',
829+
'if [ "$1" = "exec-out" ] && [ "$2" = "uiautomator" ] && [ "$3" = "dump" ] && [ "$4" = "/dev/tty" ]; then',
830+
' text="$(cat "$STATE_FILE" 2>/dev/null)"',
831+
' printf "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><hierarchy><node class=\\"android.widget.EditText\\" text=\\"%s\\" focused=\\"true\\" bounds=\\"[0,0][200,100]\\"/></hierarchy>" "$text"',
832+
' exit 0',
833+
'fi',
834+
'echo "unexpected args: $@" >&2',
835+
'exit 1',
836+
'',
837+
].join('\n'),
838+
async ({ argsLogPath, device }) => {
839+
await fillAndroid(device, 10, 10, 'curtis.layne+test+73kmc@uber.com');
840+
const logged = await fs.readFile(argsLogPath, 'utf8');
841+
assert.match(logged, /shell\ninput\ntext\ncurtis\.layne\+test\+73kmc@uber\.com/);
842+
assert.match(logged, /shell\ncmd\nclipboard\nset\ntext\ncurtis\.layne\+test\+73kmc@uber\.com/);
843+
assert.match(logged, /shell\ninput\nkeyevent\nKEYCODE_PASTE/);
844+
const shellInputTextCount = (logged.match(/shell\ninput\ntext\n/g) ?? []).length;
845+
assert.equal(shellInputTextCount, 1);
846+
},
847+
);
848+
});
849+
740850
test('typeAndroid reports clear error when unicode input is unsupported', async () => {
741851
await withMockedAdb(
742852
'agent-device-android-type-unicode-unsupported-',

src/platforms/android/index.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -488,15 +488,16 @@ export async function longPressAndroid(
488488
}
489489

490490
export async function typeAndroid(device: DeviceInfo, text: string): Promise<void> {
491-
if (shouldUseClipboardTextInjection(text)) {
491+
const shouldInjectViaClipboard = shouldUseClipboardTextInjection(text);
492+
if (shouldInjectViaClipboard) {
492493
const clipboardResult = await typeAndroidViaClipboard(device, text);
493494
if (clipboardResult === 'ok') return;
494495
}
495496
try {
496-
const encoded = text.replace(/ /g, '%s');
497+
const encoded = encodeAndroidInputText(text);
497498
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
498499
} catch (error) {
499-
if (shouldUseClipboardTextInjection(text) && isAndroidInputTextUnsupported(error)) {
500+
if (shouldInjectViaClipboard && isAndroidInputTextUnsupported(error)) {
500501
throw new AppError(
501502
'COMMAND_FAILED',
502503
'Non-ASCII text input is not supported on this Android shell. Install an ADB keyboard IME or use ASCII input.',
@@ -536,22 +537,38 @@ export async function fillAndroid(
536537
text: string,
537538
): Promise<void> {
538539
const textCodePointLength = Array.from(text).length;
539-
const attempts = [
540-
{ clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
541-
{ clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
542-
] as const;
540+
const requiresClipboardInjection = shouldUseClipboardTextInjection(text);
541+
const attempts: Array<{
542+
strategy: 'input_text' | 'clipboard_paste' | 'chunked_input';
543+
clearPadding: number;
544+
minClear: number;
545+
maxClear: number;
546+
}> = [{ strategy: 'input_text', clearPadding: 12, minClear: 8, maxClear: 48 }];
547+
if (!requiresClipboardInjection) {
548+
attempts.push({ strategy: 'clipboard_paste', clearPadding: 12, minClear: 8, maxClear: 48 });
549+
attempts.push({ strategy: 'chunked_input', clearPadding: 24, minClear: 16, maxClear: 96 });
550+
}
543551

544-
await focusAndroid(device, x, y);
545552
let lastActual: string | null = null;
546553

547554
for (const attempt of attempts) {
555+
await focusAndroid(device, x, y);
548556
const clearCount = clampCount(
549557
textCodePointLength + attempt.clearPadding,
550558
attempt.minClear,
551559
attempt.maxClear,
552560
);
553561
await clearFocusedText(device, clearCount);
554-
await typeAndroidChunked(device, text, attempt.chunkSize, attempt.delayMs);
562+
if (attempt.strategy === 'input_text') {
563+
await typeAndroid(device, text);
564+
} else if (attempt.strategy === 'clipboard_paste') {
565+
const clipboardResult = await typeAndroidViaClipboard(device, text);
566+
if (clipboardResult !== 'ok') {
567+
continue;
568+
}
569+
} else {
570+
await typeAndroidChunked(device, text, 1, 15);
571+
}
555572
lastActual = await readInputValueAtPoint(device, x, y);
556573
if (lastActual === text) return;
557574
}
@@ -1122,6 +1139,11 @@ function shouldUseClipboardTextInjection(text: string): boolean {
11221139
return false;
11231140
}
11241141

1142+
function encodeAndroidInputText(text: string): string {
1143+
// Android shell input uses `%s` as the escaped token for spaces.
1144+
return text.replace(/ /g, '%s');
1145+
}
1146+
11251147
async function typeAndroidViaClipboard(
11261148
device: DeviceInfo,
11271149
text: string,

0 commit comments

Comments
 (0)