Skip to content

Commit 94ee42e

Browse files
committed
refactor(android): extract adb executor and IME cluster into packages/platform-android (#2041)
Implementation moves behind an injected adb host port (R13-clean); shared vocabulary moves to contracts (android-touch-plan, android-helper-artifacts) and kernel (keyed-lock); root keeps thin re-export shims plus the composition wiring that binds the port. session.ts/session-observability.ts no longer import platform transport types (opaque unknown, R62 pattern).
1 parent 74a70f1 commit 94ee42e

24 files changed

Lines changed: 1933 additions & 1641 deletions

‎packages/contracts/package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,14 @@
410410
"./wait-runtime-plan": {
411411
"types": "./src/wait-runtime-plan.ts",
412412
"default": "./src/wait-runtime-plan.ts"
413+
},
414+
"./android-helper-artifacts": {
415+
"types": "./src/android-helper-artifacts.ts",
416+
"default": "./src/android-helper-artifacts.ts"
417+
},
418+
"./android-touch-plan": {
419+
"types": "./src/android-touch-plan.ts",
420+
"default": "./src/android-touch-plan.ts"
413421
}
414422
}
415423
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { AppError } from '@agent-device/kernel/errors';
2+
3+
// Provider-boundary artifact vocabulary for the Android helper APKs. An adb provider may ship
4+
// its own helper artifacts (exactly as the SDK seam allows), so these shapes cross the
5+
// provider/platform boundary and live here rather than with either implementation.
6+
7+
export type AndroidImeHelperManifest = {
8+
name: 'android-ime-helper';
9+
version: string;
10+
assetName: string;
11+
sha256: string;
12+
packageName: string;
13+
versionCode: number;
14+
serviceComponent: string;
15+
broadcastProtocol: 'android-ime-helper-v1';
16+
};
17+
18+
export type AndroidImeHelperArtifact = {
19+
apkPath: string;
20+
manifest: AndroidImeHelperManifest;
21+
};
22+
23+
export type AndroidSnapshotHelperManifest = {
24+
name: 'android-snapshot-helper';
25+
version: string;
26+
releaseTag?: string;
27+
assetName?: string;
28+
apkUrl: string | null;
29+
sha256: string;
30+
checksumName?: string;
31+
packageName: string;
32+
versionCode: number;
33+
instrumentationRunner: string;
34+
minSdk: number;
35+
targetSdk?: number;
36+
outputFormat: 'uiautomator-xml';
37+
statusProtocol: 'android-snapshot-helper-v1';
38+
installArgs: string[];
39+
};
40+
41+
export type AndroidSnapshotHelperArtifact = {
42+
apkPath: string;
43+
manifest: AndroidSnapshotHelperManifest;
44+
};
45+
46+
/** Outcome of the shared install/version-check/checksum lifecycle for a helper APK. */
47+
export type AndroidHelperInstallDecision = {
48+
packageName: string;
49+
versionCode: number;
50+
installedVersionCode?: number;
51+
installedSha256?: string;
52+
installed: boolean;
53+
reason: 'missing' | 'outdated' | 'mismatched' | 'unverifiable' | 'current';
54+
};
55+
56+
export type InstalledAndroidHelperState = Pick<
57+
AndroidHelperInstallDecision,
58+
'installedVersionCode' | 'installedSha256' | 'reason'
59+
>;
60+
61+
// --- Manifest field validators, shared by every helper's manifest parser ---------------------
62+
63+
export function readAndroidHelperManifestInteger(
64+
value: unknown,
65+
field: string,
66+
helperLabel: string,
67+
): number {
68+
if (typeof value !== 'number' || !Number.isInteger(value)) {
69+
throw new AppError(
70+
'INVALID_ARGS',
71+
`Android ${helperLabel} manifest ${field} must be an integer.`,
72+
);
73+
}
74+
return value;
75+
}
76+
77+
export function readAndroidHelperManifestLiteral<const Value extends string>(
78+
value: unknown,
79+
field: string,
80+
expected: Value,
81+
helperLabel: string,
82+
): Value {
83+
if (value !== expected) {
84+
throw new AppError(
85+
'INVALID_ARGS',
86+
`Android ${helperLabel} manifest ${field} must be "${expected}".`,
87+
);
88+
}
89+
return expected;
90+
}
91+
92+
export function readAndroidHelperManifestString(
93+
value: unknown,
94+
field: string,
95+
helperLabel: string,
96+
): string {
97+
if (typeof value !== 'string' || value.length === 0) {
98+
throw new AppError('INVALID_ARGS', `Android ${helperLabel} manifest ${field} is required.`);
99+
}
100+
return value;
101+
}
102+
103+
export function readAndroidHelperManifestSha256(value: unknown, helperLabel: string): string {
104+
const sha256 = readAndroidHelperManifestString(value, 'sha256', helperLabel).trim().toLowerCase();
105+
if (sha256.length !== 64 || !/^[0-9a-f]+$/.test(sha256)) {
106+
throw new AppError(
107+
'INVALID_ARGS',
108+
`Android ${helperLabel} manifest sha256 must be a 64-character hex string.`,
109+
);
110+
}
111+
return sha256;
112+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { interpolateGesturePoint, sampleGestureOffsets } from './gesture-plan.ts';
2+
import type {
3+
GesturePlan,
4+
MultiTouchGesturePlan,
5+
PointerTrajectory,
6+
PointerTrajectorySample,
7+
SinglePointerGesturePlan,
8+
} from './gesture-plan-types.ts';
9+
import type { Rect } from '@agent-device/kernel/snapshot';
10+
11+
export type AndroidLongPressTouchPlan = {
12+
topology: 'single';
13+
intent: 'longPress';
14+
durationMs: number;
15+
pointers: readonly [PointerTrajectory];
16+
};
17+
18+
export type AndroidTouchPlan = GesturePlan | AndroidLongPressTouchPlan;
19+
20+
/**
21+
* Transport samples are strictly denser than the canonical endpoint pair, so the shared plan a
22+
* command builds cannot satisfy the transport types: skipping the lowering below is a type error
23+
* at every injection seam rather than a silently sparse gesture.
24+
*/
25+
type AndroidTransportSamples = readonly [
26+
PointerTrajectorySample,
27+
PointerTrajectorySample,
28+
PointerTrajectorySample,
29+
...PointerTrajectorySample[],
30+
];
31+
32+
type AndroidTransportSinglePointerTrajectory = {
33+
pointerId: 0;
34+
samples: AndroidTransportSamples;
35+
};
36+
37+
export type AndroidTransportSinglePointerGesturePlan = Omit<
38+
SinglePointerGesturePlan,
39+
'pointers'
40+
> & {
41+
pointers: readonly [AndroidTransportSinglePointerTrajectory];
42+
};
43+
44+
export type AndroidTransportGesturePlan =
45+
| AndroidTransportSinglePointerGesturePlan
46+
| MultiTouchGesturePlan;
47+
48+
export type AndroidLoweredTouchPlan = AndroidTransportGesturePlan | AndroidLongPressTouchPlan;
49+
50+
export type AndroidProviderTouchPlan =
51+
| AndroidTransportGesturePlan
52+
| (AndroidLongPressTouchPlan & { viewport: Rect });
53+
54+
export function lowerAndroidTouchPlan(plan: AndroidTouchPlan): AndroidLoweredTouchPlan {
55+
if (plan.topology === 'two' || plan.intent === 'longPress') return plan;
56+
57+
const [{ pointerId, samples: canonicalSamples }] = plan.pointers;
58+
const offsets = [
59+
...new Set([
60+
...sampleGestureOffsets(plan.durationMs, 'android'),
61+
...canonicalSamples.map(({ offsetMs }) => offsetMs),
62+
]),
63+
].sort((left, right) => left - right);
64+
let segmentIndex = 0;
65+
const dense = offsets.map((offsetMs) => {
66+
while (
67+
segmentIndex < canonicalSamples.length - 2 &&
68+
offsetMs > canonicalSamples[segmentIndex + 1]!.offsetMs
69+
) {
70+
segmentIndex += 1;
71+
}
72+
const start = canonicalSamples[segmentIndex]!;
73+
const end = canonicalSamples[segmentIndex + 1]!;
74+
const segmentDurationMs = end.offsetMs - start.offsetMs;
75+
return {
76+
offsetMs,
77+
point:
78+
offsetMs === end.offsetMs || segmentDurationMs === 0
79+
? end.point
80+
: interpolateGesturePoint(
81+
start.point,
82+
end.point,
83+
(offsetMs - start.offsetMs) / segmentDurationMs,
84+
),
85+
};
86+
});
87+
// The sampler floors the frame count at three, so `dense` always holds at least four samples.
88+
const samples: AndroidTransportSamples = [dense[0]!, dense[1]!, dense[2]!, ...dense.slice(3)];
89+
90+
return {
91+
...plan,
92+
pointers: [{ pointerId, samples }],
93+
};
94+
}

‎packages/kernel/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
"./bounds": {
3737
"types": "./src/bounds.ts",
3838
"default": "./src/bounds.ts"
39+
},
40+
"./keyed-lock": {
41+
"types": "./src/keyed-lock.ts",
42+
"default": "./src/keyed-lock.ts"
3943
}
4044
}
4145
}

