diff --git a/.fallowrc.json b/.fallowrc.json index 715663d05..fda571e81 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -8,7 +8,6 @@ "src/metro.ts", "src/remote-config.ts", "src/install-source.ts", - "src/android-apps.ts", "src/android-adb.ts", "src/android-snapshot-helper.ts", "src/contracts.ts", @@ -17,7 +16,6 @@ "src/bin.ts", "src/companion-tunnel.ts", "src/daemon.ts", - "src/daemon-embedding.ts", "src/utils/update-check-entry.ts", "test/scripts/metro-prepare-packaged-smoke.mjs", "test/integration/*.test.ts", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7b0d0d2d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.15.0 + +- Breaking: removed the `agent-device/android-apps` public subpath. Use the Android app helpers from `agent-device/android-adb`. +- Breaking: removed the `agent-device/daemon` public subpath. Use `agent-device/contracts` for daemon request/response types. +- Breaking: removed public local ADB bypass/selection helpers such as `spawnAndroidAdbBySerial` and `resolveAndroidAdbProvider`; use `createLocalAndroidAdbProvider(device)` or pass providers directly to the helpers from `agent-device/android-adb`. +- Added Android ADB provider helpers for exec, stream, clipboard, keyboard, app lifecycle, logcat, and port reverse workflows. diff --git a/README.md b/README.md index 319ac47a6..81f50cad6 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Snapshots assign refs like `@e1`, `@e2`, and `@e3` to current-screen elements. R `agent-device` runs session-aware commands through platform backends: XCTest for iOS and tvOS, ADB plus the Android snapshot helper for Android, a local helper for macOS desktop automation, and AT-SPI for Linux desktop targets. See [Introduction](https://incubator.callstack.com/agent-device/docs/introduction) and [Commands](https://incubator.callstack.com/agent-device/docs/commands) for platform details. -Node consumers can use the typed client and public subpaths for bridge integrations. `agent-device/android-adb` exposes the Android ADB provider contract and reusable helpers for ADB-backed app listing and foreground state. `agent-device/daemon` exposes the supported daemon embedding surface for integrations that intentionally reuse the upstream request router. +Node consumers can use the typed client and public subpaths for bridge integrations. `agent-device/android-adb` exposes the Android ADB provider contract, logcat/clipboard/keyboard/app helpers, and port reverse management. ## Used By diff --git a/package.json b/package.json index 80b68a09a..3ffb987fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-device", - "version": "0.14.3", + "version": "0.15.0", "description": "Agent-driven CLI for mobile UI automation, network inspection, and performance diagnostics across iOS, Android, tvOS, and macOS.", "license": "MIT", "author": "Callstack", @@ -45,10 +45,6 @@ "import": "./dist/src/install-source.js", "types": "./dist/src/install-source.d.ts" }, - "./android-apps": { - "import": "./dist/src/android-apps.js", - "types": "./dist/src/android-apps.d.ts" - }, "./android-adb": { "import": "./dist/src/android-adb.js", "types": "./dist/src/android-adb.d.ts" @@ -57,10 +53,6 @@ "import": "./dist/src/android-snapshot-helper.js", "types": "./dist/src/android-snapshot-helper.d.ts" }, - "./daemon": { - "import": "./dist/src/daemon-embedding.js", - "types": "./dist/src/daemon-embedding.d.ts" - }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" diff --git a/rslib.config.ts b/rslib.config.ts index 5c2a2bcdc..2b4e2b169 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -23,10 +23,8 @@ export default defineConfig({ metro: 'src/metro.ts', 'remote-config': 'src/remote-config.ts', 'install-source': 'src/install-source.ts', - 'android-apps': 'src/android-apps.ts', 'android-adb': 'src/android-adb.ts', 'android-snapshot-helper': 'src/android-snapshot-helper.ts', - 'daemon-embedding': 'src/daemon-embedding.ts', contracts: 'src/contracts.ts', selectors: 'src/selectors.ts', finders: 'src/finders.ts', diff --git a/src/__tests__/android-adb-public.test.ts b/src/__tests__/android-adb-public.test.ts new file mode 100644 index 000000000..9d9031e3c --- /dev/null +++ b/src/__tests__/android-adb-public.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +test('public android-adb entrypoint exposes helpers but not resolver internals', async () => { + const androidAdb = await import('../android-adb.ts'); + + assert.equal(typeof androidAdb.createLocalAndroidAdbProvider, 'function'); + assert.equal(typeof androidAdb.createAndroidPortReverseManager, 'function'); + assert.equal(typeof androidAdb.captureAndroidLogcatWithAdb, 'function'); + assert.equal(typeof androidAdb.streamAndroidLogcatWithAdb, 'function'); + assert.equal(typeof androidAdb.listAndroidAppsWithAdb, 'function'); + assert.equal(typeof androidAdb.getAndroidAppStateWithAdb, 'function'); + assert.equal(typeof androidAdb.readAndroidClipboardWithAdb, 'function'); + assert.equal(typeof androidAdb.dismissAndroidKeyboardWithAdb, 'function'); + assert.equal(typeof androidAdb.openAndroidAppWithAdb, 'function'); + + assert.equal('resolveAndroidAdbProvider' in androidAdb, false); + assert.equal('resolveAndroidAdbExecutor' in androidAdb, false); + assert.equal('createDeviceAdbExecutor' in androidAdb, false); + assert.equal('withAndroidAdbProvider' in androidAdb, false); + assert.equal('spawnAndroidAdbBySerial' in androidAdb, false); +}); diff --git a/src/__tests__/android-apps-public.test.ts b/src/__tests__/android-apps-public.test.ts deleted file mode 100644 index 7e3fd3d04..000000000 --- a/src/__tests__/android-apps-public.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import { - parseAndroidForegroundApp, - parseAndroidLaunchablePackages, - parseAndroidUserInstalledPackages, -} from '../android-apps.ts'; - -test('public android-apps entrypoint re-exports pure parsers', () => { - assert.deepEqual( - parseAndroidLaunchablePackages( - [ - 'com.google.android.apps.maps/.MainActivity', - 'org.mozilla.firefox/.App', - 'com.google.android.apps.maps/.MainActivity', - '', - ].join('\n'), - ), - ['com.google.android.apps.maps', 'org.mozilla.firefox'], - ); - assert.deepEqual( - parseAndroidUserInstalledPackages( - ['package:com.google.android.apps.maps', 'package:org.mozilla.firefox', ''].join('\n'), - ), - ['com.google.android.apps.maps', 'org.mozilla.firefox'], - ); - assert.deepEqual(parseAndroidUserInstalledPackages('package:com.example\nraw.package'), [ - 'com.example', - 'raw.package', - ]); - assert.deepEqual( - parseAndroidForegroundApp( - [ - 'mResumedActivity: ActivityRecord{123 u0 com.example.old/.OldActivity t1}', - 'mCurrentFocus=Window{17b u0 com.google.android.apps.maps/.MainActivity}', - ].join('\n'), - ), - { - package: 'com.google.android.apps.maps', - activity: '.MainActivity', - }, - ); - assert.deepEqual( - parseAndroidForegroundApp( - 'mFocusedApp=AppWindowToken{17b token=Token{abc ActivityRecord{def u0 org.mozilla.firefox/.App t1}}}', - ), - { - package: 'org.mozilla.firefox', - activity: '.App', - }, - ); - assert.deepEqual( - parseAndroidForegroundApp( - 'mResumedActivity: ActivityRecord{123 u0 com.example.app/com.example.app.MainActivity t1}', - ), - { - package: 'com.example.app', - activity: 'com.example.app.MainActivity', - }, - ); - assert.deepEqual( - parseAndroidForegroundApp( - 'ResumedActivity: ActivityRecord{123 link=https://example.test/path u0 com.example.next/.NextActivity t1}', - ), - { - package: 'com.example.next', - activity: '.NextActivity', - }, - ); - assert.equal(parseAndroidForegroundApp('mCurrentFocus=Window{17b u0 no component here}'), null); - assert.equal(parseAndroidForegroundApp(''), null); -}); diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts new file mode 100644 index 000000000..b7250be48 --- /dev/null +++ b/src/__tests__/package-exports.test.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import assert from 'node:assert/strict'; + +test('package exports only supported public subpaths', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) as { + exports: Record; + }; + + assert.equal(pkg.exports['./android-apps'], undefined); + assert.equal(pkg.exports['./daemon'], undefined); + assert.equal(pkg.exports['./android-adb'] !== undefined, true); + assert.equal(pkg.exports['./contracts'] !== undefined, true); +}); diff --git a/src/android-adb.ts b/src/android-adb.ts index d29699bd8..750e6c066 100644 --- a/src/android-adb.ts +++ b/src/android-adb.ts @@ -1,18 +1,40 @@ export { - createDeviceAdbExecutor, - resolveAndroidAdbExecutor, - spawnAndroidAdbBySerial, - withAndroidAdbProvider, + createAndroidPortReverseManager, + createLocalAndroidAdbProvider, type AndroidAdbExecutor, type AndroidAdbExecutorOptions, + type AndroidAdbProcess, type AndroidAdbExecutorResult, type AndroidAdbProvider, type AndroidAdbSpawner, + type AndroidPortReverseEndpoint, + type AndroidPortReverseMapping, + type AndroidPortReverseOptions, + type AndroidPortReverseProvider, } from './platforms/android/adb-executor.ts'; export { getAndroidAppStateWithAdb, listAndroidAppsWithAdb, } from './platforms/android/app-helpers.ts'; +export { + forceStopAndroidAppWithAdb, + openAndroidAppWithAdb, + resolveAndroidLaunchComponentWithAdb, + type AndroidOpenAppWithAdbOptions, +} from './platforms/android/app-control.ts'; +export { + captureAndroidLogcatWithAdb, + streamAndroidLogcatWithAdb, + type AndroidLogcatCaptureOptions, + type AndroidLogcatStreamOptions, +} from './platforms/android/logcat.ts'; +export { + dismissAndroidKeyboardWithAdb, + getAndroidKeyboardStatusWithAdb, + readAndroidClipboardWithAdb, + writeAndroidClipboardWithAdb, + type AndroidKeyboardState, +} from './platforms/android/device-input-state.ts'; export type { AndroidAppListFilter, AndroidAppListOptions, diff --git a/src/android-apps.ts b/src/android-apps.ts deleted file mode 100644 index 52ebcee34..000000000 --- a/src/android-apps.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - parseAndroidForegroundApp, - parseAndroidLaunchablePackages, - parseAndroidUserInstalledPackages, - type AndroidForegroundApp, -} from './platforms/android/app-parsers.ts'; diff --git a/src/daemon-embedding.ts b/src/daemon-embedding.ts deleted file mode 100644 index 5533ad9c1..000000000 --- a/src/daemon-embedding.ts +++ /dev/null @@ -1,38 +0,0 @@ -export { createRequestHandler } from './daemon/request-router.ts'; -export type { AndroidAdbProviderResolver, RequestRouterDeps } from './daemon/request-router.ts'; -export { withDeviceInventoryProvider } from './core/dispatch-resolve.ts'; -export type { DeviceInventoryProvider, DeviceInventoryRequest } from './core/dispatch-resolve.ts'; -export { SessionStore } from './daemon/session-store.ts'; -export { LeaseRegistry } from './daemon/lease-registry.ts'; -export type { - AdmissionRequest, - AllocateLeaseRequest, - HeartbeatLeaseRequest, - LeaseRegistryOptions, - ReleaseLeaseRequest, - SimulatorLease, -} from './daemon/lease-registry.ts'; -export { - cleanupDownloadableArtifact, - cleanupUploadedArtifact, - prepareDownloadableArtifact, - prepareUploadedArtifact, - trackDownloadableArtifact, - trackUploadedArtifact, -} from './daemon/artifact-tracking.ts'; -export type { - DaemonArtifact, - DaemonInstallSource, - DaemonRequest, - DaemonResponse, - DaemonResponseData, - SessionRuntimeHints, - SessionState, -} from './daemon/types.ts'; -export type { DeviceInfo, Platform, PlatformSelector } from './utils/device.ts'; -export type { - AndroidAdbExecutor, - AndroidAdbExecutorOptions, - AndroidAdbExecutorResult, - AndroidAdbProvider, -} from './android-adb.ts'; diff --git a/src/daemon/__tests__/app-log-android.test.ts b/src/daemon/__tests__/app-log-android.test.ts index 36afc7aad..5ba767406 100644 --- a/src/daemon/__tests__/app-log-android.test.ts +++ b/src/daemon/__tests__/app-log-android.test.ts @@ -7,9 +7,13 @@ import path from 'node:path'; import { PassThrough } from 'node:stream'; vi.mock('node:child_process', () => ({ spawn: vi.fn() })); -vi.mock('../../utils/exec.ts', () => ({ - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), -})); +vi.mock('../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + }; +}); vi.mock('../app-log-stream.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, sleep: vi.fn(async () => {}) }; @@ -25,16 +29,18 @@ const mockRunCmd = vi.mocked(runCmd); type MockChild = EventEmitter & { stdout: PassThrough; stderr: PassThrough; - pid: number; + pid?: number; killed: boolean; kill: (signal?: NodeJS.Signals) => boolean; }; -function makeMockChild(pid: number): MockChild { +function makeMockChild(pid?: number): MockChild { const child = new EventEmitter() as MockChild; child.stdout = new PassThrough(); child.stderr = new PassThrough(); - child.pid = pid; + if (pid !== undefined) { + child.pid = pid; + } child.killed = false; child.kill = () => { if (child.killed) return false; @@ -91,6 +97,33 @@ test('startAndroidAppLog returns to active state after a successful reattach', a await appLog.wait; }); +test('startAndroidAppLog reports active for provider streams without host pid', async () => { + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-android-log-')); + const stream = fs.createWriteStream(path.join(logDir, 'app.log')); + const child = makeMockChild(); + + mockRunCmd.mockReset(); + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { + return { stdout: '111\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + mockSpawn.mockReset(); + mockSpawn.mockImplementation(() => child as unknown as ReturnType); + + const appLog = await startAndroidAppLog('emulator-5554', 'com.example.app', stream, []); + await vi.waitFor(() => { + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + assert.equal(appLog.getState(), 'active'); + + await appLog.stop(); + await appLog.wait; +}); + test('readRecentAndroidLogcatForPackage keeps lines for package-associated prior pids', async () => { mockRunCmd.mockReset(); mockRunCmd.mockImplementation(async (_cmd, args) => { diff --git a/src/daemon/__tests__/request-router-android-adb-provider-conformance.test.ts b/src/daemon/__tests__/request-router-android-adb-provider-conformance.test.ts new file mode 100644 index 000000000..a707bc8bd --- /dev/null +++ b/src/daemon/__tests__/request-router-android-adb-provider-conformance.test.ts @@ -0,0 +1,110 @@ +import { expect, test, vi } from 'vitest'; + +vi.mock('../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async (cmd: string) => { + if (cmd === 'adb') { + throw new Error('local adb must not be used'); + } + return { stdout: '', stderr: '', exitCode: 0 }; + }), + whichCmd: vi.fn(async (cmd: string) => cmd !== 'adb'), + }; +}); +vi.mock('../device-ready.ts', () => ({ + DEVICE_READY_CACHE_TTL_MS: 5_000, + clearDeviceReadyCacheForTests: vi.fn(), + ensureDeviceReady: vi.fn(async () => {}), +})); + +import { createRequestHandler } from '../request-router.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; + +test('Android daemon commands route through injected provider without host adb', async () => { + const sessionStore = makeSessionStore('agent-device-router-adb-provider-conformance-'); + sessionStore.set('default', { + name: 'default', + createdAt: Date.now(), + device: { + platform: 'android', + id: 'remote-android-1', + name: 'Remote Android', + kind: 'device', + booted: true, + }, + appBundleId: 'com.example.app', + actions: [], + }); + const adbCalls: string[][] = []; + const provider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push(args); + if (args.join(' ') === 'shell cmd clipboard get text') { + return { stdout: 'clipboard text: hello', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === 'shell dumpsys input_method') { + return { stdout: 'mInputShown=false inputType=0x1', stderr: '', exitCode: 0 }; + } + if (args[0] === 'shell' && args[1]?.startsWith('screenrecord ')) { + return { stdout: '4242\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ').startsWith('shell stat -c %s /sdcard/agent-device-recording-')) { + return { stdout: '100\n', stderr: '', exitCode: 0 }; + } + return { stdout: 'ok', stderr: '', exitCode: 0 }; + }, + }; + const handler = createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + trackDownloadableArtifact: () => 'artifact-id', + androidAdbProvider: () => provider, + }); + + const clipboard = await handler({ + token: 'token', + session: 'default', + command: 'clipboard', + positionals: ['read'], + flags: {}, + }); + const keyboard = await handler({ + token: 'token', + session: 'default', + command: 'keyboard', + positionals: ['status'], + flags: {}, + }); + const doctor = await handler({ + token: 'token', + session: 'default', + command: 'logs', + positionals: ['doctor'], + flags: {}, + }); + const record = await handler({ + token: 'token', + session: 'default', + command: 'record', + positionals: ['start'], + flags: {}, + }); + + expect(clipboard.ok).toBe(true); + expect(keyboard.ok).toBe(true); + expect(doctor.ok).toBe(true); + expect(record.ok).toBe(true); + expect(adbCalls).toContainEqual(['shell', 'cmd', 'clipboard', 'get', 'text']); + expect(adbCalls).toContainEqual(['shell', 'dumpsys', 'input_method']); + expect(adbCalls).toContainEqual(['shell', 'echo', 'ok']); + expect(adbCalls).toContainEqual(['shell', 'pidof', 'com.example.app']); + expect(adbCalls.some((args) => args[0] === 'shell' && args[1]?.startsWith('screenrecord '))).toBe( + true, + ); +}); diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index affb715b4..1cbe0d8a6 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -54,12 +54,16 @@ vi.mock('../../platforms/android/index.ts', async (importOriginal) => { const execCalls: string[][] = []; -vi.mock('../../utils/exec.ts', () => ({ - runCmd: vi.fn(async (_cmd: string, args: string[]) => { - execCalls.push(args); - return { stdout: '', stderr: '', exitCode: 0 }; - }), -})); +vi.mock('../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async (_cmd: string, args: string[]) => { + execCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }), + }; +}); function makeAndroidSession(name: string): SessionState { return { diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index f11c462d4..91dfce371 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -1,6 +1,5 @@ import { openAndroidApp, snapshotAndroid, getAndroidAppState } from '../platforms/android/index.ts'; -import { adbArgs } from '../platforms/android/adb.ts'; -import { runCmd } from '../utils/exec.ts'; +import { runAndroidAdb } from '../platforms/android/adb.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { centerOfRect, attachRefs, type SnapshotNode } from '../utils/snapshot.ts'; import { sleep } from '../utils/timeouts.ts'; @@ -31,15 +30,9 @@ export async function recoverAndroidBlockingSystemDialog(params: { } const { x, y } = centerOfRect(closeAppButton.rect); - const tapResult = await runCmd( - 'adb', - adbArgs(session.device, [ - 'shell', - 'input', - 'tap', - String(Math.round(x)), - String(Math.round(y)), - ]), + const tapResult = await runAndroidAdb( + session.device, + ['shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], { allowFailure: true }, ); if (tapResult.exitCode !== 0) { diff --git a/src/daemon/app-log-android.ts b/src/daemon/app-log-android.ts index 537a5c162..da193066c 100644 --- a/src/daemon/app-log-android.ts +++ b/src/daemon/app-log-android.ts @@ -1,7 +1,15 @@ import fs from 'node:fs'; -import { spawnAndroidAdbBySerial } from '../platforms/android/adb-executor.ts'; +import { + resolveAndroidAdbExecutor, + resolveAndroidAdbProvider, + type AndroidAdbProcess, +} from '../platforms/android/adb-executor.ts'; +import { androidDeviceForSerial } from '../platforms/android/adb.ts'; +import { + captureAndroidLogcatWithAdb, + streamAndroidLogcatWithAdb, +} from '../platforms/android/logcat.ts'; import { AppError } from '../utils/errors.ts'; -import { runCmd } from '../utils/exec.ts'; import { clearPidFile, readStoredAppLogProcessMeta, @@ -22,9 +30,10 @@ export async function resolveAndroidPid( deviceId: string, appBundleId: string, ): Promise { - const pidResult = await runCmd('adb', ['-s', deviceId, 'shell', 'pidof', appBundleId], { - allowFailure: true, - }); + const pidResult = await resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId))( + ['shell', 'pidof', appBundleId], + { allowFailure: true }, + ); const pid = pidResult.stdout.trim().split(/\s+/)[0]; if (!pid || !/^\d+$/.test(pid)) return null; return pid; @@ -43,18 +52,18 @@ export async function readRecentAndroidLogcatForPackage( ): Promise<{ pid: string | null; text: string; recoveredPids: string[] } | null> { assertAndroidPackageArgSafe(appBundleId); const pid = await resolveAndroidPid(deviceId, appBundleId); - const dump = await runCmd('adb', ['-s', deviceId, 'logcat', '-d', '-v', 'time', '-t', '4000'], { - allowFailure: true, - timeoutMs: 3_000, - }); - if (dump.exitCode !== 0 || dump.stdout.trim().length === 0) { + const adb = resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId)); + const text = await captureAndroidLogcatWithAdb(adb, { lines: 4000, timeoutMs: 3_000 }).catch( + () => '', + ); + if (text.trim().length === 0) { return null; } - const recoveredPids = collectAndroidPackagePids(dump.stdout, appBundleId, pid); + const recoveredPids = collectAndroidPackagePids(text, appBundleId, pid); if (recoveredPids.length === 0) { return null; } - const filteredText = filterAndroidLogcatToPids(dump.stdout, appBundleId, recoveredPids); + const filteredText = filterAndroidLogcatToPids(text, appBundleId, recoveredPids); if (filteredText.trim().length === 0) { return null; } @@ -70,7 +79,7 @@ export async function startAndroidAppLog( ): Promise { let state: AppLogState = 'recovering'; let stopped = false; - let activeChild: ReturnType | undefined; + let activeChild: AndroidAdbProcess | undefined; let activeWait: ReturnType | undefined; const wait = (async () => { @@ -82,16 +91,15 @@ export async function startAndroidAppLog( await sleep(1_000); continue; } - const child = spawnAndroidAdbBySerial(deviceId, ['logcat', '-v', 'time', '--pid', pid], { - stdio: ['ignore', 'pipe', 'pipe'], - }); + const provider = resolveAndroidAdbProvider(androidDeviceForSerial(deviceId)); + const child = streamAndroidLogcatWithAdb(provider, { pid }); activeChild = child; const writer = createLineWriter(stream, { redactionPatterns }); activeWait = attachChildToStream(child, stream, { endStreamOnClose: false, writer }); if (typeof child.pid === 'number') { writePidFile(pidPath, child.pid); - state = 'active'; } + state = 'active'; await activeWait; clearPidFile(pidPath); activeChild = undefined; diff --git a/src/daemon/app-log-stream.ts b/src/daemon/app-log-stream.ts index 0f5aecefe..59229ecb1 100644 --- a/src/daemon/app-log-stream.ts +++ b/src/daemon/app-log-stream.ts @@ -1,5 +1,5 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; +import type { Readable } from 'node:stream'; import type { ExecResult } from '../utils/exec.ts'; export async function waitForChildExit( @@ -53,8 +53,17 @@ export function createLineWriter( }; } +type StreamableChildProcess = { + killed: boolean; + kill(signal?: NodeJS.Signals | number): boolean; + stdout: Readable | null; + stderr: Readable | null; + on(event: 'error', listener: (error: Error) => void): unknown; + on(event: 'close', listener: (code: number | null) => void): unknown; +}; + export function attachChildToStream( - child: ReturnType, + child: StreamableChildProcess, stream: fs.WriteStream, options: { endStreamOnClose: boolean; diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index 5c117b524..edb05289c 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { DeviceInfo } from '../utils/device.ts'; import { AppError } from '../utils/errors.ts'; import { runCmd } from '../utils/exec.ts'; +import { runAndroidAdb } from '../platforms/android/adb.ts'; import { assertAndroidPackageArgSafe, readTrackedAndroidLogcatPid, @@ -388,15 +389,19 @@ export async function runAppLogDoctor( } if (device.platform === 'android') { try { - const adb = await runCmd('adb', ['version'], { allowFailure: true }); + const adb = await runAndroidAdb(device, ['shell', 'echo', 'ok'], { + allowFailure: true, + timeoutMs: 1_000, + }); checks.adbAvailable = adb.exitCode === 0; } catch { checks.adbAvailable = false; } if (appBundleId) { try { - const pidof = await runCmd('adb', ['-s', device.id, 'shell', 'pidof', appBundleId], { + const pidof = await runAndroidAdb(device, ['shell', 'pidof', appBundleId], { allowFailure: true, + timeoutMs: 1_000, }); checks.androidPidVisible = pidof.stdout.trim().length > 0; } catch { diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index e95882cc4..6fe90034d 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -3,13 +3,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../../utils/exec.ts', () => ({ - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), - runCmdBackground: vi.fn(() => ({ - child: { kill: vi.fn() }, - wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), - })), -})); +vi.mock('../../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + runCmdBackground: vi.fn(() => ({ + child: { kill: vi.fn() }, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + })), + }; +}); vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index f3d42f0dc..4874c48cc 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -1,11 +1,16 @@ import fs from 'node:fs'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { sleep } from '../../utils/timeouts.ts'; +import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import type { RecordTraceDeps } from './record-trace-types.ts'; import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; +import type { + AndroidAdbExecutorOptions, + AndroidAdbExecutorResult, +} from '../../platforms/android/adb-executor.ts'; const ANDROID_REMOTE_FILE_POLL_MS = 250; const ANDROID_REMOTE_FILE_ATTEMPTS = 20; @@ -30,6 +35,14 @@ type AndroidRecordingBase = Pick< | 'gestureEvents' >; +async function runAndroidRecordingAdb( + deviceId: string, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); +} + function parseAndroidRemotePid(stdout: string): string | undefined { return stdout .split(/\r?\n/) @@ -38,18 +51,10 @@ function parseAndroidRemotePid(stdout: string): string | undefined { .at(-1); } -async function isAndroidProcessRunning( - deps: RecordTraceDeps, - deviceId: string, - pid: string, -): Promise { - const result = await deps.runCmd( - 'adb', - ['-s', deviceId, 'shell', 'ps', '-o', 'pid=', '-p', pid], - { - allowFailure: true, - }, - ); +async function isAndroidProcessRunning(deviceId: string, pid: string): Promise { + const result = await runAndroidRecordingAdb(deviceId, ['shell', 'ps', '-o', 'pid=', '-p', pid], { + allowFailure: true, + }); if (result.exitCode !== 0) { return false; } @@ -59,22 +64,17 @@ async function isAndroidProcessRunning( .includes(pid); } -async function waitForAndroidProcessExit( - deps: RecordTraceDeps, - deviceId: string, - pid: string, -): Promise { +async function waitForAndroidProcessExit(deviceId: string, pid: string): Promise { for (let attempt = 0; attempt < ANDROID_PROCESS_EXIT_ATTEMPTS; attempt += 1) { - if (!(await isAndroidProcessRunning(deps, deviceId, pid))) { + if (!(await isAndroidProcessRunning(deviceId, pid))) { return true; } await sleep(ANDROID_PROCESS_EXIT_POLL_MS); } - return !(await isAndroidProcessRunning(deps, deviceId, pid)); + return !(await isAndroidProcessRunning(deviceId, pid)); } async function waitForAndroidRemoteFileStability( - deps: RecordTraceDeps, deviceId: string, remotePath: string, ): Promise { @@ -82,9 +82,9 @@ async function waitForAndroidRemoteFileStability( let stableCount = 0; for (let attempt = 0; attempt < ANDROID_REMOTE_FILE_ATTEMPTS; attempt += 1) { - const statResult = await deps.runCmd( - 'adb', - ['-s', deviceId, 'shell', 'stat', '-c', '%s', remotePath], + const statResult = await runAndroidRecordingAdb( + deviceId, + ['shell', 'stat', '-c', '%s', remotePath], { allowFailure: true }, ); const currentSize = statResult.exitCode === 0 ? statResult.stdout.trim() : ''; @@ -102,15 +102,14 @@ async function waitForAndroidRemoteFileStability( } async function waitForAndroidRecordingReady( - deps: RecordTraceDeps, deviceId: string, remotePath: string, remotePid: string, ): Promise { for (let attempt = 0; attempt < ANDROID_RECORDING_READY_ATTEMPTS; attempt += 1) { - const statResult = await deps.runCmd( - 'adb', - ['-s', deviceId, 'shell', 'stat', '-c', '%s', remotePath], + const statResult = await runAndroidRecordingAdb( + deviceId, + ['shell', 'stat', '-c', '%s', remotePath], { allowFailure: true }, ); const currentSize = statResult.exitCode === 0 ? Number(statResult.stdout.trim()) : NaN; @@ -118,7 +117,7 @@ async function waitForAndroidRecordingReady( return true; } - if (!(await isAndroidProcessRunning(deps, deviceId, remotePid))) { + if (!(await isAndroidProcessRunning(deviceId, remotePid))) { return false; } @@ -151,7 +150,7 @@ async function copyAndroidRecordingWithValidation(params: { // Ignore stale local file cleanup issues and let adb pull report the real failure. } - const pullResult = await deps.runCmd('adb', ['-s', deviceId, 'pull', remotePath, outPath], { + const pullResult = await runAndroidRecordingAdb(deviceId, ['pull', remotePath, outPath], { allowFailure: true, }); if (pullResult.exitCode !== 0) { @@ -213,16 +212,15 @@ function androidRemoteRecordingPaths(timestamp: number): string[] { } async function resolveAndroidRecordingSize(params: { - deps: RecordTraceDeps; deviceId: string; quality: number | undefined; }): Promise<{ width: number; height: number } | undefined> { - const { deps, deviceId, quality } = params; + const { deviceId, quality } = params; if (quality === undefined || quality >= 10) { return undefined; } - const sizeResult = await deps.runCmd('adb', ['-s', deviceId, 'shell', 'wm', 'size'], { + const sizeResult = await runAndroidRecordingAdb(deviceId, ['shell', 'wm', 'size'], { allowFailure: true, }); const match = @@ -256,22 +254,14 @@ function buildAndroidScreenrecordCommand( return `${screenrecordArgs.join(' ')} >/dev/null 2>&1 & echo $!`; } -async function cleanupAndroidRemoteRecording( - deps: RecordTraceDeps, - deviceId: string, - remotePath: string, -): Promise { - await deps.runCmd('adb', ['-s', deviceId, 'shell', 'rm', '-f', remotePath], { +async function cleanupAndroidRemoteRecording(deviceId: string, remotePath: string): Promise { + await runAndroidRecordingAdb(deviceId, ['shell', 'rm', '-f', remotePath], { allowFailure: true, }); } -async function forceStopAndroidProcess( - deps: RecordTraceDeps, - deviceId: string, - pid: string, -): Promise { - const forceResult = await deps.runCmd('adb', ['-s', deviceId, 'shell', 'kill', '-9', pid], { +async function forceStopAndroidProcess(deviceId: string, pid: string): Promise { + const forceResult = await runAndroidRecordingAdb(deviceId, ['shell', 'kill', '-9', pid], { allowFailure: true, }); emitDiagnostic({ @@ -285,24 +275,22 @@ async function forceStopAndroidProcess( stderr: forceResult.stderr.trim(), }, }); - if (forceResult.exitCode !== 0 && (await isAndroidProcessRunning(deps, deviceId, pid))) { + if (forceResult.exitCode !== 0 && (await isAndroidProcessRunning(deviceId, pid))) { return false; } - return await waitForAndroidProcessExit(deps, deviceId, pid); + return await waitForAndroidProcessExit(deviceId, pid); } export async function startAndroidRecording(params: { - deps: RecordTraceDeps; device: AndroidDevice; recordingBase: AndroidRecordingBase; }): Promise { - const { deps, device, recordingBase } = params; + const { device, recordingBase } = params; let lastStartError = 'failed to start recording: Android screenrecord did not begin producing frames'; let recordingSize: { width: number; height: number } | undefined; try { recordingSize = await resolveAndroidRecordingSize({ - deps, deviceId: device.id, quality: recordingBase.quality, }); @@ -311,9 +299,9 @@ export async function startAndroidRecording(params: { } for (const remotePath of androidRemoteRecordingPaths(Date.now())) { - const startResult = await deps.runCmd( - 'adb', - ['-s', device.id, 'shell', buildAndroidScreenrecordCommand(remotePath, recordingSize)], + const startResult = await runAndroidRecordingAdb( + device.id, + ['shell', buildAndroidScreenrecordCommand(remotePath, recordingSize)], { allowFailure: true, }, @@ -327,7 +315,7 @@ export async function startAndroidRecording(params: { if (!remotePid) { lastStartError = 'failed to start recording: adb did not return a valid Android screenrecord pid'; - await cleanupAndroidRemoteRecording(deps, device.id, remotePath); + await cleanupAndroidRemoteRecording(device.id, remotePath); continue; } @@ -341,7 +329,7 @@ export async function startAndroidRecording(params: { }, }); - if (await waitForAndroidRecordingReady(deps, device.id, remotePath, remotePid)) { + if (await waitForAndroidRecordingReady(device.id, remotePath, remotePid)) { return { platform: 'android', remotePath, @@ -353,8 +341,8 @@ export async function startAndroidRecording(params: { lastStartError = 'failed to start recording: Android screenrecord did not begin producing frames'; - await forceStopAndroidProcess(deps, device.id, remotePid); - await cleanupAndroidRemoteRecording(deps, device.id, remotePath); + await forceStopAndroidProcess(device.id, remotePid); + await cleanupAndroidRemoteRecording(device.id, remotePath); } return errorResponse('COMMAND_FAILED', lastStartError); @@ -375,9 +363,9 @@ export async function stopAndroidRecording(params: { remotePid: recording.remotePid, }, }); - const stopResult = await deps.runCmd( - 'adb', - ['-s', device.id, 'shell', 'kill', '-2', recording.remotePid], + const stopResult = await runAndroidRecordingAdb( + device.id, + ['shell', 'kill', '-2', recording.remotePid], { allowFailure: true, }, @@ -396,20 +384,20 @@ export async function stopAndroidRecording(params: { }); let stopError: string | undefined; if (stopResult.exitCode !== 0) { - if (await isAndroidProcessRunning(deps, device.id, recording.remotePid)) { - if (!(await forceStopAndroidProcess(deps, device.id, recording.remotePid))) { + if (await isAndroidProcessRunning(device.id, recording.remotePid)) { + if (!(await forceStopAndroidProcess(device.id, recording.remotePid))) { stopError = `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'adb shell kill')}`; } } - } else if (!(await waitForAndroidProcessExit(deps, device.id, recording.remotePid))) { - if (!(await forceStopAndroidProcess(deps, device.id, recording.remotePid))) { + } else if (!(await waitForAndroidProcessExit(device.id, recording.remotePid))) { + if (!(await forceStopAndroidProcess(device.id, recording.remotePid))) { stopError = `failed to stop recording: Android screenrecord pid ${recording.remotePid} did not exit`; } } let cleanupError: string | undefined; if (!stopError) { - await waitForAndroidRemoteFileStability(deps, device.id, recording.remotePath); + await waitForAndroidRemoteFileStability(device.id, recording.remotePath); const copyError = await copyAndroidRecordingWithValidation({ deps, deviceId: device.id, @@ -441,9 +429,9 @@ export async function stopAndroidRecording(params: { return null; async function cleanupRemoteRecording(): Promise { - const rmResult = await deps.runCmd( - 'adb', - ['-s', device.id, 'shell', 'rm', '-f', recording.remotePath], + const rmResult = await runAndroidRecordingAdb( + device.id, + ['shell', 'rm', '-f', recording.remotePath], { allowFailure: true, }, diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index af6adc0d1..e9344ae17 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -252,7 +252,7 @@ async function startRecording(params: { resolvedOut, }); } else { - recording = await startAndroidRecording({ deps, device, recordingBase }); + recording = await startAndroidRecording({ device, recordingBase }); } if ('ok' in recording) { diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 800c9904a..150717dba 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -1,5 +1,4 @@ import { normalizeError } from '../../utils/errors.ts'; -import { runCmd } from '../../utils/exec.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { isApplePlatform, type DeviceInfo } from '../../utils/device.ts'; import { runMacOsAlertAction } from '../../platforms/ios/macos-helper.ts'; @@ -8,6 +7,7 @@ import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { stopAppLog } from '../app-log.ts'; +import { runAndroidAdb } from '../../platforms/android/adb.ts'; import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts'; import { shutdownSimulator } from '../../platforms/ios/simulator.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; @@ -27,7 +27,7 @@ async function shutdownAndroidEmulator(device: DeviceInfo): Promise<{ stdout: string; stderr: string; }> { - const result = await runCmd('adb', ['-s', device.id, 'emu', 'kill'], { + const result = await runAndroidAdb(device, ['emu', 'kill'], { allowFailure: true, timeoutMs: 15_000, }); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 4b6c9dfd8..91f998fd5 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -85,10 +85,15 @@ const leaseAdmissionExemptCommands = new Set([ const sessionExecutionExemptCommands = new Set(leaseAdmissionExemptCommands); const sessionExecutionLocks = new Map>(); +export type AndroidAdbProviderRequestSession = Pick< + SessionState, + 'name' | 'device' | 'appBundleId' | 'appName' | 'surface' +>; + export type AndroidAdbProviderResolver = (params: { req: DaemonRequest; device: DeviceInfo; - session?: SessionState; + session?: AndroidAdbProviderRequestSession; }) => AndroidAdbProvider | AndroidAdbExecutor | undefined; // --------------------------------------------------------------------------- @@ -322,6 +327,7 @@ async function resolveScopedAndroidAdbProvider(params: { }): Promise<{ provider?: AndroidAdbProvider | AndroidAdbExecutor; executor?: AndroidAdbExecutor; + serial?: string; }> { const { req, existingSession, androidAdbProvider } = params; if (!androidAdbProvider) return {}; @@ -329,7 +335,7 @@ async function resolveScopedAndroidAdbProvider(params: { if (!device) return {}; const provider = androidAdbProvider({ req, device, session: existingSession }); const executor = typeof provider === 'function' ? provider : provider?.exec; - return { provider, executor }; + return { provider, executor, serial: device.id }; } // --------------------------------------------------------------------------- @@ -593,7 +599,7 @@ function recordVisualizationAndAction(params: { } // --------------------------------------------------------------------------- -// Public API +// Request handler API // --------------------------------------------------------------------------- export type RequestRouterDeps = { @@ -613,15 +619,9 @@ export type RequestRouterDeps = { export function createRequestHandler( deps: RequestRouterDeps, ): (req: DaemonRequest) => Promise { - const { - logPath, - token, - sessionStore, - leaseRegistry, - androidAdbProvider, - deviceInventoryProvider, - trackDownloadableArtifact, - } = deps; + const { logPath, token, androidAdbProvider, deviceInventoryProvider, trackDownloadableArtifact } = + deps; + const { sessionStore, leaseRegistry } = deps; async function handleRequest(req: DaemonRequest): Promise { const debug = Boolean(req.meta?.debug || req.flags?.verbose); @@ -708,48 +708,52 @@ export function createRequestHandler( existingSession, androidAdbProvider, }); - return await withAndroidAdbProvider(requestAdb.provider, async () => { - // The ADB provider is scoped to this single locked request; handlers may re-read - // the session state, but all device-scoped adb calls in this request share it. - // Phase 1: Try specialized handler chain - const handlerResponse = await runHandlerChain({ - req: lockedReq, - sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke: handleRequest, - androidAdbExecutor: requestAdb.executor, - contextFromFlags: (flags, appBundleId, traceLogPath) => - ({ - ...contextFromFlags(logPath, flags, appBundleId, traceLogPath), - surface: sessionStore.get(sessionName)?.surface, - }) satisfies DaemonCommandContext, - }); - if (handlerResponse) return finalize(handlerResponse); - - // Phase 2: Require active session for generic dispatch - const session = sessionStore.get(sessionName); - if (!session) { - return finalize({ - ok: false, - error: { - code: 'SESSION_NOT_FOUND', - message: 'No active session. Run open first.', - }, + return await withAndroidAdbProvider( + requestAdb.provider, + { serial: requestAdb.serial ?? '' }, + async () => { + // The ADB provider is scoped to this single locked request; handlers may re-read + // the session state, but all device-scoped adb calls in this request share it. + // Phase 1: Try specialized handler chain + const handlerResponse = await runHandlerChain({ + req: lockedReq, + sessionName, + logPath, + sessionStore, + leaseRegistry, + invoke: handleRequest, + androidAdbExecutor: requestAdb.executor, + contextFromFlags: (flags, appBundleId, traceLogPath) => + ({ + ...contextFromFlags(logPath, flags, appBundleId, traceLogPath), + surface: sessionStore.get(sessionName)?.surface, + }) satisfies DaemonCommandContext, }); - } - - // Phase 3: Dispatch command directly to device - const dispatchResponse = await dispatchGenericCommand({ - req: lockedReq, - session, - sessionName, - logPath, - sessionStore, - }); - return finalize(dispatchResponse); - }); + if (handlerResponse) return finalize(handlerResponse); + + // Phase 2: Require active session for generic dispatch + const session = sessionStore.get(sessionName); + if (!session) { + return finalize({ + ok: false, + error: { + code: 'SESSION_NOT_FOUND', + message: 'No active session. Run open first.', + }, + }); + } + + // Phase 3: Dispatch command directly to device + const dispatchResponse = await dispatchGenericCommand({ + req: lockedReq, + session, + sessionName, + logPath, + sessionStore, + }); + return finalize(dispatchResponse); + }, + ); }; if (!executionLockKey) { diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index 842d560c0..c5970267f 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -6,7 +6,7 @@ import { resolveRuntimeTransportHints, type ResolvedRuntimeTransport, } from '../utils/runtime-transport.ts'; -import { adbArgs } from '../platforms/android/adb.ts'; +import { runAndroidAdb } from '../platforms/android/adb.ts'; import { classifyAndroidAppTarget, formatAndroidInstalledPackageRequiredMessage, @@ -100,9 +100,9 @@ async function clearAndroidRuntimeHints(device: DeviceInfo, packageName: string) } async function readAndroidDevPrefs(device: DeviceInfo, packageName: string): Promise { - const result = await runCmd( - 'adb', - adbArgs(device, ['shell', 'run-as', packageName, 'cat', ANDROID_DEV_PREFS_PATH]), + const result = await runAndroidAdb( + device, + ['shell', 'run-as', packageName, 'cat', ANDROID_DEV_PREFS_PATH], { allowFailure: true }, ); if (result.exitCode !== 0) return DEFAULT_ANDROID_PREFS_XML; @@ -114,8 +114,8 @@ async function writeAndroidDevPrefs( packageName: string, xml: string, ): Promise { - const probeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'id']); - const probeResult = await runCmd('adb', probeArgs, { allowFailure: true }); + const probeArgs = ['shell', 'run-as', packageName, 'id']; + const probeResult = await runAndroidAdb(device, probeArgs, { allowFailure: true }); if (probeResult.exitCode !== 0) { const runAsDenied = isAndroidRunAsDeniedOutput(probeResult.stdout, probeResult.stderr); throw new AppError( @@ -136,15 +136,10 @@ async function writeAndroidDevPrefs( } try { - await runCmd( - 'adb', - adbArgs(device, ['shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs']), - ); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'run-as', packageName, 'tee', ANDROID_DEV_PREFS_PATH]), - { stdin: xml.trimEnd() }, - ); + await runAndroidAdb(device, ['shell', 'run-as', packageName, 'mkdir', '-p', 'shared_prefs']); + await runAndroidAdb(device, ['shell', 'run-as', packageName, 'tee', ANDROID_DEV_PREFS_PATH], { + stdin: xml.trimEnd(), + }); } catch (error) { const appErr = asAppError(error); if (appErr.code === 'TOOL_MISSING') throw appErr; diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index 8fd426a77..bf65200c4 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -9,7 +9,14 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => { }; }); -import { createDeviceAdbExecutor, withAndroidAdbProvider } from '../adb-executor.ts'; +import { + createAndroidPortReverseManager, + createDeviceAdbExecutor, + createLocalAndroidAdbProvider, + resolveAndroidAdbExecutor, + resolveAndroidAdbProvider, + withAndroidAdbProvider, +} from '../adb-executor.ts'; import { runCmd } from '../../../utils/exec.ts'; const mockRunCmd = vi.mocked(runCmd); @@ -47,6 +54,7 @@ test('createDeviceAdbExecutor remains a local adb executor inside provider scope providerCalls.push(args); return { stdout: 'provider', stderr: '', exitCode: 0 }; }, + { serial: 'emulator-5554' }, async () => await adb(['shell', 'echo', 'local']), ); @@ -56,3 +64,137 @@ test('createDeviceAdbExecutor remains a local adb executor inside provider scope ['adb', ['-s', 'emulator-5554', 'shell', 'echo', 'local'], undefined], ]); }); + +test('scoped provider only resolves for the matching device serial', async () => { + mockRunCmd.mockClear(); + const providerCalls: string[][] = []; + const otherDevice = { + platform: 'android', + id: 'other-device', + name: 'Other Android', + kind: 'device', + booted: true, + } as const; + + const result = await withAndroidAdbProvider( + async (args) => { + providerCalls.push(args); + return { stdout: 'provider', stderr: '', exitCode: 0 }; + }, + { serial: 'emulator-5554' }, + async () => { + const adb = resolveAndroidAdbExecutor(otherDevice); + const provider = resolveAndroidAdbProvider(otherDevice); + await provider.exec(['shell', 'echo', 'provider-fallback']); + return await adb(['shell', 'echo', 'executor-fallback']); + }, + ); + + assert.equal(result.stdout, 'ok'); + assert.deepEqual(providerCalls, []); + assert.deepEqual( + mockRunCmd.mock.calls.map((call) => call[1]), + [ + ['-s', 'other-device', 'shell', 'echo', 'provider-fallback'], + ['-s', 'other-device', 'shell', 'echo', 'executor-fallback'], + ], + ); +}); + +test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local adb', async () => { + mockRunCmd.mockClear(); + const provider = createLocalAndroidAdbProvider({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }); + + await provider.exec(['shell', 'echo', 'ok']); + await provider.reverse?.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }); + await provider.reverse?.removeAllOwned('session-a'); + + assert.deepEqual( + mockRunCmd.mock.calls.map((call) => call[1]), + [ + ['-s', 'emulator-5554', 'shell', 'echo', 'ok'], + ['-s', 'emulator-5554', 'reverse', 'tcp:8081', 'tcp:8081'], + ['-s', 'emulator-5554', 'reverse', '--remove', 'tcp:8081'], + ], + ); +}); + +test('createAndroidPortReverseManager makes duplicate setup idempotent and cleans owner mappings', async () => { + const calls: string[][] = []; + const manager = createAndroidPortReverseManager(async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }); + await manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }); + await manager.ensure({ local: 'tcp:8082', remote: 'tcp:8081', ownerId: 'session-a' }); + await manager.removeAllOwned('session-a'); + + assert.deepEqual(calls, [ + ['reverse', 'tcp:8081', 'tcp:8081'], + ['reverse', 'tcp:8082', 'tcp:8081'], + ['reverse', '--remove', 'tcp:8081'], + ['reverse', '--remove', 'tcp:8082'], + ]); +}); + +test('createAndroidPortReverseManager rejects mappings owned by another session', async () => { + const manager = createAndroidPortReverseManager(async () => ({ + stdout: '', + stderr: '', + exitCode: 0, + })); + + await manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }); + await assert.rejects( + () => manager.ensure({ local: 'tcp:8081', remote: 'tcp:8082', ownerId: 'session-b' }), + /already owned by session-a/, + ); +}); + +test('createAndroidPortReverseManager lists parsed reverse mappings with owners', async () => { + const manager = createAndroidPortReverseManager(async (args) => { + if (args.join(' ') === 'reverse --list') { + return { + stdout: [ + 'emulator-5554 tcp:8081 tcp:8081', + 'emulator-5554 localabstract:metro tcp:9090', + '', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await manager.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }); + const mappings = await manager.list?.(); + + assert.deepEqual(mappings, [ + { local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' }, + { local: 'localabstract:metro', remote: 'tcp:9090', ownerId: undefined }, + ]); +}); + +test('resolveAndroidAdbProvider does not infer reverse support for plain executors', () => { + const provider = resolveAndroidAdbProvider( + { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }, + async () => ({ stdout: '', stderr: '', exitCode: 0 }), + ); + + assert.equal(provider.reverse, undefined); +}); diff --git a/src/platforms/android/__tests__/adb-provider-scope.test.ts b/src/platforms/android/__tests__/adb-provider-scope.test.ts index 6725f95f4..18773bd60 100644 --- a/src/platforms/android/__tests__/adb-provider-scope.test.ts +++ b/src/platforms/android/__tests__/adb-provider-scope.test.ts @@ -1,10 +1,21 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import type { ChildProcess } from 'node:child_process'; import { test } from 'vitest'; import { runCmd } from '../../../utils/exec.ts'; -import { spawnAndroidAdbBySerial, withAndroidAdbProvider } from '../adb-executor.ts'; +import { resolveAndroidAdbProvider, withAndroidAdbProvider } from '../adb-executor.ts'; -test('withAndroidAdbProvider intercepts scoped adb commands with a device serial', async () => { +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, +} as const; + +test('withAndroidAdbProvider intercepts adb commands for the scoped serial', async () => { const calls: string[][] = []; const result = await withAndroidAdbProvider( @@ -16,6 +27,7 @@ test('withAndroidAdbProvider intercepts scoped adb commands with a device serial exitCode: 0, }; }, + { serial: device.id }, async () => await runCmd('adb', ['-s', 'emulator-5554', 'shell', 'echo', 'ok'], { allowFailure: true, @@ -26,7 +38,34 @@ test('withAndroidAdbProvider intercepts scoped adb commands with a device serial assert.deepEqual(calls, [['shell', 'echo', 'ok']]); }); -test('spawnAndroidAdbBySerial uses the scoped provider spawner', async () => { +test('withAndroidAdbProvider ignores adb commands for another serial', async () => { + const calls: string[][] = []; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-adb-provider-scope-')); + const adbPath = path.join(tmpDir, 'adb'); + fs.writeFileSync( + adbPath, + '#!/usr/bin/env node\nprocess.stdout.write(`local ${process.argv.slice(2).join(" ")}`);', + ); + fs.chmodSync(adbPath, 0o755); + + const result = await withAndroidAdbProvider( + async (args) => { + calls.push(args); + return { stdout: 'provider', stderr: '', exitCode: 0 }; + }, + { serial: device.id }, + async () => + await runCmd('adb', ['-s', 'other-device', 'shell', 'echo', 'local'], { + allowFailure: true, + env: { ...process.env, PATH: `${tmpDir}${path.delimiter}${process.env.PATH ?? ''}` }, + }), + ); + + assert.equal(result.stdout, 'local -s other-device shell echo local'); + assert.deepEqual(calls, []); +}); + +test('resolveAndroidAdbProvider uses the scoped provider spawner', async () => { const child = { pid: 123 } as ChildProcess; const calls: string[][] = []; @@ -38,7 +77,8 @@ test('spawnAndroidAdbBySerial uses the scoped provider spawner', async () => { return child; }, }, - async () => spawnAndroidAdbBySerial('emulator-5554', ['logcat', '-v', 'time']), + { serial: device.id }, + async () => resolveAndroidAdbProvider(device).spawn?.(['logcat', '-v', 'time']), ); assert.equal(result, child); diff --git a/src/platforms/android/__tests__/logcat.test.ts b/src/platforms/android/__tests__/logcat.test.ts new file mode 100644 index 000000000..4b489be32 --- /dev/null +++ b/src/platforms/android/__tests__/logcat.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '../../../utils/errors.ts'; +import { streamAndroidLogcatWithAdb } from '../logcat.ts'; + +test('streamAndroidLogcatWithAdb reports unsupported providers without spawn', () => { + assert.throws( + () => streamAndroidLogcatWithAdb({}), + (error) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + error.message === 'Android ADB provider does not support streams', + ); +}); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index ad8823bd2..9f211f115 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -25,6 +25,7 @@ import { type AndroidAdbExecutor, type AndroidSnapshotHelperManifest, } from '../snapshot-helper.ts'; +import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts'; const VALID_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=', @@ -263,8 +264,55 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a assert.equal(mockRunCmd.mock.calls.length, 0); }); +test('snapshotAndroid resolves helper adb through scoped provider', async () => { + const adbCalls: string[][] = []; + const provider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push(args); + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:13003', + stderr: '', + }; + } + if (args.includes('instrument')) { + return { + exitCode: 0, + stdout: helperOutput( + '', + ), + stderr: '', + }; + } + throw new Error(`unexpected scoped helper adb args: ${args.join(' ')}`); + }, + }; + + const result = await withAndroidAdbProvider(provider, { serial: device.id }, async () => + snapshotAndroid(device, { + helperArtifact: { + apkPath: '/tmp/helper.apk', + manifest: helperManifest, + }, + }), + ); + + assert.equal(result.nodes[0]?.label, 'provider-helper'); + assert.equal(result.androidSnapshot.backend, 'android-helper'); + assert.deepEqual( + adbCalls.map((args) => args[0]), + ['shell', 'shell'], + ); + assert.equal(mockRunCmd.mock.calls.length, 0); +}); + test('snapshotAndroid falls back to stock uiautomator when helper fails', async () => { + const adbCalls: string[][] = []; + const stockXml = + ''; const helperAdb: AndroidAdbExecutor = async (args) => { + adbCalls.push(args); if (args.includes('--show-versioncode')) { return { exitCode: 0, @@ -272,16 +320,11 @@ test('snapshotAndroid falls back to stock uiautomator when helper fails', async stderr: '', }; } - return { exitCode: 1, stdout: '', stderr: 'instrumentation failed' }; - }; - const stockXml = - ''; - mockRunCmd.mockImplementation(async (_cmd, args) => { if (args.includes('exec-out')) { return { exitCode: 0, stdout: stockXml, stderr: '' }; } - return { exitCode: 0, stdout: '', stderr: '' }; - }); + return { exitCode: 1, stdout: '', stderr: 'instrumentation failed' }; + }; const result = await snapshotAndroid(device, { helperAdb, @@ -297,6 +340,11 @@ test('snapshotAndroid falls back to stock uiautomator when helper fails', async result.androidSnapshot.fallbackReason ?? '', /failed before returning parseable output/, ); + assert.deepEqual( + adbCalls.map((args) => args[0]), + ['shell', 'shell', 'exec-out'], + ); + assert.equal(mockRunCmd.mock.calls.length, 0); }); test('snapshotAndroid re-probes helper install after helper capture failure', async () => { @@ -322,16 +370,13 @@ test('snapshotAndroid re-probes helper install after helper capture failure', as stderr: '', }; } + if (args.includes('exec-out')) { + return { exitCode: 0, stdout: stockXml, stderr: '' }; + } throw new Error(`unexpected helper adb args: ${args.join(' ')}`); }; const stockXml = ''; - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.includes('exec-out')) { - return { exitCode: 0, stdout: stockXml, stderr: '' }; - } - return { exitCode: 0, stdout: '', stderr: '' }; - }); const helperOptions = { helperAdb, helperArtifact: { diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index e34e126f8..caca6774b 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -1,5 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { spawn, type SpawnOptions } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; import type { DeviceInfo } from '../../utils/device.ts'; import { runCmd, @@ -9,6 +10,7 @@ import { type ExecOptions, type ExecResult, } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; export type AndroidAdbExecutorOptions = Pick< ExecOptions, @@ -20,6 +22,24 @@ export type AndroidAdbExecutorResult = Pick< 'exitCode' | 'stdout' | 'stderr' | 'stdoutBuffer' >; +export type AndroidAdbProcess = { + pid?: number; + stdin: Writable | null; + stdout: Readable | null; + stderr: Readable | null; + killed: boolean; + kill(signal?: NodeJS.Signals | number): boolean; + once( + event: 'exit' | 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; + on( + event: 'exit' | 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): unknown; +}; + /** * Runs device-scoped adb arguments after the device serial has already been selected. * Implementations must be safe to call concurrently for one request. @@ -29,14 +49,44 @@ export type AndroidAdbExecutor = ( options?: AndroidAdbExecutorOptions, ) => Promise; -export type AndroidAdbSpawner = (args: string[], options?: SpawnOptions) => ChildProcess; +export type AndroidAdbSpawner = (args: string[], options?: SpawnOptions) => AndroidAdbProcess; + +export type AndroidPortReverseEndpoint = `tcp:${number}` | `localabstract:${string}`; + +export type AndroidPortReverseMapping = { + local: AndroidPortReverseEndpoint; + remote: AndroidPortReverseEndpoint; + ownerId?: string; +}; + +export type AndroidPortReverseOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + +export type AndroidPortReverseProvider = { + ensure(mapping: AndroidPortReverseMapping, options?: AndroidPortReverseOptions): Promise; + remove(local: AndroidPortReverseEndpoint, options?: AndroidPortReverseOptions): Promise; + removeAllOwned(ownerId: string, options?: AndroidPortReverseOptions): Promise; + list?(options?: AndroidPortReverseOptions): Promise; +}; export type AndroidAdbProvider = { exec: AndroidAdbExecutor; spawn?: AndroidAdbSpawner; + reverse?: AndroidPortReverseProvider; +}; + +export type AndroidAdbProviderScopeOptions = { + serial: string; }; -const androidAdbProviderScope = new AsyncLocalStorage(); +type AndroidAdbProviderScope = { + provider: AndroidAdbProvider; + serial: string; +}; + +const androidAdbProviderScope = new AsyncLocalStorage(); export function createDeviceAdbExecutor(device: DeviceInfo): AndroidAdbExecutor { return createSerialAdbExecutor(device.id); @@ -55,53 +105,204 @@ function createSerialAdbSpawner(serial: string): AndroidAdbSpawner { return (args, options) => spawn('adb', ['-s', serial, ...args], options ?? {}); } +export function createLocalAndroidAdbProvider(device: DeviceInfo): AndroidAdbProvider { + const exec = createDeviceAdbExecutor(device); + return { + exec, + spawn: createSerialAdbSpawner(device.id), + reverse: createExecAndroidPortReverseProvider(exec), + }; +} + export function resolveAndroidAdbExecutor( device: DeviceInfo, executor?: AndroidAdbExecutor, ): AndroidAdbExecutor { - return executor ?? androidAdbProviderScope.getStore()?.exec ?? createDeviceAdbExecutor(device); + const scoped = androidAdbProviderScope.getStore(); + if (executor) return executor; + if (scoped?.serial === device.id) return scoped.provider.exec; + return createDeviceAdbExecutor(device); } -export function spawnAndroidAdbBySerial( - serial: string, - args: string[], - options?: SpawnOptions, -): ChildProcess { - return resolveAndroidSerialAdbSpawner(serial)(args, options); +export function resolveAndroidAdbProvider( + device: DeviceInfo, + provider?: AndroidAdbProvider | AndroidAdbExecutor, +): AndroidAdbProvider { + if (provider) return normalizeAndroidAdbProvider(provider); + const scoped = androidAdbProviderScope.getStore(); + return scoped?.serial === device.id + ? normalizeAndroidAdbProvider(scoped.provider) + : createLocalAndroidAdbProvider(device); +} + +export function createAndroidPortReverseManager( + provider: AndroidAdbProvider | AndroidAdbExecutor, +): AndroidPortReverseProvider { + const normalized = normalizeAndroidAdbProvider(provider); + const reverse = normalized.reverse ?? createExecAndroidPortReverseProvider(normalized.exec); + const active = new Map(); + return { + async ensure(mapping, options) { + const current = active.get(mapping.local); + if (current && current.ownerId !== mapping.ownerId) { + throw new AppError( + 'COMMAND_FAILED', + `Android port reverse ${mapping.local} is already owned by ${current.ownerId ?? 'another session'}`, + { current, requested: mapping }, + ); + } + if (current?.remote === mapping.remote) { + return; + } + await reverse.ensure(mapping, options); + active.set(mapping.local, { ...mapping }); + }, + async remove(local, options) { + if (!active.has(local)) { + await reverse.remove(local, options); + return; + } + await reverse.remove(local, options); + active.delete(local); + }, + async removeAllOwned(ownerId, options) { + const locals = [...active.values()] + .filter((mapping) => mapping.ownerId === ownerId) + .map((mapping) => mapping.local); + if (locals.length === 0) { + await reverse.removeAllOwned(ownerId, options); + return; + } + for (const local of locals) { + await reverse.remove(local, options); + active.delete(local); + } + }, + async list(options) { + return reverse.list ? await reverse.list(options) : [...active.values()]; + }, + }; +} + +function normalizeAndroidAdbProvider( + provider: AndroidAdbProvider | AndroidAdbExecutor, +): AndroidAdbProvider { + if (typeof provider === 'function') { + return { exec: provider }; + } + return provider; } export async function withAndroidAdbProvider( provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, + options: AndroidAdbProviderScopeOptions, fn: () => Promise, ): Promise { if (!provider) return await fn(); const normalized = typeof provider === 'function' ? { exec: provider } : provider; - const override = createAndroidCommandExecutorOverride(normalized); + const scope = { provider: normalized, serial: options.serial }; + const override = createAndroidCommandExecutorOverride(scope); return await androidAdbProviderScope.run( - normalized, + scope, async () => await withCommandExecutorOverride(override, fn), ); } function createAndroidCommandExecutorOverride( - provider: AndroidAdbProvider, + scope: AndroidAdbProviderScope, ): CommandExecutorOverride { return (cmd, args, options) => { if (cmd !== 'adb') return undefined; - const providerArgs = stripAdbSerialArgs(args); + const providerArgs = stripAdbSerialArgs(args, scope.serial); if (!providerArgs) return undefined; - return withoutCommandExecutorOverride(async () => await provider.exec(providerArgs, options)); + return withoutCommandExecutorOverride( + async () => await scope.provider.exec(providerArgs, options), + ); }; } -function stripAdbSerialArgs(args: string[]): string[] | undefined { - // The provider scope only owns normalized device-scoped adb calls produced by - // this repo's adbArgs helpers: adb -s . Global commands - // such as adb devices/version and host-preconfigured invocations stay local. +function stripAdbSerialArgs(args: string[], expectedSerial: string): string[] | undefined { + // The provider scope only owns normalized device-scoped adb calls: + // adb -s . Global commands + // such as adb devices/version, calls for another serial, and host-preconfigured + // invocations stay local. if (args[0] !== '-s' || !args[1]) return undefined; + if (args[1] !== expectedSerial) return undefined; return args.slice(2); } -function resolveAndroidSerialAdbSpawner(serial: string): AndroidAdbSpawner { - return androidAdbProviderScope.getStore()?.spawn ?? createSerialAdbSpawner(serial); +function createExecAndroidPortReverseProvider(adb: AndroidAdbExecutor): AndroidPortReverseProvider { + const owned = new Map>(); + return { + async ensure(mapping, options) { + await adb(['reverse', mapping.local, mapping.remote], { + allowFailure: false, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + }); + if (mapping.ownerId) { + const ownedLocals = owned.get(mapping.ownerId) ?? new Set(); + ownedLocals.add(mapping.local); + owned.set(mapping.ownerId, ownedLocals); + } + }, + async remove(local, options) { + const result = await adb(['reverse', '--remove', local], { + allowFailure: true, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + }); + if (result.exitCode !== 0 && !isMissingReverseMapping(result.stdout, result.stderr)) { + throw new Error(`Failed to remove Android port reverse ${local}: ${result.stderr}`); + } + for (const locals of owned.values()) { + locals.delete(local); + } + }, + async removeAllOwned(ownerId, options) { + const locals = [...(owned.get(ownerId) ?? [])]; + for (const local of locals) { + await this.remove(local, options); + } + owned.delete(ownerId); + }, + async list(options) { + const result = await adb(['reverse', '--list'], { + allowFailure: true, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + }); + if (result.exitCode !== 0) return []; + return parseAndroidReverseList(result.stdout, owned); + }, + }; +} + +function parseAndroidReverseList( + stdout: string, + owned: ReadonlyMap>, +): AndroidPortReverseMapping[] { + const ownerByLocal = new Map(); + for (const [ownerId, locals] of owned) { + for (const local of locals) { + ownerByLocal.set(local, ownerId); + } + } + return stdout + .split('\n') + .map((line) => line.trim().split(/\s+/)) + .filter((parts): parts is [string, string, string] => parts.length >= 3) + .map(([, local, remote]) => { + const localEndpoint = local as AndroidPortReverseEndpoint; + return { + local: localEndpoint, + remote: remote as AndroidPortReverseEndpoint, + ownerId: ownerByLocal.get(localEndpoint), + }; + }); +} + +function isMissingReverseMapping(stdout: string, stderr: string): boolean { + const text = `${stdout}\n${stderr}`.toLowerCase(); + return text.includes('listener') && text.includes('not found'); } diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index fc72ba97c..37cd2f656 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -2,11 +2,30 @@ import { whichCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { ensureAndroidSdkPathConfigured } from './sdk.ts'; +import { + resolveAndroidAdbExecutor, + type AndroidAdbExecutorOptions, + type AndroidAdbExecutorResult, +} from './adb-executor.ts'; export { sleep } from '../../utils/timeouts.ts'; -export function adbArgs(device: DeviceInfo, args: string[]): string[] { - return ['-s', device.id, ...args]; +export async function runAndroidAdb( + device: DeviceInfo, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + return await resolveAndroidAdbExecutor(device)(args, options); +} + +export function androidDeviceForSerial(deviceId: string): DeviceInfo { + return { + platform: 'android', + id: deviceId, + name: deviceId, + kind: deviceId.startsWith('emulator-') ? 'emulator' : 'device', + booted: true, + }; } export async function ensureAdb(): Promise { diff --git a/src/platforms/android/app-control.ts b/src/platforms/android/app-control.ts new file mode 100644 index 000000000..10c0a5166 --- /dev/null +++ b/src/platforms/android/app-control.ts @@ -0,0 +1,124 @@ +import { AppError } from '../../utils/errors.ts'; +import type { AndroidAdbExecutor } from './adb-executor.ts'; +import { isAmStartError, parseAndroidLaunchComponent } from './app-lifecycle.ts'; + +const ANDROID_LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; +const ANDROID_DEFAULT_CATEGORY = 'android.intent.category.DEFAULT'; + +export type AndroidOpenAppWithAdbOptions = { + activity?: string; + category?: string; +}; + +export async function forceStopAndroidAppWithAdb( + adb: AndroidAdbExecutor, + packageName: string, +): Promise { + await adb(['shell', 'am', 'force-stop', packageName]); +} + +export async function resolveAndroidLaunchComponentWithAdb( + adb: AndroidAdbExecutor, + packageName: string, + categories: string[] = [ANDROID_LAUNCHER_CATEGORY], +): Promise { + for (const category of categories) { + const result = await adb( + [ + 'shell', + 'cmd', + 'package', + 'resolve-activity', + '--brief', + '-a', + 'android.intent.action.MAIN', + '-c', + category, + packageName, + ], + { allowFailure: true }, + ); + if (result.exitCode !== 0) continue; + const component = parseAndroidLaunchComponent(result.stdout); + if (component) return component; + } + return null; +} + +export async function openAndroidAppWithAdb( + adb: AndroidAdbExecutor, + packageName: string, + options: AndroidOpenAppWithAdbOptions = {}, +): Promise { + const category = options.category ?? ANDROID_LAUNCHER_CATEGORY; + if (options.activity) { + await adb([ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.MAIN', + '-c', + ANDROID_DEFAULT_CATEGORY, + '-c', + category, + '-n', + normalizeAndroidComponent(packageName, options.activity), + ]); + return; + } + + const primary = await adb( + [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.MAIN', + '-c', + ANDROID_DEFAULT_CATEGORY, + '-c', + category, + '-p', + packageName, + ], + { allowFailure: true }, + ); + if (primary.exitCode === 0 && !isAmStartError(primary.stdout, primary.stderr)) { + return; + } + + const component = await resolveAndroidLaunchComponentWithAdb(adb, packageName, [category]); + if (!component) { + throw new AppError( + 'COMMAND_FAILED', + `Failed to resolve Android launch component for ${packageName}`, + { + stdout: primary.stdout, + stderr: primary.stderr, + exitCode: primary.exitCode, + }, + ); + } + await adb([ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.MAIN', + '-c', + ANDROID_DEFAULT_CATEGORY, + '-c', + category, + '-n', + component, + ]); +} + +function normalizeAndroidComponent(packageName: string, activity: string): string { + if (activity.includes('/')) return activity; + return `${packageName}/${activity.startsWith('.') ? activity : `.${activity}`}`; +} diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 0d8746a71..e47c3f629 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -7,7 +7,7 @@ import type { DeviceInfo } from '../../utils/device.ts'; import { isDeepLinkTarget } from '../../core/open-target.ts'; import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { waitForAndroidBoot } from './devices.ts'; -import { adbArgs } from './adb.ts'; +import { runAndroidAdb } from './adb.ts'; import { classifyAndroidAppTarget } from './open-target.ts'; import { prepareAndroidInstallArtifact } from './install-artifact.ts'; import { @@ -57,7 +57,7 @@ export async function resolveAndroidApp( const cached = androidAppResolutionCache.get(cacheScope, trimmed); if (cached) return cached; - const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages'])); + const result = await runAndroidAdb(device, ['shell', 'pm', 'list', 'packages']); const packages = result.stdout .split('\n') .map((line: string) => line.replace('package:', '').trim()) @@ -104,9 +104,9 @@ async function listAndroidLaunchablePackages(device: DeviceInfo): Promise { - const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages', '-3'])); + const result = await runAndroidAdb(device, ['shell', 'pm', 'list', 'packages', '-3']); return parseAndroidUserInstalledPackages(result.stdout); } @@ -207,7 +207,7 @@ async function readAndroidFocus( commands: string[][], ): Promise { for (const args of commands) { - const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true }); + const result = await runAndroidAdb(device, args, { allowFailure: true }); const text = result.stdout ?? ''; const parsed = parseAndroidForegroundApp(text); if (parsed) return parsed; @@ -231,19 +231,16 @@ export async function openAndroidApp( 'Activity override is not supported when opening a deep link URL', ); } - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'am', - 'start', - '-W', - '-a', - 'android.intent.action.VIEW', - '-d', - deepLinkTarget, - ]), - ); + await runAndroidAdb(device, [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + deepLinkTarget, + ]); return; } const resolved = await resolveAndroidApp(device, app); @@ -255,7 +252,7 @@ export async function openAndroidApp( 'Activity override requires a package name, not an intent', ); } - await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-W', '-a', resolved.value])); + await runAndroidAdb(device, ['shell', 'am', 'start', '-W', '-a', resolved.value]); return; } if (activity) { @@ -263,32 +260,29 @@ export async function openAndroidApp( ? activity : `${resolved.value}/${activity.startsWith('.') ? activity : `.${activity}`}`; try { - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'am', - 'start', - '-W', - '-a', - 'android.intent.action.MAIN', - '-c', - ANDROID_DEFAULT_CATEGORY, - '-c', - launchCategory, - '-n', - component, - ]), - ); + await runAndroidAdb(device, [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.MAIN', + '-c', + ANDROID_DEFAULT_CATEGORY, + '-c', + launchCategory, + '-n', + component, + ]); } catch (error) { await maybeRethrowAndroidMissingPackageError(device, resolved.value, error); throw error; } return; } - const primaryResult = await runCmd( - 'adb', - adbArgs(device, [ + const primaryResult = await runAndroidAdb( + device, + [ 'shell', 'am', 'start', @@ -301,7 +295,7 @@ export async function openAndroidApp( launchCategory, '-p', resolved.value, - ]), + ], { allowFailure: true }, ); if (primaryResult.exitCode === 0 && !isAmStartError(primaryResult.stdout, primaryResult.stderr)) { @@ -317,23 +311,20 @@ export async function openAndroidApp( stderr: primaryResult.stderr, }); } - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'am', - 'start', - '-W', - '-a', - 'android.intent.action.MAIN', - '-c', - ANDROID_DEFAULT_CATEGORY, - '-c', - launchCategory, - '-n', - component, - ]), - ); + await runAndroidAdb(device, [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.MAIN', + '-c', + ANDROID_DEFAULT_CATEGORY, + '-c', + launchCategory, + '-n', + component, + ]); } function buildAndroidPackageNotInstalledError(packageName: string): AppError { @@ -347,7 +338,7 @@ async function isAndroidPackageInstalled( device: DeviceInfo, packageName: string, ): Promise { - const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'path', packageName]), { + const result = await runAndroidAdb(device, ['shell', 'pm', 'path', packageName], { allowFailure: true, }); const output = `${result.stdout}\n${result.stderr}`; @@ -394,9 +385,9 @@ async function resolveAndroidLaunchComponent( new Set(resolveAndroidLaunchCategories(device, { includeFallbackWhenUnknown: true })), ); for (const category of categories) { - const result = await runCmd( - 'adb', - adbArgs(device, [ + const result = await runAndroidAdb( + device, + [ 'shell', 'cmd', 'package', @@ -407,7 +398,7 @@ async function resolveAndroidLaunchComponent( '-c', category, packageName, - ]), + ], { allowFailure: true }, ); if (result.exitCode !== 0) { @@ -446,14 +437,14 @@ export async function openAndroidDevice(device: DeviceInfo): Promise { export async function closeAndroidApp(device: DeviceInfo, app: string): Promise { const trimmed = app.trim(); if (trimmed.toLowerCase() === 'settings') { - await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', 'com.android.settings'])); + await runAndroidAdb(device, ['shell', 'am', 'force-stop', 'com.android.settings']); return; } const resolved = await resolveAndroidApp(device, app); if (resolved.type === 'intent') { throw new AppError('INVALID_ARGS', 'Close requires a package name, not an intent'); } - await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value])); + await runAndroidAdb(device, ['shell', 'am', 'force-stop', resolved.value]); } async function uninstallAndroidApp(device: DeviceInfo, app: string): Promise<{ package: string }> { @@ -461,7 +452,7 @@ async function uninstallAndroidApp(device: DeviceInfo, app: string): Promise<{ p if (resolved.type === 'intent') { throw new AppError('INVALID_ARGS', 'App uninstall requires a package name, not an intent'); } - const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { + const result = await runAndroidAdb(device, ['uninstall', resolved.value], { allowFailure: true, }); if (result.exitCode !== 0) { @@ -549,11 +540,11 @@ async function installAndroidAppFiles(device: DeviceInfo, appPath: string): Prom await installAndroidAppBundle(device, appPath); return; } - await runCmd('adb', adbArgs(device, ['install', '-r', appPath])); + await runAndroidAdb(device, ['install', '-r', appPath]); } async function listInstalledAndroidPackages(device: DeviceInfo): Promise> { - const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages'])); + const result = await runAndroidAdb(device, ['shell', 'pm', 'list', 'packages']); return new Set( result.stdout .split('\n') diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index cfefd2ded..fbce74b52 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -1,7 +1,7 @@ -import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { adbArgs, isClipboardShellUnsupported, sleep } from './adb.ts'; +import { isClipboardShellUnsupported, sleep } from './adb.ts'; +import { resolveAndroidAdbExecutor, type AndroidAdbExecutor } from './adb-executor.ts'; const ANDROID_INPUT_TYPE_CLASS_MASK = 0x0000000f; const ANDROID_INPUT_TYPE_CLASS_TEXT = 0x00000001; @@ -34,7 +34,13 @@ export type AndroidKeyboardState = { }; export async function getAndroidKeyboardState(device: DeviceInfo): Promise { - const result = await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'input_method']), { + return await getAndroidKeyboardStatusWithAdb(resolveAndroidAdbExecutor(device)); +} + +export async function getAndroidKeyboardStatusWithAdb( + adb: AndroidAdbExecutor, +): Promise { + const result = await adb(['shell', 'dumpsys', 'input_method'], { allowFailure: true, }); if (result.exitCode !== 0) { @@ -55,15 +61,26 @@ export async function dismissAndroidKeyboard(device: DeviceInfo): Promise<{ inputType?: string; type?: AndroidKeyboardType; }> { - const initialState = await getAndroidKeyboardState(device); + return await dismissAndroidKeyboardWithAdb(resolveAndroidAdbExecutor(device)); +} + +export async function dismissAndroidKeyboardWithAdb(adb: AndroidAdbExecutor): Promise<{ + attempts: number; + wasVisible: boolean; + dismissed: boolean; + visible: boolean; + inputType?: string; + type?: AndroidKeyboardType; +}> { + const initialState = await getAndroidKeyboardStatusWithAdb(adb); let state = initialState; let attempts = 0; while (state.visible && attempts < ANDROID_KEYBOARD_DISMISS_MAX_ATTEMPTS) { - await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', ANDROID_KEYCODE_ESCAPE])); + await adb(['shell', 'input', 'keyevent', ANDROID_KEYCODE_ESCAPE]); attempts += 1; await sleep(ANDROID_KEYBOARD_DISMISS_RETRY_DELAY_MS); - state = await getAndroidKeyboardState(device); + state = await getAndroidKeyboardStatusWithAdb(adb); } if (initialState.visible && state.visible) { @@ -156,8 +173,12 @@ function classifyAndroidKeyboardType(inputType: string): AndroidKeyboardType { } export async function readAndroidClipboardText(device: DeviceInfo): Promise { + return await readAndroidClipboardWithAdb(resolveAndroidAdbExecutor(device)); +} + +export async function readAndroidClipboardWithAdb(adb: AndroidAdbExecutor): Promise { const stdout = await runAndroidClipboardShellCommand( - device, + adb, ['shell', 'cmd', 'clipboard', 'get', 'text'], 'read', ); @@ -165,19 +186,26 @@ export async function readAndroidClipboardText(device: DeviceInfo): Promise { + await writeAndroidClipboardWithAdb(resolveAndroidAdbExecutor(device), text); +} + +export async function writeAndroidClipboardWithAdb( + adb: AndroidAdbExecutor, + text: string, +): Promise { await runAndroidClipboardShellCommand( - device, + adb, ['shell', 'cmd', 'clipboard', 'set', 'text', text], 'write', ); } async function runAndroidClipboardShellCommand( - device: DeviceInfo, + adb: AndroidAdbExecutor, args: string[], operation: 'read' | 'write', ): Promise { - const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true }); + const result = await adb(args, { allowFailure: true }); if (isClipboardShellUnsupported(result.stdout, result.stderr)) { throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/src/platforms/android/input-actions.ts b/src/platforms/android/input-actions.ts index 3c949faec..8d5cc89ba 100644 --- a/src/platforms/android/input-actions.ts +++ b/src/platforms/android/input-actions.ts @@ -1,14 +1,13 @@ -import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; import { buildScrollGesturePlan, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { parseBounds, readNodeAttributes } from './ui-hierarchy.ts'; import { dumpUiHierarchy } from './snapshot.ts'; -import { adbArgs, isClipboardShellUnsupported, sleep } from './adb.ts'; +import { isClipboardShellUnsupported, runAndroidAdb, sleep } from './adb.ts'; export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise { - await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)])); + await runAndroidAdb(device, ['shell', 'input', 'tap', String(x), String(y)]); } export async function swipeAndroid( @@ -19,27 +18,24 @@ export async function swipeAndroid( y2: number, durationMs = 250, ): Promise { - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'input', - 'swipe', - String(x1), - String(y1), - String(x2), - String(y2), - String(durationMs), - ]), - ); + await runAndroidAdb(device, [ + 'shell', + 'input', + 'swipe', + String(x1), + String(y1), + String(x2), + String(y2), + String(durationMs), + ]); } export async function backAndroid(device: DeviceInfo): Promise { - await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '4'])); + await runAndroidAdb(device, ['shell', 'input', 'keyevent', '4']); } export async function homeAndroid(device: DeviceInfo): Promise { - await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '3'])); + await runAndroidAdb(device, ['shell', 'input', 'keyevent', '3']); } export async function rotateAndroid( @@ -47,18 +43,26 @@ export async function rotateAndroid( orientation: DeviceRotation, ): Promise { const userRotation = resolveAndroidUserRotation(orientation); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0']), - ); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'settings', 'put', 'system', 'user_rotation', userRotation]), - ); + await runAndroidAdb(device, [ + 'shell', + 'settings', + 'put', + 'system', + 'accelerometer_rotation', + '0', + ]); + await runAndroidAdb(device, [ + 'shell', + 'settings', + 'put', + 'system', + 'user_rotation', + userRotation, + ]); } export async function appSwitcherAndroid(device: DeviceInfo): Promise { - await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '187'])); + await runAndroidAdb(device, ['shell', 'input', 'keyevent', '187']); } export async function longPressAndroid( @@ -67,19 +71,16 @@ export async function longPressAndroid( y: number, durationMs = 800, ): Promise { - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'input', - 'swipe', - String(x), - String(y), - String(x), - String(y), - String(durationMs), - ]), - ); + await runAndroidAdb(device, [ + 'shell', + 'input', + 'swipe', + String(x), + String(y), + String(x), + String(y), + String(durationMs), + ]); } export async function typeAndroid(device: DeviceInfo, text: string, delayMs = 0): Promise { @@ -98,7 +99,7 @@ async function typeAndroidImmediate(device: DeviceInfo, text: string): Promise { - const result = await runCmd('adb', adbArgs(device, ['shell', 'wm', 'size'])); + const result = await runAndroidAdb(device, ['shell', 'wm', 'size']); const match = result.stdout.match(/Physical size:\s*(\d+)x(\d+)/); if (!match) throw new AppError('COMMAND_FAILED', 'Unable to read screen size'); return { width: Number(match[1]), height: Number(match[2]) }; @@ -307,22 +305,20 @@ async function typeAndroidViaClipboard( device: DeviceInfo, text: string, ): Promise<'ok' | 'unsupported' | 'failed'> { - const setClipboard = await runCmd( - 'adb', - adbArgs(device, ['shell', 'cmd', 'clipboard', 'set', 'text', text]), + const setClipboard = await runAndroidAdb( + device, + ['shell', 'cmd', 'clipboard', 'set', 'text', text], { allowFailure: true }, ); if (setClipboard.exitCode !== 0) return 'failed'; if (isClipboardShellUnsupported(setClipboard.stdout, setClipboard.stderr)) return 'unsupported'; - const pasteByName = await runCmd( - 'adb', - adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_PASTE']), - { allowFailure: true }, - ); + const pasteByName = await runAndroidAdb(device, ['shell', 'input', 'keyevent', 'KEYCODE_PASTE'], { + allowFailure: true, + }); if (pasteByName.exitCode === 0) return 'ok'; - const pasteByCode = await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', '279']), { + const pasteByCode = await runAndroidAdb(device, ['shell', 'input', 'keyevent', '279'], { allowFailure: true, }); return pasteByCode.exitCode === 0 ? 'ok' : 'failed'; @@ -341,15 +337,15 @@ function isAndroidInputTextUnsupported(error: unknown): boolean { async function clearFocusedText(device: DeviceInfo, count: number): Promise { const deletes = Math.max(0, count); - await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']), { + await runAndroidAdb(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END'], { allowFailure: true, }); const batchSize = 24; for (let i = 0; i < deletes; i += batchSize) { const size = Math.min(batchSize, deletes - i); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')]), + await runAndroidAdb( + device, + ['shell', 'input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')], { allowFailure: true, }, diff --git a/src/platforms/android/logcat.ts b/src/platforms/android/logcat.ts new file mode 100644 index 000000000..bf36fac5f --- /dev/null +++ b/src/platforms/android/logcat.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import { AppError } from '../../utils/errors.ts'; +import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from './adb-executor.ts'; + +export type AndroidLogcatCaptureOptions = { + lines?: number; + timeoutMs?: number; + signal?: AbortSignal; +}; + +export type AndroidLogcatStreamOptions = { + pid?: string; + signal?: AbortSignal; + output?: fs.WriteStream; +}; + +export async function captureAndroidLogcatWithAdb( + adb: AndroidAdbExecutor, + options: AndroidLogcatCaptureOptions = {}, +): Promise { + const args = ['logcat', '-d', '-v', 'time']; + if (options.lines !== undefined) { + args.push('-t', String(Math.max(1, Math.floor(options.lines)))); + } + const result = await adb(args, { + allowFailure: true, + timeoutMs: options.timeoutMs, + signal: options.signal, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to capture Android logcat', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return result.stdout; +} + +export function streamAndroidLogcatWithAdb( + provider: Pick, + options: AndroidLogcatStreamOptions = {}, +): AndroidAdbProcess { + if (!provider.spawn) { + throw new AppError('UNSUPPORTED_OPERATION', 'Android ADB provider does not support streams', { + capability: 'adb.spawn', + }); + } + const args = ['logcat', '-v', 'time']; + if (options.pid) { + args.push('--pid', options.pid); + } + const child = provider.spawn(args, { stdio: ['ignore', 'pipe', 'pipe'], signal: options.signal }); + if (options.output && child.stdout) { + child.stdout.pipe(options.output, { end: false }); + } + return child; +} diff --git a/src/platforms/android/notifications.ts b/src/platforms/android/notifications.ts index 2c420b82c..f6cd141d3 100644 --- a/src/platforms/android/notifications.ts +++ b/src/platforms/android/notifications.ts @@ -1,7 +1,6 @@ -import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { adbArgs } from './adb.ts'; +import { runAndroidAdb } from './adb.ts'; type AndroidBroadcastPayload = { action?: string; @@ -37,7 +36,7 @@ export async function pushAndroidNotification( appendBroadcastExtra(args, key, rawValue); extrasCount += 1; } - await runCmd('adb', adbArgs(device, args)); + await runAndroidAdb(device, args); return { action, extrasCount }; } diff --git a/src/platforms/android/screenshot.ts b/src/platforms/android/screenshot.ts index 8c114508b..298da7cb0 100644 --- a/src/platforms/android/screenshot.ts +++ b/src/platforms/android/screenshot.ts @@ -1,8 +1,7 @@ import { promises as fs } from 'node:fs'; -import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { adbArgs, sleep } from './adb.ts'; +import { runAndroidAdb, sleep } from './adb.ts'; // PNG file signature: 0x89 P N G \r \n 0x1A \n const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -24,8 +23,7 @@ export async function screenshotAndroid(device: DeviceInfo, outPath: string): Pr * for consistent screenshots. */ async function enableAndroidDemoMode(device: DeviceInfo): Promise { - const shell = (cmd: string) => - runCmd('adb', adbArgs(device, ['shell', cmd]), { allowFailure: true }); + const shell = (cmd: string) => runAndroidAdb(device, ['shell', cmd], { allowFailure: true }); await shell('settings put global sysui_demo_allowed 1'); @@ -38,9 +36,9 @@ async function enableAndroidDemoMode(device: DeviceInfo): Promise { /** Disable demo mode and restore the live status bar. */ async function disableAndroidDemoMode(device: DeviceInfo): Promise { - await runCmd( - 'adb', - adbArgs(device, ['shell', 'am broadcast -a com.android.systemui.demo -e command exit']), + await runAndroidAdb( + device, + ['shell', 'am broadcast -a com.android.systemui.demo -e command exit'], { allowFailure: true, }, @@ -48,7 +46,7 @@ async function disableAndroidDemoMode(device: DeviceInfo): Promise { } async function captureAndroidScreenshot(device: DeviceInfo, outPath: string): Promise { - const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), { + const result = await runAndroidAdb(device, ['exec-out', 'screencap', '-p'], { binaryStdout: true, }); if (!result.stdoutBuffer) { diff --git a/src/platforms/android/settings.ts b/src/platforms/android/settings.ts index 07be204e6..7028795fe 100644 --- a/src/platforms/android/settings.ts +++ b/src/platforms/android/settings.ts @@ -1,4 +1,3 @@ -import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { @@ -7,7 +6,7 @@ import { type PermissionSettingOptions, } from '../permission-utils.ts'; import { parseAppearanceAction } from '../appearance.ts'; -import { adbArgs } from './adb.ts'; +import { runAndroidAdb } from './adb.ts'; const ANDROID_ANIMATION_SCALE_SETTINGS = [ 'window_animation_scale', @@ -26,58 +25,49 @@ export async function setAndroidSetting( switch (normalized) { case 'wifi': { const enabled = parseSettingState(state); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'svc', 'wifi', enabled ? 'enable' : 'disable']), - ); + await runAndroidAdb(device, ['shell', 'svc', 'wifi', enabled ? 'enable' : 'disable']); return; } case 'airplane': { const enabled = parseSettingState(state); const flag = enabled ? '1' : '0'; const bool = enabled ? 'true' : 'false'; - await runCmd( - 'adb', - adbArgs(device, ['shell', 'settings', 'put', 'global', 'airplane_mode_on', flag]), - ); - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'am', - 'broadcast', - '-a', - 'android.intent.action.AIRPLANE_MODE', - '--ez', - 'state', - bool, - ]), - ); + await runAndroidAdb(device, ['shell', 'settings', 'put', 'global', 'airplane_mode_on', flag]); + await runAndroidAdb(device, [ + 'shell', + 'am', + 'broadcast', + '-a', + 'android.intent.action.AIRPLANE_MODE', + '--ez', + 'state', + bool, + ]); return; } case 'location': { const enabled = parseSettingState(state); const mode = enabled ? '3' : '0'; - await runCmd( - 'adb', - adbArgs(device, ['shell', 'settings', 'put', 'secure', 'location_mode', mode]), - ); + await runAndroidAdb(device, ['shell', 'settings', 'put', 'secure', 'location_mode', mode]); return; } case 'animations': { const enabled = parseSettingState(state); const scale = enabled ? '1' : '0'; for (const key of ANDROID_ANIMATION_SCALE_SETTINGS) { - await runCmd('adb', adbArgs(device, ['shell', 'settings', 'put', 'global', key, scale])); + await runAndroidAdb(device, ['shell', 'settings', 'put', 'global', key, scale]); } return { scale, keys: [...ANDROID_ANIMATION_SCALE_SETTINGS] }; } case 'appearance': { const target = await resolveAndroidAppearanceTarget(device, state); - await runCmd( - 'adb', - adbArgs(device, ['shell', 'cmd', 'uimode', 'night', target === 'dark' ? 'yes' : 'no']), - ); + await runAndroidAdb(device, [ + 'shell', + 'cmd', + 'uimode', + 'night', + target === 'dark' ? 'yes' : 'no', + ]); return; } case 'fingerprint': { @@ -103,7 +93,7 @@ export async function setAndroidSetting( await setAndroidPhotoPermission(device, appPackage, pmAction); return; } - await runCmd('adb', adbArgs(device, ['shell', 'pm', pmAction, appPackage, target.value])); + await runAndroidAdb(device, ['shell', 'pm', pmAction, appPackage, target.value]); return; } default: @@ -128,7 +118,7 @@ async function runAndroidFingerprintCommand( const failures: Array<{ args: string[]; stdout: string; stderr: string; exitCode: number }> = []; for (const args of attempts) { - const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true }); + const result = await runAndroidAdb(device, args, { allowFailure: true }); if (result.exitCode === 0) return; failures.push({ args, @@ -209,7 +199,7 @@ async function resolveAndroidAppearanceTarget( const action = parseAppearanceAction(state); if (action !== 'toggle') return action; - const currentResult = await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night']), { + const currentResult = await runAndroidAdb(device, ['shell', 'cmd', 'uimode', 'night'], { allowFailure: true, }); if (currentResult.exitCode !== 0) { @@ -294,11 +284,9 @@ async function setAndroidPhotoPermission( const failures: Array<{ permission: string; stderr: string; exitCode: number }> = []; for (const permission of candidates) { - const result = await runCmd( - 'adb', - adbArgs(device, ['shell', 'pm', pmAction, appPackage, permission]), - { allowFailure: true }, - ); + const result = await runAndroidAdb(device, ['shell', 'pm', pmAction, appPackage, permission], { + allowFailure: true, + }); if (result.exitCode === 0) return; failures.push({ permission, stderr: result.stderr, exitCode: result.exitCode }); } @@ -318,54 +306,33 @@ async function setAndroidNotificationPermission( ): Promise { const appOpsMode = action === 'grant' ? 'allow' : action === 'deny' ? 'deny' : 'default'; if (action === 'grant') { - await runCmd('adb', adbArgs(device, ['shell', 'pm', 'grant', appPackage, target.permission]), { + await runAndroidAdb(device, ['shell', 'pm', 'grant', appPackage, target.permission], { allowFailure: true, }); } else { - await runCmd('adb', adbArgs(device, ['shell', 'pm', 'revoke', appPackage, target.permission]), { + await runAndroidAdb(device, ['shell', 'pm', 'revoke', appPackage, target.permission], { allowFailure: true, }); if (action === 'reset') { - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'pm', - 'clear-permission-flags', - appPackage, - target.permission, - 'user-set', - ]), + await runAndroidAdb( + device, + ['shell', 'pm', 'clear-permission-flags', appPackage, target.permission, 'user-set'], { allowFailure: true }, ); - await runCmd( - 'adb', - adbArgs(device, [ - 'shell', - 'pm', - 'clear-permission-flags', - appPackage, - target.permission, - 'user-fixed', - ]), + await runAndroidAdb( + device, + ['shell', 'pm', 'clear-permission-flags', appPackage, target.permission, 'user-fixed'], { allowFailure: true }, ); } } - await runCmd( - 'adb', - adbArgs(device, ['shell', 'appops', 'set', appPackage, target.appOps, appOpsMode]), - ); + await runAndroidAdb(device, ['shell', 'appops', 'set', appPackage, target.appOps, appOpsMode]); } async function getAndroidSdkInt(device: DeviceInfo): Promise { - const result = await runCmd( - 'adb', - adbArgs(device, ['shell', 'getprop', 'ro.build.version.sdk']), - { - allowFailure: true, - }, - ); + const result = await runAndroidAdb(device, ['shell', 'getprop', 'ro.build.version.sdk'], { + allowFailure: true, + }); if (result.exitCode !== 0) return null; const value = Number.parseInt(result.stdout.trim(), 10); if (!Number.isFinite(value) || value <= 0) return null; diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 3f53f6270..4cc915c21 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { runCmd } from '../../utils/exec.ts'; import { withRetry } from '../../utils/retry.ts'; import { AppError, normalizeError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; @@ -21,8 +20,7 @@ import { type AndroidSnapshotAnalysis, type AndroidUiHierarchy, } from './ui-hierarchy.ts'; -import { adbArgs } from './adb.ts'; -import { createDeviceAdbExecutor } from './adb-executor.ts'; +import { resolveAndroidAdbExecutor } from './adb-executor.ts'; import { deriveAndroidScrollableContentHints } from './scroll-hints.ts'; import { captureAndroidSnapshotWithHelper, @@ -58,11 +56,12 @@ export async function snapshotAndroid( analysis: AndroidSnapshotAnalysis; androidSnapshot: AndroidSnapshotBackendMetadata; }> { - const capture = await captureAndroidUiHierarchy(device, options); + const adb = resolveAndroidAdbExecutor(device, options.helperAdb); + const capture = await captureAndroidUiHierarchy(device, options, adb); const xml = capture.xml; if (!options.interactiveOnly) { const parsed = parseUiHierarchy(xml, ANDROID_SNAPSHOT_MAX_NODES, options); - const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes); + const nativeHints = await deriveScrollableContentHintsIfNeeded(device, parsed.nodes, adb); applyHiddenContentHintsToNodes(nativeHints, parsed.nodes); return { ...parsed, androidSnapshot: capture.metadata }; } @@ -73,7 +72,7 @@ export async function snapshotAndroid( interactiveOnly: false, }); const interactiveSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, options); - const nativeHints = await deriveScrollableContentHintsIfNeeded(device, fullSnapshot.nodes); + const nativeHints = await deriveScrollableContentHintsIfNeeded(device, fullSnapshot.nodes, adb); applyHiddenContentHintsToInteractiveNodes(nativeHints, fullSnapshot, interactiveSnapshot); if (nativeHints.size === 0) { const presentationHints = deriveMobileSnapshotHiddenContentHints( @@ -88,12 +87,12 @@ export async function snapshotAndroid( async function captureAndroidUiHierarchy( device: DeviceInfo, options: AndroidSnapshotOptions, + adb: AndroidAdbExecutor, ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { const helper = await resolveAndroidSnapshotHelperArtifact(options.helperArtifact); if (helper.artifact) { const helperDeviceKey = getAndroidSnapshotHelperDeviceKey(device); try { - const adb = options.helperAdb ?? createDeviceAdbExecutor(device); const install = await ensureAndroidSnapshotHelper({ adb, artifact: helper.artifact, @@ -134,11 +133,11 @@ async function captureAndroidUiHierarchy( packageName: helper.artifact.manifest.packageName, versionCode: helper.artifact.manifest.versionCode, }); - return await captureStockUiHierarchy(device, normalizeError(error).message); + return await captureStockUiHierarchy(device, normalizeError(error).message, adb); } } - return await captureStockUiHierarchy(device, helper.fallbackReason); + return await captureStockUiHierarchy(device, helper.fallbackReason, adb); } async function resolveAndroidSnapshotHelperArtifact( @@ -179,9 +178,10 @@ async function resolveAndroidSnapshotHelperArtifact( async function captureStockUiHierarchy( device: DeviceInfo, fallbackReason?: string, + adb?: AndroidAdbExecutor, ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { return { - xml: await dumpUiHierarchy(device), + xml: await dumpUiHierarchy(device, adb), metadata: { backend: 'uiautomator-dump', ...(fallbackReason ? { fallbackReason } : {}), @@ -198,20 +198,24 @@ function getAndroidSnapshotHelperDeviceKey(device: DeviceInfo): string { async function deriveScrollableContentHintsIfNeeded( device: DeviceInfo, nodes: RawSnapshotNode[], + adb?: AndroidAdbExecutor, ): Promise> { if (!nodes.some((node) => isScrollableType(node.type))) { return new Map(); } - const activityTopDump = await dumpActivityTop(device); + const activityTopDump = await dumpActivityTop(device, adb); if (!activityTopDump) { return new Map(); } return deriveAndroidScrollableContentHints(nodes, activityTopDump); } -export async function dumpUiHierarchy(device: DeviceInfo): Promise { +export async function dumpUiHierarchy( + device: DeviceInfo, + adb = resolveAndroidAdbExecutor(device), +): Promise { try { - return await withRetry(() => dumpUiHierarchyOnce(device), { + return await withRetry(() => dumpUiHierarchyOnce(adb), { shouldRetry: isRetryableAdbError, }); } catch (error) { @@ -232,27 +236,25 @@ export async function dumpUiHierarchy(device: DeviceInfo): Promise { } } -async function dumpUiHierarchyOnce(device: DeviceInfo): Promise { +async function dumpUiHierarchyOnce(adb: AndroidAdbExecutor): Promise { // Preferred: stream XML directly to stdout, avoiding file I/O race conditions. - const streamed = await runCmd( - 'adb', - adbArgs(device, ['exec-out', 'uiautomator', 'dump', '/dev/tty']), - { allowFailure: true, timeoutMs: UI_HIERARCHY_DUMP_TIMEOUT_MS }, - ); + const streamed = await adb(['exec-out', 'uiautomator', 'dump', '/dev/tty'], { + allowFailure: true, + timeoutMs: UI_HIERARCHY_DUMP_TIMEOUT_MS, + }); const fromStream = extractUiDumpXml(streamed.stdout, streamed.stderr); if (fromStream) return fromStream; // Fallback: dump to file and read back. // If `cat` fails with "no such file", the outer withRetry (via isRetryableAdbError) handles it. const dumpPath = '/sdcard/window_dump.xml'; - const dumpResult = await runCmd( - 'adb', - adbArgs(device, ['shell', 'uiautomator', 'dump', dumpPath]), - { allowFailure: true, timeoutMs: UI_HIERARCHY_DUMP_TIMEOUT_MS }, - ); + const dumpResult = await adb(['shell', 'uiautomator', 'dump', dumpPath], { + allowFailure: true, + timeoutMs: UI_HIERARCHY_DUMP_TIMEOUT_MS, + }); const actualPath = resolveDumpPath(dumpPath, dumpResult.stdout, dumpResult.stderr); - const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', actualPath])); + const result = await adb(['shell', 'cat', actualPath]); const xml = extractUiDumpXml(result.stdout, result.stderr); if (!xml) { throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', { @@ -310,9 +312,12 @@ function isUiHierarchyDumpTimeout(err: unknown): err is AppError { return cmd === 'adb' && args.includes('uiautomator') && args.includes('dump'); } -async function dumpActivityTop(device: DeviceInfo): Promise { +async function dumpActivityTop( + device: DeviceInfo, + adb = resolveAndroidAdbExecutor(device), +): Promise { try { - const result = await runCmd('adb', adbArgs(device, ['shell', 'dumpsys', 'activity', 'top']), { + const result = await adb(['shell', 'dumpsys', 'activity', 'top'], { allowFailure: true, timeoutMs: 8_000, }); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 6669da864..30c68211b 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -69,22 +69,20 @@ Public subpath API exposed for Node consumers: - `verifyAndroidSnapshotHelperArtifact(artifact)` - types: `AndroidAdbExecutor`, `AndroidSnapshotHelperArtifact`, `AndroidSnapshotHelperManifest`, `AndroidSnapshotHelperOutput`, `AndroidSnapshotHelperParsedSnapshot` - `agent-device/android-adb` - - `createDeviceAdbExecutor(device)` - - `resolveAndroidAdbExecutor(device, executor?)` - - `spawnAndroidAdbBySerial(serial, args, options?)` - - `withAndroidAdbProvider(provider, task)` + - `createAndroidPortReverseManager(provider)` + - `createLocalAndroidAdbProvider(device)` + - `captureAndroidLogcatWithAdb(executor, options?)` + - `streamAndroidLogcatWithAdb(provider, options?)` + - `readAndroidClipboardWithAdb(executor)` / `writeAndroidClipboardWithAdb(executor, text)` + - `getAndroidKeyboardStatusWithAdb(executor)` / `dismissAndroidKeyboardWithAdb(executor)` + - `openAndroidAppWithAdb(executor, packageName, options?)` + - `forceStopAndroidAppWithAdb(executor, packageName)` + - `resolveAndroidLaunchComponentWithAdb(executor, packageName, categories?)` - `listAndroidAppsWithAdb(executor, options?)` - `getAndroidAppStateWithAdb(executor)` - - types: `AndroidAdbProvider`, `AndroidAdbExecutor`, `AndroidAdbExecutorOptions`, `AndroidAdbExecutorResult`, `AndroidAdbSpawner` -- `agent-device/daemon` - - `createRequestHandler(deps)` - - `withDeviceInventoryProvider(provider, task)` - - `SessionStore` - - `LeaseRegistry` - - artifact tracking helpers - - request router, lease, session, device inventory, and Android ADB provider types for daemon embedders + - types: `AndroidAdbProvider`, `AndroidAdbExecutor`, `AndroidAdbExecutorOptions`, `AndroidAdbExecutorResult`, `AndroidAdbProcess`, `AndroidAdbSpawner`, `AndroidPortReverseProvider` -The `contracts`, `selectors`, `finders`, `install-source`, `android-apps`, `android-adb`, `daemon`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths remain available for compatibility. The former hosted-runtime subpaths `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability` are no longer published. +The `contracts`, `selectors`, `finders`, `install-source`, `android-adb`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths are the supported Node entry points. The former compatibility subpaths `agent-device/android-apps` and `agent-device/daemon`, plus hosted-runtime subpaths `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability`, are no longer published. ## Basic usage @@ -160,54 +158,36 @@ interactive window roots, or `active-window` when the helper falls back to Use `agent-device/android-adb` when a bridge owns Android device access but wants upstream command behavior for ADB-shaped operations. Executors receive arguments after `adb`; local callers can use -`createDeviceAdbExecutor(device)`, while remote bridges can route the same argument arrays through -an ADB tunnel. +`createLocalAndroidAdbProvider(device)`, while remote bridges can route the same argument arrays +through an abstract provider backed by an ADB tunnel, websocket API, or another remote transport. The provider contract covers normal stdout/stderr commands, binary stdout, stdin, timeout/signal -cancellation, device-scoped command interception through `withAndroidAdbProvider`, and optional -long-running spawn support for logcat-style streams. +cancellation, optional long-running spawn support for logcat-style streams, and optional reverse +support for port mappings. Public helpers accept an executor/provider directly and do not expose the +daemon's scoped adb interception internals. + +`streamAndroidLogcatWithAdb(provider, options?)` requires `provider.spawn`; exec-only providers can +use `captureAndroidLogcatWithAdb(executor, options?)`. + +Providers can also expose `reverse` for first-class port reverse ownership. Plain executors do not +advertise reverse support automatically; call `createAndroidPortReverseManager(providerOrExecutor)` +only when the provider supports `adb reverse` argument semantics. The manager makes duplicate setup +idempotent for the same owner and rejects conflicting owners for the same local endpoint. ```ts import { getAndroidAppStateWithAdb, listAndroidAppsWithAdb, - withAndroidAdbProvider, } from 'agent-device/android-adb'; const provider = { exec: async (args, options) => await runAdbThroughRemoteTunnel(args, options), }; -await withAndroidAdbProvider(provider, async () => { - const apps = await listAndroidAppsWithAdb(provider.exec, { filter: 'user-installed' }); - const foreground = await getAndroidAppStateWithAdb(provider.exec); -}); +const apps = await listAndroidAppsWithAdb(provider.exec, { filter: 'user-installed' }); +const foreground = await getAndroidAppStateWithAdb(provider.exec); ``` -## Daemon embedding - -Use `agent-device/daemon` only when an integration wants to embed the upstream request router. -The subpath is intentionally backend-neutral: it exports `createRequestHandler`, `SessionStore`, -`LeaseRegistry`, artifact tracking helpers, daemon request/response/session types, device inventory -provider types, and Android ADB provider hook types. Control-plane policy such as tenant auth, -billing, external lease ownership, storage, and uploads remains the embedder's responsibility; pass -those decisions through the dependency object instead of deep-importing daemon internals. - -`RequestRouterDeps.deviceInventoryProvider` can supply the full target candidate list for a request. -When it returns an array, even an empty one, the daemon resolver treats that inventory as -authoritative and does not call local host discovery such as `adb devices`, `simctl`, or Linux -desktop discovery. Return `undefined` or `null` to fall back to local discovery for that request. - -`RequestRouterDeps.androidAdbProvider` can supply an Android command provider for the resolved -request device. Device inventory controls discovery; the Android ADB provider controls command -execution for Android paths that already use the provider seam. Until a command family has provider -coverage, it may still require local host ADB access. - -Initial daemon conformance scope is the shared request/response contract in -`agent-device/contracts`, request-router embedding through `createRequestHandler`, authoritative -device inventory injection, artifact metadata shaping, and Android ADB provider behavior. Bridge -implementations should validate those surfaces before relying on deeper daemon internals. - ## Command methods Use `client.command.()` for command-level device actions. It uses the same daemon transport path as the higher-level client methods, including session metadata, tenant/run/lease fields, normalized daemon errors, and remote artifact handling.