Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
110 changes: 110 additions & 0 deletions src/platforms/android/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><hierarchy><node class=\\"android.widget.EditText\\" text=\\"%s\\" focused=\\"true\\" bounds=\\"[0,0][200,100]\\"/></hierarchy>" "$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-',
Expand Down
40 changes: 31 additions & 9 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,15 +488,16 @@ export async function longPressAndroid(
}

export async function typeAndroid(device: DeviceInfo, text: string): Promise<void> {
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.',
Expand Down Expand Up @@ -536,22 +537,38 @@ export async function fillAndroid(
text: string,
): Promise<void> {
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;
}
Expand Down Expand Up @@ -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,
Expand Down
Loading