Skip to content

Commit 3378d9c

Browse files
authored
refactor: move Android system observation out of daemon (#2071)
1 parent 03f0f40 commit 3378d9c

40 files changed

Lines changed: 807 additions & 82 deletions

packages/contracts/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
"types": "./src/android-input-ownership.ts",
2828
"default": "./src/android-input-ownership.ts"
2929
},
30+
"./android-observation": {
31+
"types": "./src/android-observation.ts",
32+
"default": "./src/android-observation.ts"
33+
},
3034
"./android-snapshot-quality": {
3135
"types": "./src/android-snapshot-quality.ts",
3236
"default": "./src/android-snapshot-quality.ts"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AppStateRuntimeResult } from './app-state-runtime.ts';
2+
import type { DeviceInfo } from '@agent-device/kernel/device';
3+
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
4+
5+
/** Focus facts that are safe to pass between the daemon and an Android owner. */
6+
export type AndroidBlockingDialogFocus = Readonly<{
7+
package?: string;
8+
focusedWindow: string;
9+
raw: string;
10+
}>;
11+
12+
/** One observation of Android's focused system-window state. */
13+
export type AndroidBlockingDialogObservation =
14+
| Readonly<{ status: 'dialog'; focus: AndroidBlockingDialogFocus }>
15+
| Readonly<{ status: 'clear' }>
16+
| Readonly<{ status: 'unknown' }>;
17+
18+
/** The minimal result needed by daemon-owned recovery to classify an adb tap. */
19+
export type AndroidObservationCommandResult = Readonly<{
20+
exitCode: number;
21+
stdout: string;
22+
stderr: string;
23+
}>;
24+
25+
/** Raw Android mechanics supplied by root composition to the package-owned observer. */
26+
export type AndroidObservationHost = Readonly<{
27+
runAdb(
28+
device: DeviceInfo,
29+
args: readonly string[],
30+
options?: Readonly<{ allowFailure?: boolean }>,
31+
): Promise<AndroidObservationCommandResult>;
32+
readSnapshotNodes(device: DeviceInfo): Promise<SnapshotNode[]>;
33+
openApp(device: DeviceInfo, appBundleId: string): Promise<void>;
34+
}>;
35+
36+
/** Root-composed Android observations and narrowly scoped recovery actions. */
37+
export type AndroidObservationAdapter = Readonly<{
38+
readAppState(device: DeviceInfo): Promise<AppStateRuntimeResult>;
39+
readBlockingDialog(device: DeviceInfo): Promise<AndroidBlockingDialogObservation>;
40+
readAppFocus(
41+
device: DeviceInfo,
42+
appBundleId: string,
43+
options?: Readonly<{ requireNoBlockingDialog?: boolean }>,
44+
): Promise<boolean>;
45+
readSnapshotNodes(device: DeviceInfo): Promise<SnapshotNode[]>;
46+
tap(device: DeviceInfo, x: number, y: number): Promise<AndroidObservationCommandResult>;
47+
openApp(device: DeviceInfo, appBundleId: string): Promise<void>;
48+
readScreenSize(device: DeviceInfo): Promise<Readonly<{ width: number; height: number }>>;
49+
isPermissionPackage(packageName: string): Promise<boolean>;
50+
}>;

packages/platform-android/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/
1111
import type { DeviceInfo } from '@agent-device/kernel/device';
1212
import type { AndroidInventoryConfig } from './inventory-config.ts';
1313
import type { AndroidAppStateHost } from './app-state.ts';
14+
import type {
15+
AndroidObservationAdapter,
16+
AndroidObservationHost,
17+
} from '@agent-device/contracts/android-observation';
1418

