Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/adr/0022-daemon-platform-runtime-coupling.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
(`daemon-runtime.ts` → `platform-runtime-apple-runner-owner.ts`,
`platform-runtime-resource-cleanup.ts`, and the dynamic
`platform-runtime-operation-host.ts` import).
- **#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** — surface/relaunch policy and session identity consume a neutral
root surface. Session identity and lifecycle opens share the application tools'
`resolveOpenTarget` result; the daemon supplies no platform callback. Android package
lookup and post-open foreground inference live in `packages/platform-android`, while
Apple target probes stay behind the existing Apple application tools.
- **#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.
Expand Down Expand Up @@ -82,7 +84,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 through the existing application-tool resolution. 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).
Expand Down Expand Up @@ -117,5 +119,5 @@ 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 issues #2333, #2334 (and #2273/#2274 for the selector seam) for the remaining category-3
- Child issue #2333 (and #2273/#2274 for the selector seam) for the remaining category-3
implementation work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { resolveAndroidPackageForOpen } from '../app-deployment-resolution.ts';
import { withFakeAdb } from './test-utils/fake-adb.ts';

test.each([
['com.example.app', 'com.example.app'],
['settings', undefined],
['myapp://login', undefined],
['https://example.com', undefined],
[undefined, undefined],
])('open package resolution avoids a device probe for %s', async (target, expected) => {
await withFakeAdb(
() => new Error('unexpected device probe'),
async ({ calls, device }) => {
expect(await resolveAndroidPackageForOpen(device, target)).toBe(expected);
expect(calls).toEqual([]);
},
);
});

test('open package resolution adopts a unique installed package match', async () => {
await withFakeAdb(
() => 'package:com.example.calendar\npackage:com.example.mail',
async ({ calls, device }) => {
expect(await resolveAndroidPackageForOpen(device, 'calendar')).toBe('com.example.calendar');
expect(calls).toEqual([['shell', 'pm', 'list', 'packages']]);
},
);
});

test.each([
['missing', 'package:com.example.app'],
['ambiguous', 'package:com.example.ambiguous.one\npackage:com.example.ambiguous.two'],
['unavailable', new Error('device unavailable')],
] as const)('open package resolution leaves %s identity inconclusive', async (target, response) => {
await withFakeAdb(
() => response,
async ({ device }) => {
expect(await resolveAndroidPackageForOpen(device, target)).toBeUndefined();
},
);
});
58 changes: 58 additions & 0 deletions packages/platform-android/src/__tests__/window-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { withFakeAdb } from './test-utils/fake-adb.ts';
import {
createAndroidWindowDumpReader,
getAndroidAppState,
inferAndroidPackageAfterOpen,
getAndroidBlockingDialogObservation,
resetAndroidWindowDumpFocusMemoForTests,
type AndroidBlockingDialogObservation,
Expand Down Expand Up @@ -40,6 +41,63 @@ beforeEach(() => {
resetAndroidWindowDumpFocusMemoForTests();
});

test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => {
await withFakeAdb(
() => 'mCurrentFocus=Window{a1b2c3 u0 host.exp.exponent/.experience.ExperienceActivity}',
async ({ calls, device }) => {
assert.equal(
await inferAndroidPackageAfterOpen(device, 'exp://127.0.0.1:8082', undefined),
'host.exp.exponent',
);
assert.deepEqual(
calls.map((args) => args.join(' ')),
[dumpsysWindowWindows],
);
},
);
});

test('post-open inference preserves an existing package without observing another foreground app', async () => {
await withFakeAdb(
() => NORMAL_FOCUS_DUMP,
async ({ calls, device }) => {
assert.equal(
await inferAndroidPackageAfterOpen(device, 'myapp://login', 'com.example.current'),
'com.example.current',
);
assert.deepEqual(calls, []);
},
);
});

