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,