From 4928b6cc9636be8d49749c0f2cc234b059c97c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 01:37:23 +0200 Subject: [PATCH 1/8] fix(android): retain requested intent app identity for ADB opens --- .../src/open-target-resolution.test.ts | 79 ++++++++++++++++++- .../src/open-target-resolution.ts | 29 ++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/platform-android/src/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts index 7287cb73de..d44f96ada2 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,86 @@ 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', + '-a', + 'android.settings.SETTINGS', + '-c', + 'android.intent.category.DEFAULT', + ], + { allowFailure: true, timeoutMs: 2_000 }, + ); + expect(getAndroidAppState).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')).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..e54052329a 100644 --- a/packages/platform-android/src/open-target-resolution.ts +++ b/packages/platform-android/src/open-target-resolution.ts @@ -2,8 +2,12 @@ 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 { 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, @@ -12,7 +16,28 @@ export async function resolveAndroidPackageForOpen( return undefined; try { const resolved = await resolveAndroidApp(device, openTarget); - return resolved.type === 'package' ? resolved.value : undefined; + if (resolved.type === 'package') return resolved.value; + const result = await runAndroidShell( + device, + [ + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '-a', + resolved.value, + '-c', + 'android.intent.category.DEFAULT', + ], + { 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; } From 4744d8717e51a69e16bc891b4062eeda2fcdd5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 01:44:47 +0200 Subject: [PATCH 2/8] fix(android): resolve intent ownership with effective launch arguments --- .../src/application-lifecycle-runtime.ts | 1 + .../platform-android/src/app-lifecycle.ts | 14 ++++++- .../src/open-target-resolution.test.ts | 42 +++++++++++++++++-- .../src/open-target-resolution.ts | 10 +++-- .../__tests__/session-open-runtime.test.ts | 32 ++++++++++++++ .../internal/session-open-prepare.ts | 3 ++ ...tform-runtime-android-application-tools.ts | 1 + src/platform-runtime-open-target.ts | 6 ++- 8 files changed, 100 insertions(+), 9 deletions(-) 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..b523a4ee98 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -192,6 +192,13 @@ function androidLaunchArgs(options: OpenAndroidAppOptions): string[] { return options.launchArgs ?? []; } +export function buildAndroidIntentArgs( + intent: string, + launchArgs: readonly string[] = [], +): string[] { + return ['-a', intent, ...launchArgs]; +} + export async function openAndroidApp( device: DeviceInfo, app: string, @@ -289,7 +296,12 @@ 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)]); + await runAndroidShell(device, [ + 'am', + 'start', + '-W', + ...buildAndroidIntentArgs(intent, options.launchArgs), + ]); } async function openAndroidPackageActivity( diff --git a/packages/platform-android/src/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts index d44f96ada2..96a2a702ab 100644 --- a/packages/platform-android/src/open-target-resolution.test.ts +++ b/packages/platform-android/src/open-target-resolution.test.ts @@ -66,16 +66,47 @@ test('resolveAndroidPackageForOpen retains the native requested intent package w 'package', 'resolve-activity', '--brief', - '-a', - 'android.settings.SETTINGS', '-c', 'android.intent.category.DEFAULT', + '-a', + 'android.settings.SETTINGS', ], { allowFailure: true, timeoutMs: 2_000 }, ); expect(getAndroidAppState).not.toHaveBeenCalled(); }); +test('intent launch and ownership resolve the same overriding component arguments', async () => { + const { openAndroidApp } = await import('./app-lifecycle.ts'); + const launchArgs = ['-n', 'com.example.app/.MainActivity']; + 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', + '-c', + 'android.intent.category.DEFAULT', + ...intentArgs, + ]); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + test.each([ 'No activity found', 'com.android.settings/Invalid-Class', @@ -100,7 +131,12 @@ test('intent package ownership does not adopt a component from a failed native r stderr: '', exitCode: 1, }); - await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBeUndefined(); + 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 () => { diff --git a/packages/platform-android/src/open-target-resolution.ts b/packages/platform-android/src/open-target-resolution.ts index e54052329a..bbfe0d374f 100644 --- a/packages/platform-android/src/open-target-resolution.ts +++ b/packages/platform-android/src/open-target-resolution.ts @@ -3,7 +3,11 @@ 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 { androidActivityComponent, parseAndroidLaunchComponent } from './app-lifecycle.ts'; +import { + androidActivityComponent, + buildAndroidIntentArgs, + parseAndroidLaunchComponent, +} from './app-lifecycle.ts'; const INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS = 2_000; @@ -11,6 +15,7 @@ const INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS = 2_000; export async function resolveAndroidPackageForOpen( device: DeviceInfo, openTarget: string | undefined, + launchArgs?: readonly string[], ): Promise { if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) return undefined; @@ -24,10 +29,9 @@ export async function resolveAndroidPackageForOpen( 'package', 'resolve-activity', '--brief', - '-a', - resolved.value, '-c', 'android.intent.category.DEFAULT', + ...buildAndroidIntentArgs(resolved.value, launchArgs), ], { allowFailure: true, timeoutMs: INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS }, ); 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..7019dfe743 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'; 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; } From 1f2df30d08bd400d27958dfe8b89e56993852de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 11:17:22 +0200 Subject: [PATCH 3/8] fix(android): separate intent resolution from launch controls --- .../platform-android/src/app-lifecycle.ts | 10 +- .../src/intent-arguments.test.ts | 89 +++++++++ .../platform-android/src/intent-arguments.ts | 186 ++++++++++++++++++ .../src/open-target-resolution.test.ts | 129 +++++++++++- .../src/open-target-resolution.ts | 15 +- 5 files changed, 407 insertions(+), 22 deletions(-) create mode 100644 packages/platform-android/src/intent-arguments.test.ts create mode 100644 packages/platform-android/src/intent-arguments.ts diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index b523a4ee98..759be35164 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -7,6 +7,7 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { runAndroidShell } from './adb.ts'; +import { buildAndroidIntentArgs } from './intent-arguments.ts'; import { androidAdbResultError, createAndroidPortReverseManager, @@ -192,13 +193,6 @@ function androidLaunchArgs(options: OpenAndroidAppOptions): string[] { return options.launchArgs ?? []; } -export function buildAndroidIntentArgs( - intent: string, - launchArgs: readonly string[] = [], -): string[] { - return ['-a', intent, ...launchArgs]; -} - export async function openAndroidApp( device: DeviceInfo, app: string, @@ -300,7 +294,7 @@ async function openAndroidIntent( 'am', 'start', '-W', - ...buildAndroidIntentArgs(intent, options.launchArgs), + ...buildAndroidIntentArgs(intent, options.launchArgs).launchArgs, ]); } 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..fadd238bd4 --- /dev/null +++ b/packages/platform-android/src/intent-arguments.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'vitest'; +import fc from 'fast-check'; +import { buildAndroidIntentArgs } from './intent-arguments.ts'; + +const ACTION_ARGS = ['-a', 'android.settings.SETTINGS']; + +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: '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'], + ['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 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(fc.constantFrom('-R', '--display'), fc.nat({ max: 20 })) + .map(([flag, value]) => ({ role: 'launch', tokens: [flag, String(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], + }); + }), + ); +}); diff --git a/packages/platform-android/src/intent-arguments.ts b/packages/platform-android/src/intent-arguments.ts new file mode 100644 index 0000000000..7caa42bf0d --- /dev/null +++ b/packages/platform-android/src/intent-arguments.ts @@ -0,0 +1,186 @@ +type AndroidIntentOption = readonly [audience: 'intent' | 'launch', valueCount: 0 | 1 | 2]; + +/** 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], + '--clock-type': ['launch', 1], + '--profiler-output-version': ['launch', 1], + '--streaming': ['launch', 0], + '--attach-agent': ['launch', 1], + '--attach-agent-bind': ['launch', 1], + '-R': ['launch', 1], + '-S': ['launch', 0], + '--track-allocation': ['launch', 0], + '--receiver-permission': ['launch', 1], + '--display': ['launch', 1], + '--task-display-area-feature-id': ['launch', 1], + '--windowingMode': ['launch', 1], + '--activityType': ['launch', 1], + '--task': ['launch', 1], + '--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], +}; + +type ParsedAndroidIntentOption = { + name: string; + audience: AndroidIntentOption[0]; + end: number; + value: string | undefined; +}; + +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; + return { + name, + audience: option[0], + end, + value: attachedValue ? token.slice(2) : args[index + 1], + }; +} + +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/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts index 96a2a702ab..61b9e97da5 100644 --- a/packages/platform-android/src/open-target-resolution.test.ts +++ b/packages/platform-android/src/open-target-resolution.test.ts @@ -66,8 +66,8 @@ test('resolveAndroidPackageForOpen retains the native requested intent package w 'package', 'resolve-activity', '--brief', - '-c', - 'android.intent.category.DEFAULT', + '--query-flags', + '0x10000', '-a', 'android.settings.SETTINGS', ], @@ -76,9 +76,30 @@ test('resolveAndroidPackageForOpen retains the native requested intent package w expect(getAndroidAppState).not.toHaveBeenCalled(); }); -test('intent launch and ownership resolve the same overriding component arguments', async () => { +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']; + 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') @@ -100,13 +121,109 @@ test('intent launch and ownership resolve the same overriding component argument 'package', 'resolve-activity', '--brief', - '-c', - 'android.intent.category.DEFAULT', + '--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([ + ['-a', 'android.intent.action.VIEW', '-d', 'content://example/image/1'], + ['-dcontent://example/image/1'], + ['--selector', '-d', 'content://example/image/1'], + ['content://example/image/1'], +])('implicit content MIME 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', diff --git a/packages/platform-android/src/open-target-resolution.ts b/packages/platform-android/src/open-target-resolution.ts index bbfe0d374f..b569df6ac4 100644 --- a/packages/platform-android/src/open-target-resolution.ts +++ b/packages/platform-android/src/open-target-resolution.ts @@ -3,11 +3,8 @@ 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 { - androidActivityComponent, - buildAndroidIntentArgs, - parseAndroidLaunchComponent, -} from './app-lifecycle.ts'; +import { buildAndroidIntentArgs } from './intent-arguments.ts'; +import { androidActivityComponent, parseAndroidLaunchComponent } from './app-lifecycle.ts'; const INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS = 2_000; @@ -22,6 +19,8 @@ export async function resolveAndroidPackageForOpen( try { const resolved = await resolveAndroidApp(device, openTarget); if (resolved.type === 'package') return resolved.value; + const { resolutionArgs } = buildAndroidIntentArgs(resolved.value, launchArgs); + if (!resolutionArgs) return undefined; const result = await runAndroidShell( device, [ @@ -29,9 +28,9 @@ export async function resolveAndroidPackageForOpen( 'package', 'resolve-activity', '--brief', - '-c', - 'android.intent.category.DEFAULT', - ...buildAndroidIntentArgs(resolved.value, launchArgs), + '--query-flags', + '0x10000', + ...resolutionArgs, ], { allowFailure: true, timeoutMs: INTENT_PACKAGE_RESOLUTION_TIMEOUT_MS }, ); From 372e65187a4b2ca67ca67e7c1b35d4a9dc612846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 12:24:00 +0200 Subject: [PATCH 4/8] fix(android): defer intent argument loading until launch --- packages/platform-android/src/app-lifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 759be35164..5a8144b026 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -7,7 +7,6 @@ import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { runAndroidShell } from './adb.ts'; -import { buildAndroidIntentArgs } from './intent-arguments.ts'; import { androidAdbResultError, createAndroidPortReverseManager, @@ -290,6 +289,7 @@ async function openAndroidIntent( if (options.activity) { throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent'); } + const { buildAndroidIntentArgs } = await import('./intent-arguments.ts'); await runAndroidShell(device, [ 'am', 'start', From 23be54ea2be38794345126a7baeeaf712fdd01cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 13:04:38 +0200 Subject: [PATCH 5/8] fix(android): validate native integer launch controls --- .../src/intent-arguments.test.ts | 53 ++++++++++++++++++- .../platform-android/src/intent-arguments.ts | 45 ++++++++++++---- .../src/open-target-resolution.test.ts | 4 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/platform-android/src/intent-arguments.test.ts b/packages/platform-android/src/intent-arguments.test.ts index fadd238bd4..4f103c2c53 100644 --- a/packages/platform-android/src/intent-arguments.test.ts +++ b/packages/platform-android/src/intent-arguments.test.ts @@ -3,6 +3,7 @@ import fc from 'fast-check'; import { buildAndroidIntentArgs } from './intent-arguments.ts'; const ACTION_ARGS = ['-a', 'android.settings.SETTINGS']; +const PROPERTY_RUNS = 100; test.each([ { @@ -15,6 +16,11 @@ test.each([ 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'], @@ -38,6 +44,9 @@ test.each([ ['-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({ @@ -52,6 +61,17 @@ 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) @@ -67,8 +87,18 @@ const argumentGroupArb: fc.Arbitrary = fc.oneof( .tuple(fc.constantFrom('-P', '--attach-agent'), flagLikeValueArb) .map((tokens) => ({ role: 'launch', tokens })), fc - .tuple(fc.constantFrom('-R', '--display'), fc.nat({ max: 20 })) - .map(([flag, value]) => ({ role: 'launch', tokens: [flag, String(value)] })), + .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', () => { @@ -85,5 +115,24 @@ test('intent projection preserves whole argument groups while launch preserves t 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 index 7caa42bf0d..41bac19c21 100644 --- a/packages/platform-android/src/intent-arguments.ts +++ b/packages/platform-android/src/intent-arguments.ts @@ -1,4 +1,8 @@ -type AndroidIntentOption = readonly [audience: 'intent' | 'launch', valueCount: 0 | 1 | 2]; +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> = { @@ -66,21 +70,21 @@ const ANDROID_INTENT_OPTIONS: Readonly> = { '-W': ['launch', 0], '-P': ['launch', 1], '--start-profiler': ['launch', 1], - '--sampling': ['launch', 1], + '--sampling': ['launch', 1, 'java-int'], '--clock-type': ['launch', 1], - '--profiler-output-version': ['launch', 1], + '--profiler-output-version': ['launch', 1, 'java-int'], '--streaming': ['launch', 0], '--attach-agent': ['launch', 1], '--attach-agent-bind': ['launch', 1], - '-R': ['launch', 1], + '-R': ['launch', 1, 'java-int'], '-S': ['launch', 0], '--track-allocation': ['launch', 0], '--receiver-permission': ['launch', 1], - '--display': ['launch', 1], - '--task-display-area-feature-id': ['launch', 1], - '--windowingMode': ['launch', 1], - '--activityType': ['launch', 1], - '--task': ['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], @@ -88,7 +92,7 @@ const ANDROID_INTENT_OPTIONS: Readonly> = { '--splashscreen-show-icon': ['launch', 0], '--dismiss-keyguard-if-insecure': ['launch', 0], '--dismiss-keyguard': ['launch', 0], - '--allow-fgs-start-reason': ['launch', 1], + '--allow-fgs-start-reason': ['launch', 1, 'java-int'], }; type ParsedAndroidIntentOption = { @@ -98,6 +102,23 @@ type ParsedAndroidIntentOption = { 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, @@ -109,11 +130,13 @@ function readAndroidIntentOption( 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: attachedValue ? token.slice(2) : args[index + 1], + value, }; } diff --git a/packages/platform-android/src/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts index 61b9e97da5..a037d23513 100644 --- a/packages/platform-android/src/open-target-resolution.test.ts +++ b/packages/platform-android/src/open-target-resolution.test.ts @@ -207,11 +207,13 @@ test('intent selectors resolve only handlers eligible for activity launch', asyn }); 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'], -])('implicit content MIME resolution retains unknown ownership: %j', async (...launchArgs) => { +])('unprojectable intent resolution retains unknown ownership: %j', async (...launchArgs) => { resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); runAndroidShell.mockResolvedValue({ stdout: 'com.example.generic/.Main', From c4b3b146fbdfe439f4e948aa41663b65aa75d0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:01:28 +0200 Subject: [PATCH 6/8] fix(android): refuse relaunch without resolved package ownership --- .../platform-android/src/lifecycle.test.ts | 62 ++++++++++++++++--- packages/platform-android/src/lifecycle.ts | 14 ++++- src/commands/management/app.ts | 11 +++- website/docs/docs/sessions.md | 4 ++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 0a417adbfc..cab6d08753 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,48 @@ 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, 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(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 +263,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/src/commands/management/app.ts b/src/commands/management/app.ts index 2b1367079c..fb1dd9e404 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -33,9 +33,12 @@ const appsCommandMetadata = defineFieldCommandMetadata( }, ); +const androidRelaunchPackageRequirement = + 'On Android over ADB, --relaunch fails before stopping or opening an app if its package cannot be resolved; retry with the exact installed package name.'; + 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. ${androidRelaunchPackageRequirement}`, { app: stringField('App name, bundle id, package, or URL.'), url: stringField('Optional URL passed with an app shell.'), @@ -197,9 +200,11 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + androidRelaunchPackageRequirement + + ' Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: - "metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", + androidRelaunchPackageRequirement + + " metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", }, metadata: openCommandMetadata, run: (client, input) => client.apps.open(toAppOpenOptions(input)), 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 From 0a350df68bd5655b194b266abcc3cc28da891dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:09:43 +0200 Subject: [PATCH 7/8] test(android): supply native ownership in relaunch fixtures --- src/commands/management/app.ts | 11 +++-------- .../handlers/__tests__/session-relaunch-close.test.ts | 5 +---- .../internal/__tests__/session-open-runtime.test.ts | 1 + test/integration/provider-scenarios/android-world.ts | 6 ++++++ 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index fb1dd9e404..62cd74ca41 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -33,12 +33,9 @@ const appsCommandMetadata = defineFieldCommandMetadata( }, ); -const androidRelaunchPackageRequirement = - 'On Android over ADB, --relaunch fails before stopping or opening an app if its package cannot be resolved; retry with the exact installed package name.'; - 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. ${androidRelaunchPackageRequirement}`, + '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.'), @@ -200,11 +197,9 @@ export const openCommandFacet = defineCommandFacet({ text: { summary: 'Open an app, deep link or URL, save replays', cliDetail: - androidRelaunchPackageRequirement + - ' Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', + 'Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app\'s Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app\'s launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if no single installed app owns the scheme, the prompt stays on screen and open returns as usual. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app\'s target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session.', mcpDetail: - androidRelaunchPackageRequirement + - " metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", + "metroHost, metroPort, and bundleUrl choose the app's Metro server and save its address for later reloads. launchUrl selects the app's launch URL and does not select the reload server. A fresh open without Metro hints clears an older same-name binding; close also clears it.", }, metadata: openCommandMetadata, run: (client, input) => client.apps.open(toAppOpenOptions(input)), 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 7019dfe743..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 @@ -408,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/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) ?? From 22dc288c6174048e78c7750aea1f46b3150fac3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 19:31:21 +0200 Subject: [PATCH 8/8] test(android): verify resolved relaunch runner identity --- packages/platform-android/src/lifecycle.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index cab6d08753..79a4228f7c 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -151,13 +151,16 @@ 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, lifecycle } = createLifecycle({ openedAppBundleId: 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); });