test.each([undefined, 'com.example.app'])(
'post-open inference does not probe for a non-URL target: %s',
async (target) => {
await withFakeAdb(
() => NORMAL_FOCUS_DUMP,
async ({ calls, device }) => {
assert.equal(await inferAndroidPackageAfterOpen(device, target, undefined), undefined);
assert.deepEqual(calls, []);
},
);
},
);

test.each(['', new Error('device unavailable')])(
'post-open inference leaves absent or failed foreground evidence inconclusive: %s',
async (response) => {
await withFakeAdb(
() => response,
async ({ device }) => {
assert.equal(
await inferAndroidPackageAfterOpen(device, 'myapp://login', undefined),
undefined,
);
},
);
},
);

test('a focused-window dump answers the blocking-dialog question without a second dumpsys variant', async () => {
const { calls, observation } = await withFakeAdb(
(args) => (args.join(' ') === dumpsysWindowWindows ? NORMAL_FOCUS_DUMP : ''),
Expand Down
15 changes: 15 additions & 0 deletions packages/platform-android/src/app-deployment-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDeepLinkTarget } from '@agent-device/contracts/command';
import { AppError } from '@agent-device/kernel/errors';
import type { DeviceInfo } from '@agent-device/kernel/device';
import {
Expand Down Expand Up @@ -78,6 +79,20 @@ export async function resolveAndroidApp(
});
}

export async function resolveAndroidPackageForOpen(
device: DeviceInfo,
openTarget: string | undefined,
): Promise<string | undefined> {
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;
}
}

