diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index b423b92606..6fff12e779 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -54,9 +54,14 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. the Apple runner owner, the Android snapshot-helper and Web orphan cleanups, and legacy app-log marker recovery. The `resource-cleanup` edge stays, reclassified composition-essential and carrying only `platformResourceCleanup`. - - **#2334** — open-target planning separated from platform mechanics - (`session-open-prepare.ts`, `session-selector-dispatch.ts` → - `platform-runtime-open-target.ts`), blocked by #2332. + - **Open-target planning (done, #2334)** — `platform-runtime-open-target.ts` keeps only the + neutral open plan/result surface (`resolveRequestedOpenSurface`, `validateOpenRelaunchTarget`, + `resolveSessionAppBundleIdForTarget`); Android package resolution + (`resolveAndroidPackageForOpen`, `inferAndroidPackageAfterOpen`) moved behind the Android + owning seam in `packages/platform-android`. `session-open-prepare.ts` consumes surface and + relaunch-target policy, `session-selector-dispatch.ts` consumes the app-bundle-identity + resolver; both edges are daemon-policy-essential, and the resolver stays the one + construction path for the open plan. - **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the selector seam those issues own; coordination was posted there rather than opening a second selector producer. @@ -85,7 +90,7 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. 5. **Per-audit-area decisions.** Apple session observation: consume the neutral `AppleSessionObservation` contract. Runtime lifecycle participation: deepen through the existing lifecycle phases, no generic hook bag (#2333). Open-target planning: separate plan/result from - platform mechanics, one construction path preserved (#2334). Session state/store authority: + platform mechanics, one construction path preserved. Session state/store authority: keep the current access shape, ratchet the handler-owned slice (R75). Route depth: record the re-traced routes; collapse only the proven pass-through hops (none undertaken in this change). @@ -120,5 +125,4 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. `scripts/layering/check.ts` (both observed red against planted violations before acceptance). - R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice. - R65 for the concrete-platform-import ban this audit builds on. -- Child issue #2334 (and #2273/#2274 for the selector seam) for the remaining category-3 - implementation work; #2333 landed (see §2.2). +- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 remain for the selector seam. diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 37ccae0b02..3ae57b756b 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -239,7 +239,7 @@ async function openAndroidAppBoundDeepLink( throw new AppError('INVALID_ARGS', 'Android app-bound open requires a valid URL target'); } await ensureAndroidLocalhostReverse(device, deepLinkUrl); - const resolved = await resolveAndroidPackageForOpen(device, app, 'app-bound open'); + const resolved = await requireAndroidPackageForOpen(device, app, 'app-bound open'); await runAndroidAdb(device, [ 'shell', 'am', @@ -352,7 +352,7 @@ function buildAndroidActivityLaunchArgs( ]; } -async function resolveAndroidPackageForOpen( +async function requireAndroidPackageForOpen( device: DeviceInfo, app: string, label: string, diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index adad0225ec..721349d43c 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -190,6 +190,22 @@ export { formatAndroidInstalledPackageRequiredMessage, type AndroidAppTargetKind, } from './open-target.ts'; +export async function resolveAndroidPackageForOpen( + ...args: Parameters +): Promise< + Awaited> +> { + const { resolveAndroidPackageForOpen: load } = await import('./open-target-resolution.ts'); + return await load(...args); +} +export async function inferAndroidPackageAfterOpen( + ...args: Parameters +): Promise< + Awaited> +> { + const { inferAndroidPackageAfterOpen: load } = await import('./open-target-resolution.ts'); + return await load(...args); +} export { resetAndroidFramePerfStats, sampleAndroidFramePerf, diff --git a/packages/platform-android/src/open-target-resolution.test.ts b/packages/platform-android/src/open-target-resolution.test.ts new file mode 100644 index 0000000000..7287cb73de --- /dev/null +++ b/packages/platform-android/src/open-target-resolution.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +const resolveAndroidApp = vi.hoisted(() => vi.fn()); +const getAndroidAppState = vi.hoisted(() => vi.fn()); + +vi.mock('./app-deployment-resolution.ts', () => ({ resolveAndroidApp })); +vi.mock('./window-state.ts', () => ({ getAndroidAppState })); + +const { resolveAndroidPackageForOpen, inferAndroidPackageAfterOpen } = + await import('./open-target-resolution.ts'); + +const androidDevice: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, +}; +const appleDevice: DeviceInfo = { + platform: 'apple', + id: '00000000-0000-0000-0000-000000000000', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('resolveAndroidPackageForOpen returns the resolved package for a package match', async () => { + resolveAndroidApp.mockResolvedValue({ type: 'package', value: 'com.example.demo' }); + + await expect(resolveAndroidPackageForOpen(androidDevice, 'demo')).resolves.toBe( + 'com.example.demo', + ); +}); + +test('resolveAndroidPackageForOpen ignores an intent resolution', async () => { + resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' }); + + await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBeUndefined(); +}); + +test('resolveAndroidPackageForOpen swallows resolution failures', async () => { + resolveAndroidApp.mockRejectedValue(new AppError('APP_NOT_INSTALLED', 'No package found')); + + await expect(resolveAndroidPackageForOpen(androidDevice, 'demo')).resolves.toBeUndefined(); + expect(resolveAndroidApp).toHaveBeenCalled(); +}); + +test('resolveAndroidPackageForOpen skips non-Android devices without resolving', async () => { + await expect(resolveAndroidPackageForOpen(appleDevice, 'demo')).resolves.toBeUndefined(); + expect(resolveAndroidApp).not.toHaveBeenCalled(); +}); + +test('resolveAndroidPackageForOpen skips a deep-link target without resolving', async () => { + await expect( + resolveAndroidPackageForOpen(androidDevice, 'myapp://login'), + ).resolves.toBeUndefined(); + expect(resolveAndroidApp).not.toHaveBeenCalled(); +}); + +test('inferAndroidPackageAfterOpen reads the foreground package for a deep-link open', async () => { + getAndroidAppState.mockResolvedValue({ + package: 'host.exp.exponent', + activity: 'host.exp.exponent.experience.ExperienceActivity', + }); + + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), + ).resolves.toBe('host.exp.exponent'); +}); + +test('inferAndroidPackageAfterOpen keeps an already-known bundle id without reading state', async () => { + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', 'com.example.demo'), + ).resolves.toBe('com.example.demo'); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + +test('inferAndroidPackageAfterOpen leaves a non-deep-link target unchanged', async () => { + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'com.example.demo', undefined), + ).resolves.toBeUndefined(); + expect(getAndroidAppState).not.toHaveBeenCalled(); +}); + +test('inferAndroidPackageAfterOpen swallows a foreground-state read failure', async () => { + getAndroidAppState.mockRejectedValue(new Error('adb connection dropped')); + + await expect( + inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), + ).resolves.toBeUndefined(); +}); diff --git a/packages/platform-android/src/open-target-resolution.ts b/packages/platform-android/src/open-target-resolution.ts new file mode 100644 index 0000000000..e8211211bd --- /dev/null +++ b/packages/platform-android/src/open-target-resolution.ts @@ -0,0 +1,37 @@ +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'; + +/** Resolves an `open` target to an installed package; only an exact package match counts. */ +export async function resolveAndroidPackageForOpen( + device: DeviceInfo, + openTarget: string | undefined, +): Promise { + if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) + return undefined; + try { + const resolved = await resolveAndroidApp(device, openTarget); + return resolved.type === 'package' ? resolved.value : undefined; + } catch { + return undefined; + } +} + +/** A deep-link open can foreground a different package than the one requested; read it back. */ +export async function inferAndroidPackageAfterOpen( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, +): Promise { + if (currentAppBundleId) return currentAppBundleId; + if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) { + return currentAppBundleId; + } + try { + const foreground = await getAndroidAppState(device); + return foreground.package?.trim() || currentAppBundleId; + } catch { + return currentAppBundleId; + } +} diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index 8c3d3e1a9b..cca21db597 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -98,6 +98,25 @@ test('R76 rejects new symbols on a classified edge', () => { assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/); }); +test('R76 rejects a reintroduced Android-mechanics import on the selector-dispatch edge', () => { + const sources = { + 'src/platform-runtime-open-target.ts': + 'export async function resolveSessionAppBundleIdForTarget() { return undefined; }\n' + + 'export async function resolveAndroidPackageForOpen() { return undefined; }\n', + 'src/daemon/handlers/session-selector-dispatch.ts': + "import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" + + 'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n', + }; + const found = edgeViolations(sources, 'src/daemon/handlers/session-selector-dispatch.ts'); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /classified symbols drifted/); + assert.match( + found[0]!.message, + /resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget/, + ); +}); + test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { const sources = { 'src/platform-runtime-daemon-lifecycle.ts': diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index f610c20856..97d438866c 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -151,22 +151,24 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] { file: 'src/daemon/handlers/session-selector-dispatch.ts', target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveAndroidPackageForOpen', 'resolveSessionAppBundleIdForTarget'], - classification: 'leaked-platform-mechanics', + symbols: ['resolveSessionAppBundleIdForTarget'], + classification: 'daemon-policy-essential', rationale: - 'selector dispatch consumes the mixed open-target module; Android package resolution ' + - 'is platform mechanics that should sit behind the Android owning seam.', - deepenedBy: '#2334', + 'selector dispatch reconstructs the session app-bundle identity after a trigger-app-event ' + + 'deep link through the one neutral open-plan resolver (#2334); Android package resolution ' + + 'moved behind the Android owning seam in packages/platform-android, so the resolver is the ' + + 'only symbol this edge names.', }, { file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', target: 'src/platform-runtime-open-target.ts', symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], - classification: 'leaked-platform-mechanics', + classification: 'daemon-policy-essential', rationale: - 'open-prepare policy consumes the mixed open-target module; the neutral open ' + - 'plan/result should be separated from the platform mechanics that share the file.', - deepenedBy: '#2334', + 'open-prepare policy consumes only the neutral open plan/result surface (#2334): surface ' + + 'classification and relaunch-target validation. The platform mechanics that used to share ' + + 'the file (Android package resolution) moved behind the Android owning seam, leaving this ' + + 'edge daemon policy over two neutral, non-mechanics functions.', }, ] as const; diff --git a/src/__tests__/platform-runtime-android-application-tools-infer-open-target.test.ts b/src/__tests__/platform-runtime-android-application-tools-infer-open-target.test.ts new file mode 100644 index 0000000000..6a3f75ecb0 --- /dev/null +++ b/src/__tests__/platform-runtime-android-application-tools-infer-open-target.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +vi.mock('../platform-runtime-android-mechanics.ts', () => ({ + loadAndroidMechanics: vi.fn(async () => { + throw new Error('adb host unavailable'); + }), +})); + +import { loadAndroidMechanics } from '../platform-runtime-android-mechanics.ts'; +import { createAndroidApplicationTools } from '../platform-runtime-android-application-tools.ts'; + +const mockLoadAndroidMechanics = vi.mocked(loadAndroidMechanics); + +beforeEach(() => { + mockLoadAndroidMechanics.mockClear(); +}); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + booted: true, +}; + +test('inferOpenedAppBundleId stays best-effort when Android mechanics fails to load for a targetless open', async () => { + await expect( + createAndroidApplicationTools().inferOpenedAppBundleId(device, undefined, undefined), + ).resolves.toBeUndefined(); +}); + +test('inferOpenedAppBundleId skips loading Android mechanics when the app-bundle identity is already known', async () => { + await expect( + createAndroidApplicationTools().inferOpenedAppBundleId( + device, + 'exp://127.0.0.1:8082', + 'com.example.demo', + ), + ).resolves.toBe('com.example.demo'); + expect(mockLoadAndroidMechanics).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 5ad1583ae0..8b4e583526 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -22,14 +22,14 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => await importOriginal(); return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn() }; -}); vi.mock('@agent-device/platform-android/mechanics', () => ({ activateAndroidTestIme: vi.fn(async () => ({ activated: false })), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}), + resolveAndroidPackageForOpen: vi.fn(), + inferAndroidPackageAfterOpen: vi.fn( + async (_device, _target, currentAppBundleId) => currentAppBundleId, + ), })); vi.mock('@agent-device/host-kit/process', async (importOriginal) => (await import('../../../__tests__/test-utils/host-process-mock.ts')).pinOwnProcessStartTime( @@ -40,8 +40,10 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintValues } from '../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts'; -import { activateAndroidTestIme } from '@agent-device/platform-android/mechanics'; +import { + activateAndroidTestIme, + resolveAndroidPackageForOpen, +} from '@agent-device/platform-android/mechanics'; import { discoverReadyAndroidEmulators, dispatchApplicationLifecycleEffect, diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index c416d5e2a3..97c98f6ae8 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -46,8 +46,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index 31fc7522dd..a5531a6744 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -68,16 +68,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), + resolveAndroidPackageForOpen: vi.fn(async () => undefined), }; }); vi.mock('@agent-device/host-kit/command', async (importOriginal) => { @@ -113,7 +110,7 @@ import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId, } from '@agent-device/platform-apple/app-resolution'; -import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { runCmd } from '@agent-device/host-kit/command'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c876d377dd..bec0f91e98 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -14,10 +14,7 @@ import { resolveBoundAppEventRuntime } from '../app-event-runtime.ts'; import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { - resolveAndroidPackageForOpen, - resolveSessionAppBundleIdForTarget, -} from '../../platform-runtime-open-target.ts'; +import { resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { DaemonCommandContext } from '../context.ts'; import type { DeviceReadyOptions } from '../device-ready.ts'; @@ -228,7 +225,6 @@ export async function handleAppEventCommand( session.device, eventUrl, session.appBundleId, - resolveAndroidPackageForOpen, )) ?? session.appBundleId) : session.appBundleId; return { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index dad7ddb26d..43dcbe44ea 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -41,14 +41,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => await importOriginal(); return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.demo') }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })) }; + return { + ...actual, + activateAndroidTestIme: vi.fn(async () => ({ activated: false })), + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + }; }); vi.mock('@agent-device/host-kit/process', async (importOriginal) => { const actual = await importOriginal(); @@ -64,7 +63,7 @@ import { applyRuntimeHintValues, clearRuntimeHintValues, } from '../../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { makeAndroidEmulator, 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 d92d1895ae..09eea8543c 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 @@ -53,14 +53,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => await importOriginal(); return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.demo') }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })) }; + return { + ...actual, + activateAndroidTestIme: vi.fn(async () => ({ activated: false })), + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + }; }); vi.mock('@agent-device/host-kit/process', async (importOriginal) => { const actual = await importOriginal(); @@ -73,7 +72,7 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; import { applyRuntimeHintValues } from '../../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { lifecycleRuntimeFacts } from '../../../__tests__/application-lifecycle-runtime-harness.ts'; import { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts index 1cc81c4664..104eead874 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts @@ -1,23 +1,13 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { getAndroidAppState } from '@agent-device/platform-android/mechanics'; -import { - inferAndroidPackageAfterOpen, - resolveSessionAppBundleIdForTarget, -} from '../../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; +import { resolveSessionAppBundleIdForTarget } from '../../../../platform-runtime-open-target.ts'; vi.mock('@agent-device/platform-android/mechanics', () => ({ - getAndroidAppState: vi.fn(), + resolveAndroidPackageForOpen: vi.fn(), })); -const mockGetAndroidAppState = vi.mocked(getAndroidAppState); -const androidDevice: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, -}; +const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); const harmonyDevice: DeviceInfo = { platform: 'harmonyos', id: '127.0.0.1:5555', @@ -30,41 +20,18 @@ beforeEach(() => { vi.clearAllMocks(); }); -test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => { - mockGetAndroidAppState.mockResolvedValue({ - package: 'host.exp.exponent', - activity: 'host.exp.exponent.experience.ExperienceActivity', - }); - - await expect( - inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), - ).resolves.toBe('host.exp.exponent'); -}); - test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => { - const resolveAndroidPackageForOpen = vi.fn(async () => undefined); - await expect( - resolveSessionAppBundleIdForTarget( - harmonyDevice, - 'com.example.application', - undefined, - resolveAndroidPackageForOpen, - ), + resolveSessionAppBundleIdForTarget(harmonyDevice, 'com.example.application', undefined), ).resolves.toBe('com.example.application'); - expect(resolveAndroidPackageForOpen).not.toHaveBeenCalled(); + expect(mockResolveAndroidPackage).not.toHaveBeenCalled(); }); test.each(['myapp://login', 'https://example.com', 'Demo App'])( 'HarmonyOS retains the existing app across non-bundle targets: %s', async (openTarget) => { await expect( - resolveSessionAppBundleIdForTarget( - harmonyDevice, - openTarget, - 'com.example.application', - vi.fn(async () => undefined), - ), + resolveSessionAppBundleIdForTarget(harmonyDevice, openTarget, 'com.example.application'), ).resolves.toBe('com.example.application'); }, ); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts index 8be53b738c..991affa8aa 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts @@ -38,9 +38,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); diff --git a/src/platform-runtime-android-application-tools.ts b/src/platform-runtime-android-application-tools.ts index e77c27e382..57178f3eef 100644 --- a/src/platform-runtime-android-application-tools.ts +++ b/src/platform-runtime-android-application-tools.ts @@ -25,8 +25,13 @@ export function createAndroidApplicationTools(): AndroidApplicationTools { return Object.freeze({ resolveOpenTarget: async (device, input) => await resolveAndroidOpenTarget(device, input), inferOpenedAppBundleId: async (device, target, currentAppBundleId) => { - const { inferAndroidPackageAfterOpen } = await loadOpenTarget(); - return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId); + if (currentAppBundleId) return currentAppBundleId; + try { + const { inferAndroidPackageAfterOpen } = await loadAndroidMechanics(); + return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId); + } catch { + return currentAppBundleId; + } }, resetFramePerfStats: async (device, appBundleId) => { const { resetAndroidFramePerfStats } = await loadAndroidMechanics(); @@ -96,14 +101,12 @@ async function resolveAndroidOpenTarget( device: DeviceInfo, input: OpenTargetResolutionInput, ): Promise { - const { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } = - await loadOpenTarget(); + const { resolveSessionAppBundleIdForTarget } = await loadOpenTarget(); return { appBundleId: await resolveSessionAppBundleIdForTarget( device, input.target, input.currentAppBundleId, - resolveAndroidPackageForOpen, ), appName: input.target, }; diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 321e287871..81a91765d7 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -145,12 +145,7 @@ async function resolveAppleOpenTarget( return { appBundleId: macOsSurface.appBundleId ?? - (await resolveSessionAppBundleIdForTarget( - device, - input.target, - input.currentAppBundleId, - async () => undefined, - )), + (await resolveSessionAppBundleIdForTarget(device, input.target, input.currentAppBundleId)), appName: macOsSurface.appName ?? input.target, }; } diff --git a/src/platform-runtime-open-target.ts b/src/platform-runtime-open-target.ts index 9a5f2e3deb..4d6930c4af 100644 --- a/src/platform-runtime-open-target.ts +++ b/src/platform-runtime-open-target.ts @@ -145,39 +145,6 @@ async function tryResolveIosAppBundleId( } } -export async function resolveAndroidPackageForOpen( - device: DeviceInfo, - openTarget: string | undefined, -): Promise { - if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) - return undefined; - try { - const { resolveAndroidApp } = await loadAndroidMechanics(); - const resolved = await resolveAndroidApp(device, openTarget); - return resolved.type === 'package' ? resolved.value : undefined; - } catch { - return undefined; - } -} - -export async function inferAndroidPackageAfterOpen( - device: DeviceInfo, - openTarget: string | undefined, - currentAppBundleId: string | undefined, -): Promise { - if (currentAppBundleId) return currentAppBundleId; - if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) { - return currentAppBundleId; - } - try { - const { getAndroidAppState } = await loadAndroidMechanics(); - const foreground = await getAndroidAppState(device); - return foreground.package?.trim() || currentAppBundleId; - } catch { - return currentAppBundleId; - } -} - function shouldPreserveAndroidPackageContext( device: DeviceInfo, openTarget: string | undefined, @@ -196,17 +163,26 @@ export async function resolveSessionAppBundleIdForTarget( device: DeviceInfo, openTarget: string | undefined, currentAppBundleId: string | undefined, - resolveAndroidPackageForOpenFn: ( - device: DeviceInfo, - openTarget: string | undefined, - ) => Promise, ): Promise { if (device.platform === 'harmonyos') { return bundleIdFromOpenTarget(openTarget) ?? currentAppBundleId; } return ( (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? - (await resolveAndroidPackageForOpenFn(device, openTarget)) ?? + (await tryResolveAndroidPackageForOpen(device, openTarget)) ?? (shouldPreserveAndroidPackageContext(device, openTarget) ? currentAppBundleId : undefined) ); } + +async function tryResolveAndroidPackageForOpen( + device: DeviceInfo, + openTarget: string | undefined, +): Promise { + if (device.platform !== 'android' || !openTarget) return undefined; + try { + const { resolveAndroidPackageForOpen } = await loadAndroidMechanics(); + return await resolveAndroidPackageForOpen(device, openTarget); + } catch { + return undefined; + } +}