‎packages/kernel/src/keyed-lock.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
2+
3+
type LockFrame = {
4+
locks: Map<string, Promise<unknown>>;
5+
key: string;
6+
};
7+
8+
const keyedLockStorage = new AsyncLocalStorage<LockFrame[]>();
9+
10+
export async function withKeyedLock<T>(
11+
locks: Map<string, Promise<unknown>>,
12+
key: string,
13+
task: () => Promise<T>,
14+
): Promise<T> {
15+
const activeLocks = keyedLockStorage.getStore() ?? [];
16+
if (activeLocks.some((entry) => entry.locks === locks && entry.key === key)) {
17+
return await task();
18+
}
19+
const previous = locks.get(key) ?? Promise.resolve();
20+
const current = previous
21+
.catch(() => {})
22+
.then(() => keyedLockStorage.run([...activeLocks, { locks, key }], task));
23+
locks.set(key, current);
24+
return current.finally(() => {
25+
if (locks.get(key) === current) {
26+
locks.delete(key);
27+
}
28+
});
29+
}

‎packages/platform-android/package.json‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@
1313
".": {
1414
"types": "./src/index.ts",
1515
"default": "./src/index.ts"
16+
},
17+
"./adb-executor": {
18+
"types": "./src/adb-executor.ts",
19+
"default": "./src/adb-executor.ts"
20+
},
21+
"./adb-host": {
22+
"types": "./src/adb-host.ts",
23+
"default": "./src/adb-host.ts"
24+
},
25+
"./ime-helper": {
26+
"types": "./src/ime-helper.ts",
27+
"default": "./src/ime-helper.ts"
28+
},
29+
"./ime-lifecycle": {
30+
"types": "./src/ime-lifecycle.ts",
31+
"default": "./src/ime-lifecycle.ts"
1632
}
1733
}
1834
}

0 commit comments

Comments
 (0)