Skip to content
Merged
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
24 changes: 0 additions & 24 deletions packages/contracts/src/app-inventory-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,3 @@ export type ListAppsInput = Readonly<{
export type AppInventoryRuntimeOperations = Readonly<{
listApps(input: ListAppsInput): Promise<readonly InstalledAppInfo[]>;
}>;

export type AppInventoryRuntimeHost = Readonly<{
apple: Readonly<{
listApps(
device: DeviceInfo,
filter: AppsFilter,
signal: AbortSignal,
): Promise<readonly InstalledAppInfo[]>;
}>;
android: Readonly<{
listApps(
device: DeviceInfo,
filter: AppsFilter,
signal: AbortSignal,
): Promise<readonly InstalledAppInfo[]>;
}>;
harmonyos: Readonly<{
listApps(
device: DeviceInfo,
filter: AppsFilter,
signal: AbortSignal,
): Promise<readonly InstalledAppInfo[]>;
}>;
}>;
29 changes: 0 additions & 29 deletions packages/contracts/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import type { DeviceInfo } from '@agent-device/kernel/device';

/** Neutral foreground identity returned by a selected platform/provider runtime. */
export type AppStateRuntimeResult = Readonly<{
package?: string;
activity?: string;
}>;

export type AppStateRuntimeCommand = Readonly<{
args: readonly string[];
allowFailure?: boolean;
timeoutMs?: number;
}>;

export type AppStateRuntimeCommandResult = Readonly<{
stdout: string;
}>;

export type AppStateRuntimeOperations = Readonly<{
appState(): Promise<AppStateRuntimeResult>;
}>;

export type AppStateRuntimeHost = Readonly<{
android: Readonly<{
run(
device: DeviceInfo,
command: AppStateRuntimeCommand,
signal: AbortSignal,
): Promise<AppStateRuntimeCommandResult>;
}>;
harmonyos: Readonly<{
run(
device: DeviceInfo,
command: AppStateRuntimeCommand,
signal: AbortSignal,
): Promise<AppStateRuntimeCommandResult>;
}>;
}>;
9 changes: 2 additions & 7 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts';
import type {
AppInventoryRuntimeHost,
AppInventoryRuntimeOperations,
} from './app-inventory-runtime.ts';
import type { AppInventoryRuntimeOperations } from './app-inventory-runtime.ts';
import type {
AndroidAppDeploymentExecutor,
AppDeploymentRuntimeOperations,
AppleAppDeploymentExecutor,
} from './app-deployment-runtime.ts';
import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state-runtime.ts';
import type { AppStateRuntimeOperations } from './app-state-runtime.ts';
import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts';
import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts';
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
Expand Down Expand Up @@ -721,8 +718,6 @@ export const keyboardRuntimePlanUses = Object.freeze([
export type PlatformRuntimeHost = AppLogRuntimeHost &
NetworkRuntimeHost &
Readonly<{
appInventory: AppInventoryRuntimeHost;
appState: AppStateRuntimeHost;
/** Focused native ports; deployment semantics remain in the owning family packages. */
appleDeployment: AppleAppDeploymentExecutor;
androidDeployment: AndroidAppDeploymentExecutor;
Expand Down
17 changes: 16 additions & 1 deletion packages/platform-android/src/app-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { parseAndroidForegroundApp } from './app-state.ts';
import { parseAndroidForegroundApp, readAndroidAppStateWithExecutor } from './app-state.ts';

test('parses Android window and activity foreground markers', () => {
expect(
Expand Down Expand Up @@ -28,3 +28,18 @@ test('scans repeated uncontrolled focus text without regular-expression backtrac
parseAndroidForegroundApp(`ResumedActivity:${'ResumedActivity:a'.repeat(20_000)}`),
).toBeNull();
});

test('stops between dumpsys attempts once the request is aborted', async () => {
const controller = new AbortController();
const issued: string[][] = [];
const run = async (args: string[]) => {
issued.push(args);
controller.abort(new Error('request canceled'));
return { exitCode: 0, stdout: 'mCurrentFocus=Window{1 u0 StatusBar}', stderr: '' };
};

await expect(readAndroidAppStateWithExecutor(run, controller.signal)).rejects.toThrow(
'request canceled',
);
expect(issued).toEqual([['shell', 'dumpsys', 'window', 'windows']]);
});
52 changes: 7 additions & 45 deletions packages/platform-android/src/app-state.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import type {
AppStateRuntimeCommand,
AppStateRuntimeCommandResult,
AppStateRuntimeResult,
} from '@agent-device/contracts/app-state-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
import { parseAndroidFocusSegment } from './app-parsers.ts';

export type AndroidAppStateHost = Readonly<{
run(
device: DeviceInfo,
command: AppStateRuntimeCommand,
signal: AbortSignal,
): Promise<AppStateRuntimeCommandResult>;
}>;

const FOCUS_COMMANDS = [
['shell', 'dumpsys', 'window', 'windows'],
['shell', 'dumpsys', 'window'],
Expand All @@ -29,60 +16,35 @@ export type AndroidCommandExecutor = (

export async function readAndroidAppStateWithExecutor(
run: AndroidCommandExecutor,
signal?: AbortSignal,
): Promise<AppStateRuntimeResult> {
const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS);
const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS, signal);
if (windowFocus) return windowFocus;

const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS);
const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS, signal);
if (activityFocus) return activityFocus;
return {};
}

async function readAndroidFocusWithExecutor(
run: AndroidCommandExecutor,
commands: readonly (readonly string[])[],
signal?: AbortSignal,
): Promise<AppStateRuntimeResult | null> {
for (const args of commands) {
signal?.throwIfAborted();
const result = await run([...args], { allowFailure: true });
signal?.throwIfAborted();
const parsed = parseAndroidForegroundApp(result.stdout ?? '');
if (parsed) return parsed;
}
return null;
}

export async function readAndroidAppState(
host: AndroidAppStateHost,
device: DeviceInfo,
signal: AbortSignal,
): Promise<AppStateRuntimeResult> {
const windowFocus = await readAndroidFocus(host, device, FOCUS_COMMANDS, signal);
if (windowFocus) return windowFocus;

const activityFocus = await readAndroidFocus(host, device, ACTIVITY_COMMANDS, signal);
if (activityFocus) return activityFocus;
return {};
}

export function parseAndroidForegroundApp(text: string): AppStateRuntimeResult | null {
return parseAndroidFocusSegment(text, (segment) => parseAndroidComponentFromSegment(segment));
}

async function readAndroidFocus(
host: AndroidAppStateHost,
device: DeviceInfo,
commands: readonly (readonly string[])[],
signal: AbortSignal,
): Promise<AppStateRuntimeResult | null> {
for (const args of commands) {
signal.throwIfAborted();
const result = await host.run(device, { args, allowFailure: true }, signal);
signal.throwIfAborted();
const parsed = parseAndroidForegroundApp(result.stdout);
if (parsed) return parsed;
}
return null;
}

function parseAndroidComponentFromSegment(segment: string): AppStateRuntimeResult | null {
const match = segment.match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/);
return match?.[1] && match[2] ? { package: match[1], activity: match[2] } : null;
Expand Down
50 changes: 25 additions & 25 deletions packages/platform-android/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import type {
AppStateRuntimeHost,
AppStateRuntimeResult,
} from '@agent-device/contracts/app-state-runtime';
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
import type {
InventoryPlatformModule,
PlatformModuleMetadata,
} from '@agent-device/contracts/platform-module';
import type { PlatformRuntimeModule } from '@agent-device/contracts/platform-runtime-operations';
import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/device-shutdown-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { AndroidInventoryConfig } from './inventory-config.ts';
import type { AndroidAppStateHost } from './app-state.ts';
import type {
AndroidObservationAdapter,
AndroidObservationHost,
Expand All @@ -21,7 +16,6 @@ const metadata = Object.freeze({
} satisfies PlatformModuleMetadata);

export type { AndroidInventoryConfig } from './inventory-config.ts';
export type { AndroidAppStateHost } from './app-state.ts';

/** Package-owned Android observation policy, loaded only when a daemon request needs it. */
export function createAndroidObservationAdapter(
Expand All @@ -46,29 +40,35 @@ export function createAndroidObservationAdapter(
});
}

export async function readAndroidAppState(
host: AndroidAppStateHost | AppStateRuntimeHost['android'],
device: DeviceInfo,
signal: AbortSignal,
): Promise<AppStateRuntimeResult> {
const { readAndroidAppState: read } = await import('./app-state.ts');
return await read(host, device, signal);
}

export async function readAndroidAppStateWithExecutor(
run: import('./app-state.ts').AndroidCommandExecutor,
): Promise<import('@agent-device/contracts/app-state-runtime').AppStateRuntimeResult> {
signal?: AbortSignal,
): Promise<AppStateRuntimeResult> {
const { readAndroidAppStateWithExecutor: read } = await import('./app-state.ts');
return await read(run);
return await read(run, signal);
}

export const runtimeModule = Object.freeze({
...metadata,
loadRuntime: async (host) => {
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
return createAndroidPlatformRuntime(host);
},
} satisfies PlatformRuntimeModule);
/** What the composition root supplies before this package's runtime can reach a device. */
export type AndroidRuntimeModuleDependencies = Readonly<{
/**
* Binds the process-wide adb host port (`bindAndroidAdbHost`) the runtime's mechanics run
* through. Awaited before the runtime loads, so no caller has to import anything first.
*/
bindAdbHost(): Promise<void>;
}>;

export function createAndroidRuntimeModule(
dependencies: AndroidRuntimeModuleDependencies,
): PlatformRuntimeModule {
return Object.freeze({
...metadata,
loadRuntime: async (host) => {
await dependencies.bindAdbHost();
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
return createAndroidPlatformRuntime(host);
},
} satisfies PlatformRuntimeModule);
}

export function createAndroidInventoryModule(
config: AndroidInventoryConfig,
Expand Down
1 change: 0 additions & 1 deletion packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export async function listAndroidAppsWithAdb(
export {
closeAndroidApp,
isAmStartError,
listAndroidApps,
openAndroidApp,
openAndroidDevice,
parseAndroidLaunchComponent,
Expand Down
14 changes: 0 additions & 14 deletions packages/platform-android/src/network/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,6 @@ function host(options: {
readProcessMarker: async () => options.marker,
},
networkTransports: { resolve: async () => ({ mode: 'local' }) },
appInventory: {
apple: { listApps: async () => [] },
android: { listApps: async () => [] },
harmonyos: { listApps: async () => [] },
},
};
}

Expand Down Expand Up @@ -192,16 +187,7 @@ function unusedAppLogHost(): Omit<
terminate: async () => 'already-missing',
},
processTransports: { resolve: async () => ({ mode: 'local' }) },
appInventory: {
apple: { listApps: async () => [] },
android: { listApps: async () => [] },
harmonyos: { listApps: async () => [] },
},
clock: { now: () => 1, sleep: async () => {} },
appState: {
android: { run: async () => ({ stdout: '' }) },
harmonyos: { run: async () => ({ stdout: '' }) },
},
deviceReadiness: {
applePhysical: { ensureConnected: async () => {} },
appleAutomation: {
Expand Down
27 changes: 26 additions & 1 deletion packages/platform-android/src/runtime-facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,36 @@ vi.mock('./logs/runtime.ts', async (loadOriginal) => {
return await loadOriginal();
});

import { runtimeModule } from './index.ts';
import { createAndroidRuntimeModule } from './index.ts';

test('defers Android app-log mechanics until runtime load', async () => {
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost: async () => {} });
expect(mechanics.evaluations).toBe(0);
expect(runtimeModule.family).toBe('android');
await runtimeModule.loadRuntime({} as never);
expect(mechanics.evaluations).toBe(1);
});

test('binds the adb host it was constructed with before the runtime loads', async () => {
const order: string[] = [];
const bindAdbHost = vi.fn(async () => {
order.push('bind-adb-host');
});
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost });

expect(bindAdbHost).not.toHaveBeenCalled();
await runtimeModule.loadRuntime({} as never);
order.push('runtime-loaded');

expect(order).toEqual(['bind-adb-host', 'runtime-loaded']);
});

test('a binding that fails keeps the runtime unloaded', async () => {
const runtimeModule = createAndroidRuntimeModule({
bindAdbHost: async () => {
throw new Error('adb host unavailable');
},
});

await expect(runtimeModule.loadRuntime({} as never)).rejects.toThrow('adb host unavailable');
});
13 changes: 0 additions & 13 deletions packages/platform-android/src/runtime.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,6 @@ const audioProbeHost: PlatformRuntimeHost['audioProbe'] = {
ownedProcesses: { replace: () => {}, clear: () => {} },
};

export const emptyAppInventory = {
apple: { listApps: async () => [] },
android: { listApps: async () => [] },
harmonyos: { listApps: async () => [] },
};

const emptyAppState = {
android: { run: async () => ({ stdout: '' }) },
harmonyos: { run: async () => ({ stdout: '' }) },
};

function localAndroidScreenRecording() {
return {
mode: 'local' as const,
Expand All @@ -72,7 +61,6 @@ export function androidRuntimeHost(overrides: Record<string, unknown> = {}): Pla
return {
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
appInventory: emptyAppInventory,
localInteractors: { resolve: async () => ({}) },
audioProbe: audioProbeHost,
screenRecording: { android: { resolve: async () => localAndroidScreenRecording() } },
Expand All @@ -89,7 +77,6 @@ export function androidNavigationHost(
probeClipboardShellSupport,
runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
},
appState: emptyAppState,
deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } },
});
}
Expand Down
Loading
Loading