diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index b7ed746547..e7a39ed7b8 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -57,6 +57,7 @@ export type ApplicationLifecycleExecution = Readonly<{ /** Semantic target resolution used before an application open. */ export type OpenTargetResolutionInput = Readonly<{ target?: string; + launchArgs?: readonly string[]; currentAppBundleId?: string; surface: SessionSurface; /** `open --foreground` asks the admitted Apple binding to inspect its exact target. */ diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index c3ae10b6e8..5a8144b026 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -289,7 +289,13 @@ async function openAndroidIntent( if (options.activity) { throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent'); } - await runAndroidShell(device, ['am', 'start', '-W', '-a', intent, ...androidLaunchArgs(options)]); + const { buildAndroidIntentArgs } = await import('./intent-arguments.ts'); + await runAndroidShell(device, [ + 'am', + 'start', + '-W', + ...buildAndroidIntentArgs(intent, options.launchArgs).launchArgs, + ]); } async function openAndroidPackageActivity( diff --git a/packages/platform-android/src/intent-arguments.test.ts b/packages/platform-android/src/intent-arguments.test.ts new file mode 100644 index 0000000000..4f103c2c53 --- /dev/null +++ b/packages/platform-android/src/intent-arguments.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from 'vitest'; +import fc from 'fast-check'; +import { buildAndroidIntentArgs } from './intent-arguments.ts'; + +const ACTION_ARGS = ['-a', 'android.settings.SETTINGS']; +const PROPERTY_RUNS = 100; + +test.each([ + { + name: 'attached short values and extras resembling launch options', + args: ['-R2', '-ncom.example/.Main', '-ekey', '--display', '-P-S', '--esn', '-S', '-S'], + intentArgs: ['-ncom.example/.Main', '-ekey', '--display', '--esn', '-S'], + }, + { + name: 'selector arguments', + args: ['-S', '--selector', '-a', 'com.example.VIEW', '--display', '0', '-p', 'com.example'], + intentArgs: ['--selector', '-a', 'com.example.VIEW', '-p', 'com.example'], + }, + { + name: 'MIME type retained by AOSP across a selector', + args: ['-t', 'text/plain', '--selector', '-d', 'content://example/item', '-S'], + intentArgs: ['-t', 'text/plain', '--selector', '-d', 'content://example/item'], + }, + { + name: 'positional component and unparsed trailing tokens', + args: ['-S', 'com.example/.Main', '--display', '0'], + intentArgs: ['com.example/.Main', '--display', '0'], + }, + { + name: 'explicit end of options', + args: ['-S', '--', 'com.example/.Main', '-R', '2'], + intentArgs: ['--', 'com.example/.Main', '-R', '2'], + }, +])('intent arguments preserve AOSP token boundaries for $name', ({ args, intentArgs }) => { + expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({ + launchArgs: [...ACTION_ARGS, ...args], + resolutionArgs: [...ACTION_ARGS, ...intentArgs], + }); +}); + +test.each([ + ['--future-option', '-p', 'com.other'], + ['--display=1'], + ['-R'], + ['--es', 'key'], + ['-Sattached'], + ['--display', 'not-a-number'], + ['-R--display'], + ['-d', 'content://example/item', '--selector', '-t', 'text/plain'], + ['intent:#Intent;scheme=content;end'], +])('unprojectable launch arguments retain unknown ownership: %j', (...args) => { + expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({ + launchArgs: [...ACTION_ARGS, ...args], + resolutionArgs: undefined, + }); +}); + +type ArgumentGroup = { role: 'intent' | 'launch'; tokens: string[] }; + +const flagLikeValueArb = fc.oneof( + fc.string({ maxLength: 40 }), + fc.constantFrom('-S', '-R', '--display', '--user', '--selector', '--', ''), +); +const integerLaunchFlagArb = fc.constantFrom( + '--sampling', + '--profiler-output-version', + '-R', + '--display', + '--task-display-area-feature-id', + '--windowingMode', + '--activityType', + '--task', + '--allow-fgs-start-reason', +); +const argumentGroupArb: fc.Arbitrary = fc.oneof( + fc + .tuple(fc.constantFrom('-a', '-d', '-t', '-i', '-c', '-p'), flagLikeValueArb) + .map((tokens) => ({ role: 'intent', tokens })), + fc + .tuple(fc.constantFrom('-e', '--es', '--esa', '--esal'), flagLikeValueArb, flagLikeValueArb) + .map((tokens) => ({ role: 'intent', tokens })), + flagLikeValueArb.map((key) => ({ role: 'intent', tokens: ['--esn', key] })), + fc + .constantFrom('-S', '-W', '-D', '--task-overlay') + .map((flag) => ({ role: 'launch', tokens: [flag] })), + fc + .tuple(fc.constantFrom('-P', '--attach-agent'), flagLikeValueArb) + .map((tokens) => ({ role: 'launch', tokens })), + fc + .tuple( + integerLaunchFlagArb, + fc.oneof( + fc.integer({ min: -2147483648, max: 2147483647 }).map(String), + fc.constantFrom('+0', '-0', '0001', '2147483647', '-2147483648', '٠١', '02'), + ), + fc.boolean(), + ) + .map(([flag, value, attached]) => ({ + role: 'launch', + tokens: flag === '-R' && attached ? [flag + value] : [flag, value], + })), +); + +test('intent projection preserves whole argument groups while launch preserves the original stream', () => { + fc.assert( + fc.property(fc.array(argumentGroupArb, { maxLength: 25 }), (groups) => { + const args = ['-t', 'application/test', ...groups.flatMap((group) => group.tokens)]; + const intentArgs = [ + '-t', + 'application/test', + ...groups.filter((group) => group.role === 'intent').flatMap((group) => group.tokens), + ]; + expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({ + launchArgs: [...ACTION_ARGS, ...args], + resolutionArgs: [...ACTION_ARGS, ...intentArgs], + }); + }), + { numRuns: PROPERTY_RUNS }, + ); +}); + +test('invalid native integer launch controls keep ownership unknown, including in selectors', () => { + fc.assert( + fc.property( + integerLaunchFlagArb, + fc.constantFrom('', '+', '--display', '1.0', '0x10', ' 1', '2147483648', '-2147483649', '𝟙'), + fc.array(argumentGroupArb, { maxLength: 10 }), + (flag, value, groups) => { + const args = [...groups.flatMap((group) => group.tokens), '--selector', flag, value]; + expect(buildAndroidIntentArgs('android.settings.SETTINGS', args)).toEqual({ + launchArgs: [...ACTION_ARGS, ...args], + resolutionArgs: undefined, + }); + }, + ), + { numRuns: PROPERTY_RUNS }, + ); +}); diff --git a/packages/platform-android/src/intent-arguments.ts b/packages/platform-android/src/intent-arguments.ts new file mode 100644 index 0000000000..41bac19c21 --- /dev/null +++ b/packages/platform-android/src/intent-arguments.ts @@ -0,0 +1,209 @@ +type AndroidIntentOption = readonly [ + audience: 'intent' | 'launch', + valueCount: 0 | 1 | 2, + valueType?: 'java-int', +]; + +/** AOSP Intent.parseCommandArgs and ActivityManagerShellCommand.makeIntent option arities. */ +const ANDROID_INTENT_OPTIONS: Readonly> = { + '-a': ['intent', 1], + '-d': ['intent', 1], + '-t': ['intent', 1], + '-i': ['intent', 1], + '-c': ['intent', 1], + '-n': ['intent', 1], + '-p': ['intent', 1], + '-f': ['intent', 1], + '-e': ['intent', 2], + '--es': ['intent', 2], + '--esn': ['intent', 1], + '--ei': ['intent', 2], + '--eu': ['intent', 2], + '--ecn': ['intent', 2], + '--eia': ['intent', 2], + '--eial': ['intent', 2], + '--el': ['intent', 2], + '--ela': ['intent', 2], + '--elal': ['intent', 2], + '--ef': ['intent', 2], + '--efa': ['intent', 2], + '--efal': ['intent', 2], + '--ed': ['intent', 2], + '--eda': ['intent', 2], + '--edal': ['intent', 2], + '--esa': ['intent', 2], + '--esal': ['intent', 2], + '--ez': ['intent', 2], + '--grant-read-uri-permission': ['intent', 0], + '--grant-write-uri-permission': ['intent', 0], + '--grant-persistable-uri-permission': ['intent', 0], + '--grant-prefix-uri-permission': ['intent', 0], + '--exclude-stopped-packages': ['intent', 0], + '--include-stopped-packages': ['intent', 0], + '--debug-log-resolution': ['intent', 0], + '--activity-brought-to-front': ['intent', 0], + '--activity-clear-top': ['intent', 0], + '--activity-clear-when-task-reset': ['intent', 0], + '--activity-exclude-from-recents': ['intent', 0], + '--activity-launched-from-history': ['intent', 0], + '--activity-multiple-task': ['intent', 0], + '--activity-no-animation': ['intent', 0], + '--activity-no-history': ['intent', 0], + '--activity-no-user-action': ['intent', 0], + '--activity-previous-is-top': ['intent', 0], + '--activity-reorder-to-front': ['intent', 0], + '--activity-reset-task-if-needed': ['intent', 0], + '--activity-single-top': ['intent', 0], + '--activity-clear-task': ['intent', 0], + '--activity-task-on-home': ['intent', 0], + '--activity-match-external': ['intent', 0], + '--receiver-registered-only': ['intent', 0], + '--receiver-replace-pending': ['intent', 0], + '--receiver-foreground': ['intent', 0], + '--receiver-no-abort': ['intent', 0], + '--receiver-include-background': ['intent', 0], + '--selector': ['intent', 0], + '--user': ['intent', 1], + '-D': ['launch', 0], + '--suspend': ['launch', 0], + '-N': ['launch', 0], + '-W': ['launch', 0], + '-P': ['launch', 1], + '--start-profiler': ['launch', 1], + '--sampling': ['launch', 1, 'java-int'], + '--clock-type': ['launch', 1], + '--profiler-output-version': ['launch', 1, 'java-int'], + '--streaming': ['launch', 0], + '--attach-agent': ['launch', 1], + '--attach-agent-bind': ['launch', 1], + '-R': ['launch', 1, 'java-int'], + '-S': ['launch', 0], + '--track-allocation': ['launch', 0], + '--receiver-permission': ['launch', 1], + '--display': ['launch', 1, 'java-int'], + '--task-display-area-feature-id': ['launch', 1, 'java-int'], + '--windowingMode': ['launch', 1, 'java-int'], + '--activityType': ['launch', 1, 'java-int'], + '--task': ['launch', 1, 'java-int'], + '--task-overlay': ['launch', 0], + '--lock-task': ['launch', 0], + '--allow-background-activity-starts': ['launch', 0], + '--async': ['launch', 0], + '--splashscreen-show-icon': ['launch', 0], + '--dismiss-keyguard-if-insecure': ['launch', 0], + '--dismiss-keyguard': ['launch', 0], + '--allow-fgs-start-reason': ['launch', 1, 'java-int'], +}; + +type ParsedAndroidIntentOption = { + name: string; + audience: AndroidIntentOption[0]; + end: number; + value: string | undefined; +}; + +/** Integer.parseInt accepts ASCII signs and BMP decimal digits, within signed 32-bit bounds. */ +function isAndroidIntentJavaInteger(value: string): boolean { + if (!/^[+-]?\p{Decimal_Number}+$/u.test(value)) return false; + const negative = value[0] === '-'; + const digits = value[0] === '-' || value[0] === '+' ? value.slice(1) : value; + let result = 0; + for (const digit of digits) { + if (digit.length !== 1) return false; + const code = digit.charCodeAt(0); + let zero = code; + while (/\p{Decimal_Number}/u.test(String.fromCharCode(zero - 1))) zero -= 1; + result = result * 10 + code - zero; + if (result > (negative ? 2147483648 : 2147483647)) return false; + } + return true; +} + +function readAndroidIntentOption( + args: readonly string[], + index: number, + token: string, +): ParsedAndroidIntentOption | undefined { + const attachedValue = token.length > 2 && token[1] !== '-'; + const name = attachedValue ? token.slice(0, 2) : token; + const option = ANDROID_INTENT_OPTIONS[name]; + if (!option || (attachedValue && option[1] === 0)) return undefined; + const end = index + 1 + option[1] - Number(attachedValue); + if (end > args.length) return undefined; + const value = attachedValue ? token.slice(2) : args[index + 1]; + if (option[2] === 'java-int' && !isAndroidIntentJavaInteger(value ?? '')) return undefined; + return { + name, + audience: option[0], + end, + value, + }; +} + +function canResolvePositionalIntent(token: string | undefined, type: string | undefined): boolean { + return !token?.startsWith('intent:') && !(token?.startsWith('content:') && type === undefined); +} + +function needsContentType(data: string | undefined, type: string | undefined): boolean { + // am start resolves content-provider MIME types; package lookup does not. + return data?.startsWith('content:') === true && type === undefined; +} + +type AndroidIntentMime = { data?: string; type?: string }; + +function updateAndroidIntentMime( + mime: AndroidIntentMime, + name: string, + value: string | undefined, +): boolean { + if (name === '-d') mime.data = value; + if (name === '-t') mime.type = value; + return name !== '--selector' || !needsContentType(mime.data, mime.type); +} + +function readAndroidIntentArguments(args: readonly string[]): + | { + options: Array; + positional: string[]; + } + | undefined { + const options: Array = []; + let index = 0; + while (true) { + const token = args[index]; + if (token === undefined) return { options, positional: [] }; + if (!token.startsWith('-') || token === '--') return { options, positional: args.slice(index) }; + const option = readAndroidIntentOption(args, index, token); + if (!option) return undefined; + options.push({ ...option, tokens: args.slice(index, option.end) }); + index = option.end; + } +} + +function androidIntentResolutionArgs(args: readonly string[]): string[] | undefined { + const parsed = readAndroidIntentArguments(args); + if (!parsed) return undefined; + const resolutionArgs: string[] = []; + const mime: AndroidIntentMime = {}; + for (const option of parsed.options) { + if (!updateAndroidIntentMime(mime, option.name, option.value)) return undefined; + if (option.audience === 'intent') resolutionArgs.push(...option.tokens); + } + const positional = parsed.positional[parsed.positional[0] === '--' ? 1 : 0]; + if (!canResolvePositionalIntent(positional, mime.type) || needsContentType(mime.data, mime.type)) + return undefined; + resolutionArgs.push(...parsed.positional); + return resolutionArgs; +} + +/** Launch stays verbatim; resolution receives only complete, recognized intent options. */ +export function buildAndroidIntentArgs( + intent: string, + launchArgs: readonly string[] = [], +): { launchArgs: string[]; resolutionArgs: string[] | undefined } { + const resolutionArgs = androidIntentResolutionArgs(launchArgs); + return { + launchArgs: ['-a', intent, ...launchArgs], + resolutionArgs: resolutionArgs && ['-a', intent, ...resolutionArgs], + }; +} diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 0a417adbfc..79a4228f7c 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -25,6 +25,7 @@ afterEach(() => { type LifecycleFixture = Readonly<{ calls: string[]; + interactorResolutions: Array; lifecycle: ReturnType; }>; @@ -41,20 +42,25 @@ function createLifecycle( }> = {}, ): LifecycleFixture { const calls: string[] = []; + const interactorResolutions: Array = []; const localInteractors: LocalApplicationInteractorHost = { - resolve: async () => - ({ + resolve: async (_device, runner) => { + interactorResolutions.push(runner.appBundleId); + return { open: async (app: string) => { calls.push(`open:${app}`); }, openDevice: async () => {}, - close: async () => {}, + close: async (app: string) => { + calls.push(`close:${app}`); + }, setSetting: async () => {}, snapshot: async (options: SnapshotOptions) => { calls.push(`observe:${options.appBundleId}`); return await (params.snapshot?.(options) ?? Promise.resolve({ nodes: [] })); }, - }) as unknown as Interactor, + } as unknown as Interactor; + }, }; const host = { localInteractors, @@ -93,7 +99,7 @@ function createLifecycle( device, signal: params.signal ?? new AbortController().signal, }); - return { calls, lifecycle }; + return { calls, interactorResolutions, lifecycle }; } function openInput(overrides: Partial = {}): OpenApplicationInput { @@ -113,6 +119,51 @@ function openInput(overrides: Partial = {}): OpenApplicati }; } +test('an Android relaunch with unknown requested package refuses before dispatch', async () => { + const { calls, interactorResolutions, lifecycle } = createLifecycle({ + openedAppBundleId: undefined, + }); + const applyRuntimeHints = vi.fn(async () => {}); + + await expect( + lifecycle.openApplication( + openInput({ + target: 'settings', + positionals: ['settings'], + appBundleId: undefined, + relaunch: true, + runtimeHints: { metroHost: 'localhost' }, + applyRuntimeHints, + execution: { launchArgs: ['-n', 'com.vendor.settings/.Settings'] }, + }), + ), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'android-relaunch-owner-unresolved', target: 'settings' }, + }); + + expect(interactorResolutions).toEqual([]); + expect(calls).toEqual([]); + expect(applyRuntimeHints).not.toHaveBeenCalled(); +}); + +test.each([ + ['settings', 'com.vendor.settings'], + ['com.example.app', 'com.example.app'], +])('Android relaunch of %s closes its resolved package %s', async (target, appBundleId) => { + const { calls, interactorResolutions, lifecycle } = createLifecycle({ + openedAppBundleId: appBundleId, + }); + + const outcome = await lifecycle.openApplication( + openInput({ target, positionals: [target], appBundleId, relaunch: true }), + ); + + expect(calls.slice(0, 2)).toEqual([`close:${appBundleId}`, `open:${target}`]); + expect(new Set(interactorResolutions)).toEqual(new Set([appBundleId])); + expect(outcome.appBundleId).toBe(appBundleId); +}); + test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { const { calls, lifecycle } = createLifecycle(); vi.spyOn(Date, 'now') @@ -215,10 +266,10 @@ test('an app open whose launched package cannot be identified reports it unident const { calls, lifecycle } = createLifecycle({ openedAppBundleId: undefined }); const outcome = await lifecycle.openApplication( - openInput({ target: 'Example', positionals: ['Example'], appBundleId: undefined }), + openInput({ target: 'settings', positionals: ['settings'], appBundleId: undefined }), ); - expect(calls).toEqual(['open:Example']); + expect(calls).toEqual(['open:settings']); expect(outcome.timing.postOpenObservation).toBe('app-unidentified'); }); diff --git a/packages/platform-android/src/lifecycle.ts b/packages/platform-android/src/lifecycle.ts index 20d19fb375..d1f572300d 100644 --- a/packages/platform-android/src/lifecycle.ts +++ b/packages/platform-android/src/lifecycle.ts @@ -102,11 +102,23 @@ async function openAndroidApplication( ): Promise { const timing: MutableOpenTiming = {}; if (input.relaunch && input.target) { + if (!input.appBundleId) { + throw new AppError( + 'COMMAND_FAILED', + 'Android relaunch requires a resolved package for the requested app.', + { + reason: 'android-relaunch-owner-unresolved', + deviceId: binding.device.id, + target: input.target, + hint: 'Retry open --relaunch with the exact installed package name.', + }, + ); + } const closeStartedAtMs = Date.now(); await invokeApplicationClose({ device: binding.device, interactor: await binding.resolveInteractor(input.execution, input.appBundleId), - positionals: [input.appBundleId ?? input.target], + positionals: [input.appBundleId], }); timing.relaunchCloseDurationMs = elapsed(closeStartedAtMs); } diff --git a/packages/platform-android/src/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts index 7287cb73de..a037d23513 100644 --- a/packages/platform-android/src/open-target-resolution.test.ts +++ b/packages/platform-android/src/open-target-resolution.test.ts @@ -4,9 +4,11 @@ import { AppError } from '@agent-device/kernel/errors'; const resolveAndroidApp = vi.hoisted(() => vi.fn()); const getAndroidAppState = vi.hoisted(() => vi.fn()); +const runAndroidShell = vi.hoisted(() => vi.fn()); vi.mock('./app-deployment-resolution.ts', () => ({ resolveAndroidApp })); vi.mock('./window-state.ts', () => ({ getAndroidAppState })); +vi.mock('./adb.ts', () => ({ runAndroidShell })); const { resolveAndroidPackageForOpen, inferAndroidPackageAfterOpen } = await import('./open-target-resolution.ts'); @@ -36,11 +38,241 @@ test('resolveAndroidPackageForOpen returns the resolved package for a package ma await expect(resolveAndroidPackageForOpen(androidDevice, 'demo')).resolves.toBe( 'com.example.demo', ); + expect(runAndroidShell).not.toHaveBeenCalled(); }); -test('resolveAndroidPackageForOpen ignores an intent resolution', async () => { +test('resolveAndroidPackageForOpen retains the native requested intent package without adopting foreground Search', async () => { + const deployment = await vi.importActual( + './app-deployment-resolution.ts', + ); + resolveAndroidApp.mockImplementationOnce(deployment.resolveAndroidApp); + + runAndroidShell.mockResolvedValue({ + stdout: 'priority=0 preferredOrder=0 match=0x108000\ncom.android.settings/.Settings\n', + stderr: '', + exitCode: 0, + }); + getAndroidAppState.mockResolvedValue({ + package: 'com.google.android.settings.intelligence', + activity: '.search.SearchActivity', + }); + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBe( + 'com.android.settings', + ); + expect(runAndroidShell).toHaveBeenCalledWith( + androidDevice, + [ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '--query-flags', + '0x10000', + '-a', + 'android.settings.SETTINGS', + ], + { allowFailure: true, timeoutMs: 2_000 }, + ); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + +test('intent launch and ownership retain intent overrides and extra values that resemble launch controls', async () => { + const { openAndroidApp } = await import('./app-lifecycle.ts'); + const launchArgs = [ + '-n', + 'com.example.app/.MainActivity', + '-p', + 'com.example.app', + '-d', + 'content://example/settings', + '-t', + 'text/plain', + '-c', + 'com.example.CUSTOM', + '--user', + '10', + '--es', + 'launch_control', + '--display', + '--esn', + '-S', + '--ei', + 'offset', + '-1', + ]; + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockImplementation(async (_device, args: string[]) => ({ + stdout: args.includes('com.example.app/.MainActivity') + ? 'com.example.app/.MainActivity' + : 'com.android.settings/.Settings', + stderr: '', + exitCode: 0, + })); + + await openAndroidApp(androidDevice, 'settings', { launchArgs }); + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings', launchArgs)).resolves.toBe( + 'com.example.app', + ); + const commands = runAndroidShell.mock.calls.map((call) => call[1]); + const intentArgs = ['-a', 'android.settings.SETTINGS', ...launchArgs]; + expect(commands[0]).toEqual(['am', 'start', '-W', ...intentArgs]); + expect(commands[1]).toEqual([ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '--query-flags', + '0x10000', + ...intentArgs, + ]); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + +test('intent launch controls reach am start without breaking native package ownership', async () => { + const { openAndroidApp } = await import('./app-lifecycle.ts'); + const launchArgs = ['-S', '-R', '2', '--display', '0']; + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockImplementation(async (_device, args: string[]) => { + if (args[0] === 'cmd' && args.slice(8).includes('-S')) { + return { + stdout: '', + stderr: 'java.lang.IllegalArgumentException: Unknown option: -S', + exitCode: 1, + }; + } + return { stdout: 'com.android.settings/.Settings', stderr: '', exitCode: 0 }; + }); + + await openAndroidApp(androidDevice, 'settings', { launchArgs }); + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings', launchArgs)).resolves.toBe( + 'com.android.settings', + ); + const commands = runAndroidShell.mock.calls.map((call) => call[1]); + expect(commands[0]).toEqual([ + 'am', + 'start', + '-W', + '-a', + 'android.settings.SETTINGS', + ...launchArgs, + ]); + expect(commands[1]).toEqual([ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '--query-flags', + '0x10000', + '-a', + 'android.settings.SETTINGS', + ]); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + +test('intent selectors resolve only handlers eligible for activity launch', async () => { resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockImplementation(async (_device, args: string[]) => ({ + stdout: + args.includes('--query-flags') && args[args.indexOf('--query-flags') + 1] === '0x10000' + ? 'com.example.default/.Main' + : 'com.example.nondefault/.Main', + stderr: '', + exitCode: 0, + })); + await expect( + resolveAndroidPackageForOpen(androidDevice, 'settings', [ + '--selector', + '-a', + 'com.example.VIEW', + ]), + ).resolves.toBe('com.example.default'); + expect(runAndroidShell).toHaveBeenCalledWith( + androidDevice, + [ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '--query-flags', + '0x10000', + '-a', + 'android.settings.SETTINGS', + '--selector', + '-a', + 'com.example.VIEW', + ], + { allowFailure: true, timeoutMs: 2_000 }, + ); +}); + +test.each([ + ['--display', 'not-a-number'], + ['-R--display'], + ['-a', 'android.intent.action.VIEW', '-d', 'content://example/image/1'], + ['-dcontent://example/image/1'], + ['--selector', '-d', 'content://example/image/1'], + ['content://example/image/1'], +])('unprojectable intent resolution retains unknown ownership: %j', async (...launchArgs) => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockResolvedValue({ + stdout: 'com.example.generic/.Main', + stderr: '', + exitCode: 0, + }); + await expect( + resolveAndroidPackageForOpen(androidDevice, 'settings', launchArgs), + ).resolves.toBeUndefined(); + expect(runAndroidShell).not.toHaveBeenCalled(); +}); + +test.each([ + 'No activity found', + 'com.android.settings/Invalid-Class', + 'wrong-package/.Settings', + '/data/apps/settings.apk', + 'warning /not/a/component', + 'com.android.settings/', +])( + 'intent package ownership refuses malformed or unresolved native component %s', + async (stdout) => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockResolvedValue({ stdout, stderr: '', exitCode: 0 }); + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBeUndefined(); + expect(getAndroidAppState).not.toHaveBeenCalled(); + }, +); + +test('intent package ownership does not adopt a component from a failed native resolution', async () => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockResolvedValue({ + stdout: 'com.android.settings/.Settings', + stderr: '', + exitCode: 1, + }); + await expect( + resolveAndroidPackageForOpen(androidDevice, 'settings', [ + '-n', + 'com.example.app/.MainActivity', + ]), + ).resolves.toBeUndefined(); +}); + +test('intent package ownership reads its native handler rather than assuming the Settings package', async () => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockResolvedValue({ + stdout: 'com.vendor.settings/org.vendor.Settings$Panel', + stderr: '', + exitCode: 0, + }); + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBe( + 'com.vendor.settings', + ); +}); + +test('intent resolution retains unknown ownership on an ADB failure', async () => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + runAndroidShell.mockRejectedValue(new AppError('COMMAND_FAILED', 'ADB unavailable')); await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBeUndefined(); }); diff --git a/packages/platform-android/src/open-target-resolution.ts b/packages/platform-android/src/open-target-resolution.ts index e8211211bd..b569df6ac4 100644 --- a/packages/platform-android/src/open-target-resolution.ts +++ b/packages/platform-android/src/open-target-resolution.ts @@ -2,17 +2,45 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; import { getAndroidAppState } from './window-state.ts'; +import { runAndroidShell } from './adb.ts'; +import { buildAndroidIntentArgs } from './intent-arguments.ts'; +import { androidActivityComponent, parseAndroidLaunchComponent } from './app-lifecycle.ts'; -/** Resolves an `open` target to an installed package; only an exact package match counts. */ +const INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS = 2_000; + +/** Resolves an ADB app open to its package or the native handler of its requested intent. */ export async function resolveAndroidPackageForOpen( device: DeviceInfo, openTarget: string | undefined, + launchArgs?: readonly string[], ): Promise { if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) return undefined; try { const resolved = await resolveAndroidApp(device, openTarget); - return resolved.type === 'package' ? resolved.value : undefined; + if (resolved.type === 'package') return resolved.value; + const { resolutionArgs } = buildAndroidIntentArgs(resolved.value, launchArgs); + if (!resolutionArgs) return undefined; + const result = await runAndroidShell( + device, + [ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '--query-flags', + '0x10000', + ...resolutionArgs, + ], + { allowFailure: true, timeoutMs: INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS }, + ); + if (result.exitCode !== 0) return undefined; + const component = parseAndroidLaunchComponent(result.stdout); + if (!component) return undefined; + const packageName = component.split('/')[0]; + if (!packageName || androidActivityComponent(packageName, component) !== component) + return undefined; + return packageName; } catch { return undefined; } diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 2b1367079c..62cd74ca41 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -35,7 +35,7 @@ const appsCommandMetadata = defineFieldCommandMetadata( const openCommandMetadata = defineFieldCommandMetadata( 'open', - 'Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target.', + 'Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target. On Android over ADB, relaunching fails before stopping or opening an app if its package cannot be resolved; retry with the exact installed package name.', { app: stringField('App name, bundle id, package, or URL.'), url: stringField('Optional URL passed with an app shell.'), diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index dc8263b1e7..8f5b45b6ae 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -54,10 +54,6 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); import { createRequestHandler, @@ -225,6 +221,7 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails // session's frame was expired BEFORE the close dispatch and stays expired — a // post-dispatch close failure never restores it (there is no rollback). expect(response?.ok ?? false).toBe(false); + expect(mockDispatch.mock.calls.map(([, command]) => command)).toEqual(['close']); expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); }); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index fd8e52b5f6..9baa6b7828 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -275,6 +275,38 @@ test('open applies launch-only flags only to the direct app launch before runtim ]); }); +test('Android open forwards component launch overrides to requested app identity resolution', async () => { + const sessionStore = makeSessionStore(); + const device = makeAndroidEmulator(); + const launchArgs = ['-n', 'com.example.app/.MainActivity']; + mockResolveTargetDevice.mockResolvedValue(device); + const response = await handleSessionCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'intent-override', + command: 'open', + positionals: ['settings'], + flags: { platform: 'android', launchArgs }, + }, + sessionName: 'intent-override', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockResolveAndroidPackage).toHaveBeenCalledWith(device, 'settings', launchArgs); + expect(mockDispatch).toHaveBeenCalledWith( + device, + 'open', + ['settings'], + undefined, + expect.objectContaining({ launchArgs }), + ); +}); + test('open --metro-port alone defaults the host to 10.0.2.2 on an Android emulator', async () => { const sessionStore = makeSessionStore(); const sessionName = 'runtime-open-port-only-android'; @@ -376,6 +408,7 @@ test('open --metro-port alone stays host-ambiguous on a physical Android device' }); test('open --relaunch allows Android package names ending with apk-like suffix', async () => { + mockResolveAndroidPackage.mockResolvedValue('com.example.apk'); const sessionStore = makeSessionStore(); const dispatchCalls: Array<{ command: string; positionals: string[] }> = []; diff --git a/src/daemon/session-lifecycle/internal/session-open-prepare.ts b/src/daemon/session-lifecycle/internal/session-open-prepare.ts index 4d6f787af7..8c544e84bd 100644 --- a/src/daemon/session-lifecycle/internal/session-open-prepare.ts +++ b/src/daemon/session-lifecycle/internal/session-open-prepare.ts @@ -180,6 +180,7 @@ export async function prepareOpenCommandDetails(params: { surface, openTarget, existingAppBundleId: existingSession?.appBundleId, + launchArgs: req.flags?.launchArgs, foreground, }); if (runtimeHintPlan.clearRemovedRuntimeHints) { @@ -216,11 +217,13 @@ async function resolvePreparedOpenIdentity(params: { surface: SessionSurface; openTarget: string | undefined; existingAppBundleId?: string; + launchArgs?: readonly string[]; foreground: boolean; }): Promise<{ appBundleId?: string; appName?: string }> { const { runtime, surface, openTarget, existingAppBundleId, foreground } = params; const resolved = await runtime.operations.resolveOpenTarget({ target: openTarget, + launchArgs: params.launchArgs, currentAppBundleId: existingAppBundleId, surface, foreground, diff --git a/src/platform-runtime-android-application-tools.ts b/src/platform-runtime-android-application-tools.ts index 167be21c8f..4e20bae86c 100644 --- a/src/platform-runtime-android-application-tools.ts +++ b/src/platform-runtime-android-application-tools.ts @@ -115,6 +115,7 @@ async function resolveAndroidOpenTarget( device, input.target, input.currentAppBundleId, + input.launchArgs, ), appName: input.target, }; diff --git a/src/platform-runtime-open-target.ts b/src/platform-runtime-open-target.ts index 9519b9429f..f2c9c87ea3 100644 --- a/src/platform-runtime-open-target.ts +++ b/src/platform-runtime-open-target.ts @@ -165,13 +165,14 @@ export async function resolveSessionAppBundleIdForTarget( device: DeviceInfo, openTarget: string | undefined, currentAppBundleId: string | undefined, + launchArgs?: readonly string[], ): Promise { if (device.platform === 'harmonyos') { return bundleIdFromOpenTarget(openTarget) ?? currentAppBundleId; } return ( (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? - (await tryResolveAndroidPackageForOpen(device, openTarget)) ?? + (await tryResolveAndroidPackageForOpen(device, openTarget, launchArgs)) ?? (shouldPreserveAndroidPackageContext(device, openTarget) ? currentAppBundleId : undefined) ); } @@ -179,11 +180,12 @@ export async function resolveSessionAppBundleIdForTarget( async function tryResolveAndroidPackageForOpen( device: DeviceInfo, openTarget: string | undefined, + launchArgs?: readonly string[], ): Promise { if (device.platform !== 'android' || !openTarget) return undefined; try { const { resolveAndroidPackageForOpen } = await loadAndroidMechanics(); - return await resolveAndroidPackageForOpen(device, openTarget); + return await resolveAndroidPackageForOpen(device, openTarget, launchArgs); } catch { return undefined; } diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 1a25d1f4d3..cc84f9dcd8 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -604,6 +604,12 @@ function androidPackageAdbResult( args: string[], dumpsysWindow?: () => string, ): AndroidAdbResult | undefined { + if ( + key === + 'shell cmd package resolve-activity --brief --query-flags 0x10000 -a android.settings.SETTINGS' + ) { + return { stdout: 'com.android.settings/.Settings\n', stderr: '', exitCode: 0 }; + } return ( androidSnapshotHelperProbeAdbResult(key) ?? androidPackageDumpsysAdbResult(args) ?? diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index b419bac96d..8a5a05461f 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -21,6 +21,10 @@ A session opened without `--platform` uses the platform-less `default` session, platform still join it as long as they match its device. Agents in different worktrees do not attach to each other's implicit session; a named session (`--session `) is shared across worktrees. +Android sessions driven through ADB require a resolved package for `open --relaunch`. If a name or +alias cannot be resolved, the command fails before stopping or opening an app. Retry with the exact +installed package name, for example `agent-device open com.example.myapp --platform android --relaunch`. + ## Find a session's logs and artifacts When a session starts, human output prints a `Session state: ` line and JSON output includes