diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts
index 27422efd4..f2af52ad0 100644
--- a/src/platforms/android/__tests__/index.test.ts
+++ b/src/platforms/android/__tests__/index.test.ts
@@ -4,6 +4,7 @@ import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
+ fillAndroid,
inferAndroidAppName,
isAmStartError,
listAndroidApps,
@@ -737,6 +738,115 @@ test('typeAndroid uses adb input text for ascii text', async () => {
);
});
+test('typeAndroid passes shell-sensitive ascii text to adb input text', async () => {
+ await withMockedAdb(
+ 'agent-device-android-type-ascii-special-',
+ '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
+ async ({ argsLogPath, device }) => {
+ await typeAndroid(device, 'curtis.layne+test+73kmc@uber.com');
+ const args = (await fs.readFile(argsLogPath, 'utf8'))
+ .trim()
+ .split('\n')
+ .filter(Boolean);
+ assert.deepEqual(args, [
+ '-s',
+ 'emulator-5554',
+ 'shell',
+ 'input',
+ 'text',
+ 'curtis.layne+test+73kmc@uber.com',
+ ]);
+ },
+ );
+});
+
+test('typeAndroid preserves percent signs while encoding spaces', async () => {
+ await withMockedAdb(
+ 'agent-device-android-type-ascii-percent-',
+ '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
+ async ({ argsLogPath, device }) => {
+ await typeAndroid(device, '50% complete');
+ const args = (await fs.readFile(argsLogPath, 'utf8'))
+ .trim()
+ .split('\n')
+ .filter(Boolean);
+ assert.deepEqual(args, [
+ '-s',
+ 'emulator-5554',
+ 'shell',
+ 'input',
+ 'text',
+ '50%%scomplete',
+ ]);
+ },
+ );
+});
+
+test('fillAndroid falls back to clipboard paste when adb input text truncates', async () => {
+ await withMockedAdb(
+ 'agent-device-android-fill-fallback-',
+ [
+ '#!/bin/sh',
+ 'STATE_FILE="$(dirname "$AGENT_DEVICE_TEST_ARGS_FILE")/fill_state.txt"',
+ 'CLIP_FILE="$(dirname "$AGENT_DEVICE_TEST_ARGS_FILE")/clipboard_state.txt"',
+ 'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
+ 'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
+ 'if [ "$1" = "-s" ]; then',
+ ' shift',
+ ' shift',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "tap" ]; then',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_MOVE_END" ]; then',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_DEL" ]; then',
+ ' : > "$STATE_FILE"',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "text" ]; then',
+ ' # Simulate WebView truncation on shell text input with special chars.',
+ ' if [ "$4" = "curtis.layne+test+73kmc@uber.com" ]; then',
+ ' printf "curti" > "$STATE_FILE"',
+ ' else',
+ ' printf "%s" "$4" > "$STATE_FILE"',
+ ' fi',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "clipboard" ] && [ "$4" = "set" ] && [ "$5" = "text" ]; then',
+ ' printf "%s" "$6" > "$CLIP_FILE"',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "KEYCODE_PASTE" ]; then',
+ ' cat "$CLIP_FILE" > "$STATE_FILE"',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "shell" ] && [ "$2" = "input" ] && [ "$3" = "keyevent" ] && [ "$4" = "279" ]; then',
+ ' cat "$CLIP_FILE" > "$STATE_FILE"',
+ ' exit 0',
+ 'fi',
+ 'if [ "$1" = "exec-out" ] && [ "$2" = "uiautomator" ] && [ "$3" = "dump" ] && [ "$4" = "/dev/tty" ]; then',
+ ' text="$(cat "$STATE_FILE" 2>/dev/null)"',
+ ' printf "" "$text"',
+ ' exit 0',
+ 'fi',
+ 'echo "unexpected args: $@" >&2',
+ 'exit 1',
+ '',
+ ].join('\n'),
+ async ({ argsLogPath, device }) => {
+ await fillAndroid(device, 10, 10, 'curtis.layne+test+73kmc@uber.com');
+ const logged = await fs.readFile(argsLogPath, 'utf8');
+ assert.match(logged, /shell\ninput\ntext\ncurtis\.layne\+test\+73kmc@uber\.com/);
+ assert.match(logged, /shell\ncmd\nclipboard\nset\ntext\ncurtis\.layne\+test\+73kmc@uber\.com/);
+ assert.match(logged, /shell\ninput\nkeyevent\nKEYCODE_PASTE/);
+ const shellInputTextCount = (logged.match(/shell\ninput\ntext\n/g) ?? []).length;
+ assert.equal(shellInputTextCount, 1);
+ },
+ );
+});
+
test('typeAndroid reports clear error when unicode input is unsupported', async () => {
await withMockedAdb(
'agent-device-android-type-unicode-unsupported-',
diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts
index 61c2d0f76..6121f66c2 100644
--- a/src/platforms/android/index.ts
+++ b/src/platforms/android/index.ts
@@ -488,15 +488,16 @@ export async function longPressAndroid(
}
export async function typeAndroid(device: DeviceInfo, text: string): Promise {
- if (shouldUseClipboardTextInjection(text)) {
+ const shouldInjectViaClipboard = shouldUseClipboardTextInjection(text);
+ if (shouldInjectViaClipboard) {
const clipboardResult = await typeAndroidViaClipboard(device, text);
if (clipboardResult === 'ok') return;
}
try {
- const encoded = text.replace(/ /g, '%s');
+ const encoded = encodeAndroidInputText(text);
await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded]));
} catch (error) {
- if (shouldUseClipboardTextInjection(text) && isAndroidInputTextUnsupported(error)) {
+ if (shouldInjectViaClipboard && isAndroidInputTextUnsupported(error)) {
throw new AppError(
'COMMAND_FAILED',
'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(
text: string,
): Promise {
const textCodePointLength = Array.from(text).length;
- const attempts = [
- { clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
- { clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
- ] as const;
+ const requiresClipboardInjection = shouldUseClipboardTextInjection(text);
+ const attempts: Array<{
+ strategy: 'input_text' | 'clipboard_paste' | 'chunked_input';
+ clearPadding: number;
+ minClear: number;
+ maxClear: number;
+ }> = [{ strategy: 'input_text', clearPadding: 12, minClear: 8, maxClear: 48 }];
+ if (!requiresClipboardInjection) {
+ attempts.push({ strategy: 'clipboard_paste', clearPadding: 12, minClear: 8, maxClear: 48 });
+ attempts.push({ strategy: 'chunked_input', clearPadding: 24, minClear: 16, maxClear: 96 });
+ }
- await focusAndroid(device, x, y);
let lastActual: string | null = null;
for (const attempt of attempts) {
+ await focusAndroid(device, x, y);
const clearCount = clampCount(
textCodePointLength + attempt.clearPadding,
attempt.minClear,
attempt.maxClear,
);
await clearFocusedText(device, clearCount);
- await typeAndroidChunked(device, text, attempt.chunkSize, attempt.delayMs);
+ if (attempt.strategy === 'input_text') {
+ await typeAndroid(device, text);
+ } else if (attempt.strategy === 'clipboard_paste') {
+ const clipboardResult = await typeAndroidViaClipboard(device, text);
+ if (clipboardResult !== 'ok') {
+ continue;
+ }
+ } else {
+ await typeAndroidChunked(device, text, 1, 15);
+ }
lastActual = await readInputValueAtPoint(device, x, y);
if (lastActual === text) return;
}
@@ -1122,6 +1139,11 @@ function shouldUseClipboardTextInjection(text: string): boolean {
return false;
}
+function encodeAndroidInputText(text: string): string {
+ // Android shell input uses `%s` as the escaped token for spaces.
+ return text.replace(/ /g, '%s');
+}
+
async function typeAndroidViaClipboard(
device: DeviceInfo,
text: string,