/** Produces a readable display label when an Android provider reports only a package id. */
export function inferAndroidAppName(packageName: string): string {
const ignoredTokens = new Set([
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
androidAppsDiscoveryHint,
inferAndroidAppName,
resolveAndroidApp,
resolveAndroidPackageForOpen,
withAndroidAppResolutionCacheInvalidated,
type AndroidAppResolution,
} from './app-deployment-resolution.ts';
Expand Down Expand Up @@ -327,6 +328,7 @@ export {
export {
createAndroidWindowDumpReader,
getAndroidAppState,
inferAndroidPackageAfterOpen,
getAndroidBlockingDialogObservation,
resetAndroidWindowDumpFocusMemoForTests,
type AndroidBlockingDialogObservation,
Expand Down
18 changes: 18 additions & 0 deletions packages/platform-android/src/window-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDeepLinkTarget } from '@agent-device/contracts/command';
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { runAndroidAdb } from './adb.ts';
Expand Down Expand Up @@ -135,6 +136,23 @@ export async function getAndroidAppState(
return {};
}

export async function inferAndroidPackageAfterOpen(
device: DeviceInfo,
openTarget: string | undefined,
currentAppBundleId: string | undefined,
): Promise<string | undefined> {
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;
}
}

/**
* A dump that shows the focused window has answered the blocking-dialog question, whether or not
* the answer is a dialog. The remaining variants exist for devices whose earlier one says nothing
Expand Down
16 changes: 16 additions & 0 deletions scripts/layering/daemon-platform-runtime-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ test('R76 accepts a classified edge with the exact recorded symbols', () => {
assert.deepEqual(edgeViolations(sources, 'src/daemon/device-ready.ts'), []);
});

test('R76 rejects Android package mechanics alongside the selector target policy', () => {
const file = 'src/daemon/handlers/session-selector-dispatch.ts';
const sources = {
'src/platform-runtime-open-target.ts':
'export function resolveAndroidPackageForOpen() {}\n' +
'export function resolveSessionAppBundleIdForTarget() {}\n',
[file]:
"import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" +
'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n',
};
const found = edgeViolations(sources, file);
assert.equal(found.length, 1);
assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE);
assert.match(found[0]!.message, /classified symbols drifted/);
});

for (const [file, target, symbol] of [
['request-recording-health', 'apple-resources', 'inspectAppleRunnerSession'],
['session-device-resolution', 'apple-resources', 'inspectAppleRunnerSession'],
Expand Down
17 changes: 8 additions & 9 deletions scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,22 +167,21 @@ 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 derives session identity through the same neutral open-target ' +
'resolution used by lifecycle operations; application tools own the selected-family ' +
'observations, with Android package lookup behind its package mechanics seam.',
},
{
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 preparation consumes surface and relaunch policy only; device observations ' +
'are owned by the application tools rather than reconstructed in daemon planning.',
},
] as const;

Expand Down
40 changes: 39 additions & 1 deletion src/__tests__/platform-runtime-android-application-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createAndroidApplicationTools } from '../platform-runtime-android-application-tools.ts';
import * as androidMechanics from '../platform-runtime-android-mechanics.ts';

const activateAndroidTestIme = vi.hoisted(() => vi.fn());
const restoreAndroidTestIme = vi.hoisted(() => vi.fn());

vi.mock('@agent-device/platform-android/mechanics', () => ({
activateAndroidTestIme,
restoreAndroidTestIme,
inferAndroidPackageAfterOpen: async () => 'com.example.foreground',
listAndroidAdbSerialsQuick: async () => [],
restoreOrphanedAndroidTestImeOnDaemonStartup: async () => undefined,
}));
Expand All @@ -27,6 +29,42 @@ const settled = {
helperPackageName: 'pkg',
};

describe('android application tools: optional opened package inference', () => {
afterEach(() => vi.restoreAllMocks());

test.each([
['a targetless fresh open', undefined, undefined],
['a targetless open with an existing identity', undefined, 'com.example.app'],
['a deep link with an existing identity', 'example://home', 'com.example.app'],
])('%s needs no Android mechanics', async (_name, target, currentAppBundleId) => {
const load = vi
.spyOn(androidMechanics, 'loadAndroidMechanics')
.mockRejectedValue(new Error('Android mechanics unavailable'));

await expect(
createAndroidApplicationTools().inferOpenedAppBundleId(device, target, currentAppBundleId),
).resolves.toBe(currentAppBundleId);
expect(load).not.toHaveBeenCalled();
});

test('a deep link leaves its package identity unset if Android mechanics cannot load', async () => {
const load = vi
.spyOn(androidMechanics, 'loadAndroidMechanics')
.mockRejectedValue(new Error('Android mechanics unavailable'));

await expect(
createAndroidApplicationTools().inferOpenedAppBundleId(device, 'example://home', undefined),
).resolves.toBeUndefined();
expect(load).toHaveBeenCalledOnce();
});

test('a deep link adopts the inferred foreground package when mechanics are available', async () => {
await expect(
createAndroidApplicationTools().inferOpenedAppBundleId(device, 'example://home', undefined),
).resolves.toBe('com.example.foreground');
});
});

describe('android application tools: test IME activation policy', () => {
// Test IME is default-on for emulators, so an unobtainable helper must not fail the open.
test('an unobtainable helper warns and leaves the open successful', async () => {
Expand Down
48 changes: 48 additions & 0 deletions src/__tests__/platform-runtime-open-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { resolveSessionAppBundleIdForTarget } from '../platform-runtime-open-target.ts';
import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts';

test('session target planning resolves an Android package without a platform callback', async () => {
await expect(
resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, 'com.example.app', undefined),
).resolves.toBe('com.example.app');
});

test('session target planning preserves Android app context across a deep link', async () => {
await expect(
resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, 'myapp://login', 'com.example.app'),
).resolves.toBe('com.example.app');
});

test.each([undefined, 'settings'])(
'session target planning does not retain an Android package for a non-app target: %s',
async (target) => {
await expect(
resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, target, 'com.example.previous'),
).resolves.toBeUndefined();
},
);

const harmonyDevice: DeviceInfo = {
platform: 'harmonyos',
id: '127.0.0.1:5555',
name: 'HarmonyOS Emulator',
kind: 'emulator',
booted: true,
};

test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => {
await expect(
resolveSessionAppBundleIdForTarget(harmonyDevice, 'com.example.application', undefined),
).resolves.toBe('com.example.application');
});

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'),
).resolves.toBe('com.example.application');
},
);
Loading
Loading