1519
const metadata = Object.freeze({
1620
family: 'android',
@@ -19,6 +23,29 @@ const metadata = Object.freeze({
1923
export type { AndroidInventoryConfig } from './inventory-config.ts';
2024
export type { AndroidAppStateHost } from './app-state.ts';
2125

26+
/** Package-owned Android observation policy, loaded only when a daemon request needs it. */
27+
export function createAndroidObservationAdapter(
28+
host: AndroidObservationHost,
29+
): AndroidObservationAdapter {
30+
let implementation: Promise<AndroidObservationAdapter> | undefined;
31+
const load = () => {
32+
implementation ??= import('./observation.ts').then(({ createAndroidObservationAdapter }) =>
33+
createAndroidObservationAdapter(host),
34+
);
35+
return implementation;
36+
};
37+
return Object.freeze({
38+
readAppState: async (...args) => await (await load()).readAppState(...args),
39+
readBlockingDialog: async (...args) => await (await load()).readBlockingDialog(...args),
40+
readAppFocus: async (...args) => await (await load()).readAppFocus(...args),
41+
readSnapshotNodes: async (...args) => await (await load()).readSnapshotNodes(...args),
42+
tap: async (...args) => await (await load()).tap(...args),
43+
openApp: async (...args) => await (await load()).openApp(...args),
44+
readScreenSize: async (...args) => await (await load()).readScreenSize(...args),
45+
isPermissionPackage: async (...args) => await (await load()).isPermissionPackage(...args),
46+
});
47+
}
48+
2249
export async function readAndroidAppState(
2350
host: AndroidAppStateHost | AppStateRuntimeHost['android'],
2451
device: DeviceInfo,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect, test, vi } from 'vitest';
2+
import type { AndroidObservationHost } from '@agent-device/contracts/android-observation';
3+
import type { DeviceInfo } from '@agent-device/kernel/device';
4+
import { createAndroidObservationAdapter } from './observation.ts';
5+
6+
const device = {
7+
platform: 'android',
8+
target: 'mobile',
9+
id: 'emulator-5554',
10+
name: 'Pixel',
11+
kind: 'emulator',
12+
booted: true,
13+
} satisfies DeviceInfo;
14+
15+
function hostFor(stdoutByCommand: ReadonlyMap<string, string>): AndroidObservationHost {
16+
return {
17+
runAdb: vi.fn(async (_device, args) => ({
18+
exitCode: 0,
19+
stdout: stdoutByCommand.get(args.join(' ')) ?? '',
20+
stderr: '',
21+
})),
22+
readSnapshotNodes: vi.fn(async () => []),
23+
openApp: vi.fn(async () => {}),
24+
};
25+
}
26+
27+
test('answers focus and blocking-dialog questions from one observation-bound dump', async () => {
28+
const host = hostFor(
29+
new Map([
30+
['shell dumpsys window windows', 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}'],
31+
]),
32+
);
33+
const observer = createAndroidObservationAdapter(host);
34+
35+
await expect(
36+
observer.readAppFocus(device, 'com.example.app', { requireNoBlockingDialog: true }),
37+
).resolves.toBe(true);
38+
expect(host.runAdb).toHaveBeenCalledTimes(1);
39+
});
40+
41+
test('classifies an app-owned ANR without exposing dump mechanics to the daemon', async () => {
42+
const host = hostFor(
43+
new Map([
44+
[
45+
'shell dumpsys window windows',
46+
'mCurrentFocus=Window{1 u0 Application Not Responding: com.example.app}',
47+
],
48+
]),
49+
);
50+
51+
await expect(createAndroidObservationAdapter(host).readBlockingDialog(device)).resolves.toEqual({
52+
status: 'dialog',
53+
focus: {
54+
package: 'com.example.app',
55+
focusedWindow: 'Application Not Responding: com.example.app',
56+
raw: 'mCurrentFocus=Window{1 u0 Application Not Responding: com.example.app}',
57+
},
58+
});
59+
});
60+
61+
test('owns touch rounding and screen-size parsing behind raw host commands', async () => {
62+
const host = hostFor(new Map([['shell wm size', 'Physical size: 1080x2400']]));
63+
const observer = createAndroidObservationAdapter(host);
64+
65+
await expect(observer.readScreenSize(device)).resolves.toEqual({ width: 1080, height: 2400 });
66+
await observer.tap(device, 12.6, 42.2);
67+
expect(host.runAdb).toHaveBeenLastCalledWith(device, ['shell', 'input', 'tap', '13', '42'], {
68+
allowFailure: true,
69+
});
70+
});
71+
72+
test('a transient empty dump never demotes a variant that previously answered', async () => {
73+
const calls: string[] = [];
74+
let primaryReads = 0;
75+
const host: AndroidObservationHost = {
76+
...hostFor(new Map()),
77+
runAdb: vi.fn(async (_device, args) => {
78+
const key = args.join(' ');
79+
calls.push(key);
80+
if (key === 'shell dumpsys window windows') primaryReads += 1;
81+
return {
82+
exitCode: 0,
83+
stdout:
84+
primaryReads === 2 && key === 'shell dumpsys window windows'
85+
? ''
86+
: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}',
87+
stderr: '',
88+
};
89+
}),
90+
};
91+
const observer = createAndroidObservationAdapter(host);
92+
const transientDevice = { ...device, id: 'emulator-transient' };
93+
94+
await observer.readAppState(transientDevice);
95+
await observer.readAppState(transientDevice);
96+
const thirdReadStart = calls.length;
97+
await observer.readAppState(transientDevice);
98+
99+
expect(calls[thirdReadStart]).toBe('shell dumpsys window windows');
100+
});

0 commit comments

Comments
 (0)