diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts index 254fa2ddc..38bac884b 100644 --- a/src/commands/client-command-contracts.ts +++ b/src/commands/client-command-contracts.ts @@ -81,7 +81,9 @@ export const clientCommandDefinitions = [ surface: enumField(SURFACE_VALUES), activity: stringField('Android activity name.'), launchConsole: stringField('Launch console mode.'), - launchArgs: stringArrayField('iOS launch arguments forwarded verbatim to the app process.'), + launchArgs: stringArrayField( + 'Launch arguments forwarded verbatim to the platform launch command.', + ), relaunch: booleanField('Force relaunch.'), saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), noRecord: booleanField('Do not record this action.'), diff --git a/src/core/__tests__/dispatch-open.test.ts b/src/core/__tests__/dispatch-open.test.ts index 1cf399a73..523762dfb 100644 --- a/src/core/__tests__/dispatch-open.test.ts +++ b/src/core/__tests__/dispatch-open.test.ts @@ -4,6 +4,7 @@ import { dispatchCommand } from '../dispatch.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { clearIosSimulatorAppState, openIosApp } from '../../platforms/ios/apps.ts'; +import { openAndroidApp } from '../../platforms/android/app-lifecycle.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => { @@ -18,12 +19,22 @@ vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => { }; }); +vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openAndroidApp: vi.fn(async () => {}), + }; +}); + const mockClearIosSimulatorAppState = vi.mocked(clearIosSimulatorAppState); const mockOpenIosApp = vi.mocked(openIosApp); +const mockOpenAndroidApp = vi.mocked(openAndroidApp); beforeEach(() => { mockClearIosSimulatorAppState.mockClear(); mockOpenIosApp.mockClear(); + mockOpenAndroidApp.mockClear(); }); test('dispatch open rejects URL as first argument when second URL is provided', async () => { @@ -46,7 +57,19 @@ test('dispatch open rejects URL as first argument when second URL is provided', ); }); -test('dispatch open rejects Android launch arguments instead of dropping them', async () => { +test('dispatch open rejects launch arguments without an app target', async () => { + await assert.rejects( + () => dispatchCommand(IOS_SIMULATOR, 'open', [], undefined, { launchArgs: ['-Flag'] }), + (error: unknown) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'INVALID_ARGS'); + assert.match((error as AppError).message, /requires an app target/i); + return true; + }, + ); +}); + +test('dispatch open forwards Android launch arguments to openAndroidApp', async () => { const device: DeviceInfo = { platform: 'android', id: 'emulator-5554', @@ -55,15 +78,36 @@ test('dispatch open rejects Android launch arguments instead of dropping them', booted: true, }; + await dispatchCommand(device, 'open', ['com.example.app'], undefined, { + launchArgs: ['--es', 'KEY', 'value'], + }); + + assert.equal(mockOpenAndroidApp.mock.calls.length, 1); + assert.equal(mockOpenAndroidApp.mock.calls[0]?.[0], device); + assert.equal(mockOpenAndroidApp.mock.calls[0]?.[1], 'com.example.app'); + const optionsArg = mockOpenAndroidApp.mock.calls[0]?.[2]; + assert.ok(optionsArg && typeof optionsArg === 'object', 'expected options object'); + assert.deepEqual(optionsArg.launchArgs, ['--es', 'KEY', 'value']); +}); + +test('dispatch open rejects launch arguments on Linux', async () => { + const device: DeviceInfo = { + platform: 'linux', + id: 'linux-local', + name: 'Linux', + kind: 'device', + booted: true, + }; + await assert.rejects( () => - dispatchCommand(device, 'open', ['com.example.app'], undefined, { + dispatchCommand(device, 'open', ['org.example.App'], undefined, { launchArgs: ['--fixture', 'demo'], }), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); - assert.match((error as AppError).message, /Apple platforms/i); + assert.match((error as AppError).message, /Linux/i); return true; }, ); diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 085a697a5..8afc629cd 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -181,6 +181,7 @@ async function handleOpenCommand( const app = positionals[0]; const url = positionals[1]; const launchConsole = context?.launchConsole; + const launchArgs = context?.launchArgs; if (positionals.length > 2) { throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: [url]'); } @@ -188,12 +189,18 @@ async function handleOpenCommand( if (launchConsole) { throw new AppError('INVALID_ARGS', '--launch-console requires an app target'); } + if (launchArgs && launchArgs.length > 0) { + throw new AppError('INVALID_ARGS', '--launch-args requires an app target'); + } await interactor.openDevice(); return { app: null, ...successText('Opened device') }; } if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } + if (device.platform === 'linux' && launchArgs && launchArgs.length > 0) { + throw new AppError('UNSUPPORTED_OPERATION', '--launch-args is not supported on Linux.'); + } if (url !== undefined) { if (isDeepLinkTarget(app)) { throw new AppError( @@ -210,7 +217,7 @@ async function handleOpenCommand( await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId, - launchArgs: context?.launchArgs, + launchArgs, url, }); return { app, url, ...successText(`Opened: ${app}`) }; @@ -218,12 +225,6 @@ async function handleOpenCommand( if (launchConsole && isDeepLinkTarget(app)) { throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE); } - if (device.platform === 'android' && context?.launchArgs && context.launchArgs.length > 0) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Launch arguments are currently supported only on Apple platforms.', - ); - } if (context?.clearAppState) { if (isDeepLinkTarget(app)) { throw new AppError( @@ -243,7 +244,7 @@ async function handleOpenCommand( activity: context?.activity, appBundleId: context?.appBundleId, launchConsole, - launchArgs: context?.launchArgs, + launchArgs, }); return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) }; } diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 8ebb77220..871024481 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -38,6 +38,7 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { openAndroidApp(device, app, { activity: options?.activity, appBundleId: options?.appBundleId, + launchArgs: options?.launchArgs, url: options?.url, }), openDevice: () => openAndroidDevice(device), diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index 47e1351fa..367de7a23 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -66,6 +66,24 @@ async function withMockedAdb( } } +function androidOpenAdbScript(): string { + return [ + '#!/bin/sh', + '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" = "am" ] && [ "$3" = "start" ]; then', + ' echo "Status: ok"', + ' exit 0', + 'fi', + 'exit 0', + '', + ].join('\n'); +} + test('parseUiHierarchy reads double-quoted Android node attributes', () => { const xml = ''; @@ -1121,25 +1139,7 @@ test('installAndroidInstallablePath invalidates cached display-name package matc test('openAndroidApp default launch uses -p package flag', async () => { await withMockedAdb( 'agent-device-android-open-default-', - [ - '#!/bin/sh', - '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" = "pm" ] && [ "$3" = "list" ]; then', - ' echo "package:com.example.app"', - ' exit 0', - 'fi', - 'if [ "$1" = "shell" ] && [ "$2" = "am" ] && [ "$3" = "start" ]; then', - ' echo "Status: ok"', - ' exit 0', - 'fi', - 'exit 0', - '', - ].join('\n'), + androidOpenAdbScript(), async ({ argsLogPath, device }) => { await openAndroidApp(device, 'com.example.app'); const logged = await fs.readFile(argsLogPath, 'utf8'); @@ -1149,6 +1149,87 @@ test('openAndroidApp default launch uses -p package flag', async () => { ); }); +test('openAndroidApp appends launchArgs to am start when launching by package', async () => { + await withMockedAdb( + 'agent-device-android-open-launch-args-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + await openAndroidApp(device, 'com.example.app', { + launchArgs: ['--es', 'screen', 'home', '--ez', 'fresh', 'true'], + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /-p\ncom\.example\.app\n--es\nscreen\nhome\n--ez\nfresh\ntrue/); + }, + ); +}); + +test('openAndroidApp appends launchArgs to am start when activity override is set', async () => { + await withMockedAdb( + 'agent-device-android-open-launch-args-activity-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + await openAndroidApp(device, 'com.example.app', { + activity: '.MainActivity', + launchArgs: ['--es', 'mode', 'debug'], + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /-n\ncom\.example\.app\/\.MainActivity\n--es\nmode\ndebug/); + }, + ); +}); + +test('openAndroidApp appends launchArgs to am start for deep link URL opens', async () => { + await withMockedAdb( + 'agent-device-android-open-launch-args-url-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + await openAndroidApp(device, 'myapp://item/42', { + launchArgs: ['--es', 'ref', 'campaign'], + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /-d\nmyapp:\/\/item\/42\n--es\nref\ncampaign/); + }, + ); +}); + +test('openAndroidApp appends launchArgs to am start for app-bound URL opens', async () => { + await withMockedAdb( + 'agent-device-android-open-launch-args-app-bound-url-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + await openAndroidApp(device, 'com.example.app', { + url: 'https://example.com/promo', + launchArgs: ['--es', 'ref', 'campaign'], + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match( + logged, + /-d\nhttps:\/\/example\.com\/promo\n-p\ncom\.example\.app\n--es\nref\ncampaign/, + ); + }, + ); +}); + +test('openAndroidApp shell-quotes launchArgs containing JSON or shell metacharacters', async () => { + await withMockedAdb( + 'agent-device-android-open-launch-args-quoting-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + // Value contains characters the device shell would otherwise re-interpret: + // `#` (comment), `;` (statement separator), `&` (background), `*` (glob), + // ` ` (word separator), `\` (escape). + const jsonPayload = '{"a":"x #y;z&w","b":"path/*"}'; + await openAndroidApp(device, 'com.example.app', { + launchArgs: ['--es', 'EXTRA_CONFIG', jsonPayload], + }); + const logged = await fs.readFile(argsLogPath, 'utf8'); + // `--es` and the safe extra key pass through unquoted; the JSON value + // is single-quoted so `adb shell` re-tokenisation preserves it. + assert.match(logged, /--es\nEXTRA_CONFIG\n'\{"a":"x #y;z&w","b":"path\/\*"\}'/); + }, + ); +}); + test('openAndroidApp normalizes missing package launch failures into APP_NOT_INSTALLED', async () => { await withMockedAdb( 'agent-device-android-open-missing-package-', diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 7aaf062e3..77dadb9df 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -295,9 +295,25 @@ async function ensureAndroidLocalhostReverse(device: DeviceInfo, target: string) export type OpenAndroidAppOptions = { activity?: string; appBundleId?: string; + launchArgs?: string[]; url?: string; }; +// `adb shell` joins its argv with spaces and feeds the result to a device +// shell, which re-tokenises. The other `am start` arguments (action, category, +// component, etc.) are well-known and never contain shell-significant +// characters, so they round-trip untouched. Launch arguments are user-supplied +// and may contain JSON, spaces, `#`, etc.; each is single-quoted unless it +// consists entirely of safe shell characters. +function quoteAndroidShellArg(arg: string): string { + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +function androidLaunchArgs(options: OpenAndroidAppOptions): string[] { + return (options.launchArgs ?? []).map(quoteAndroidShellArg); +} + export async function openAndroidApp( device: DeviceInfo, app: string, @@ -320,14 +336,14 @@ export async function openAndroidApp( const resolved = await resolveAndroidApp(device, app); const launchCategory = resolveAndroidLauncherCategory(device); if (resolved.type === 'intent') { - await openAndroidIntent(device, resolved.value, activity); + await openAndroidIntent(device, resolved.value, options); return; } if (activity) { - await openAndroidPackageActivity(device, resolved.value, activity, launchCategory); + await openAndroidPackageActivity(device, resolved.value, activity, launchCategory, options); return; } - await openAndroidPackage(device, resolved.value, launchCategory); + await openAndroidPackage(device, resolved.value, launchCategory, options); } async function openAndroidDeepLink( @@ -352,6 +368,7 @@ async function openAndroidDeepLink( '-d', target, ...androidDeepLinkPackageArgs(options.appBundleId), + ...androidLaunchArgs(options), ]); } @@ -382,18 +399,27 @@ async function openAndroidAppBoundDeepLink( deepLinkUrl, '-p', resolved, + ...androidLaunchArgs(options), ]); } async function openAndroidIntent( device: DeviceInfo, intent: string, - activity: string | undefined, + options: OpenAndroidAppOptions, ): Promise { - if (activity) { + if (options.activity) { throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent'); } - await runAndroidAdb(device, ['shell', 'am', 'start', '-W', '-a', intent]); + await runAndroidAdb(device, [ + 'shell', + 'am', + 'start', + '-W', + '-a', + intent, + ...androidLaunchArgs(options), + ]); } async function openAndroidPackageActivity( @@ -401,12 +427,13 @@ async function openAndroidPackageActivity( packageName: string, activity: string, launchCategory: string, + options: OpenAndroidAppOptions, ): Promise { const component = activity.includes('/') ? activity : `${packageName}/${activity.startsWith('.') ? activity : `.${activity}`}`; try { - await runAndroidAdb(device, buildAndroidActivityLaunchArgs(component, launchCategory)); + await runAndroidAdb(device, buildAndroidActivityLaunchArgs(component, launchCategory, options)); } catch (error) { await maybeRethrowAndroidMissingPackageError(device, packageName, error); throw error; @@ -417,6 +444,7 @@ async function openAndroidPackage( device: DeviceInfo, packageName: string, launchCategory: string, + options: OpenAndroidAppOptions, ): Promise { const primaryResult = await runAndroidAdb( device, @@ -433,6 +461,7 @@ async function openAndroidPackage( launchCategory, '-p', packageName, + ...androidLaunchArgs(options), ], { allowFailure: true }, ); @@ -449,10 +478,14 @@ async function openAndroidPackage( stderr: primaryResult.stderr, }); } - await runAndroidAdb(device, buildAndroidActivityLaunchArgs(component, launchCategory)); + await runAndroidAdb(device, buildAndroidActivityLaunchArgs(component, launchCategory, options)); } -function buildAndroidActivityLaunchArgs(component: string, launchCategory: string): string[] { +function buildAndroidActivityLaunchArgs( + component: string, + launchCategory: string, + options: OpenAndroidAppOptions, +): string[] { return [ 'shell', 'am', @@ -466,6 +499,7 @@ function buildAndroidActivityLaunchArgs(component: string, launchCategory: strin launchCategory, '-n', component, + ...androidLaunchArgs(options), ]; } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 83fe4b91c..a3bfa03f8 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -331,6 +331,7 @@ test('usageForCommand documents open --launch-args', () => { if (help === null) throw new Error('Expected open help text'); assert.match(help, /--launch-args /); assert.match(help, /forwarded verbatim/); + assert.match(help, /Linux and macOS reject the flag/); }); test('parseArgs accepts install-from-source GitHub Actions artifact flag', () => { diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index f5c1d925e..aa0470ddc 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -506,7 +506,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ multiple: true, usageLabel: '--launch-args ', usageDescription: - 'open: repeatable launch argument forwarded verbatim to the iOS launch command (simctl launch positional args for simulators; devicectl process launch positional args for devices, after `--`). Currently supported only on iOS; Android and macOS reject the flag.', + 'open: repeatable launch argument forwarded verbatim to the platform launch command (iOS app process args; Android adb shell am start args). Linux and macOS reject the flag.', }, { key: 'header',