diff --git a/src/__tests__/android-adb-public.test.ts b/src/__tests__/android-adb-public.test.ts index 9d9031e3c..ab489fdc2 100644 --- a/src/__tests__/android-adb-public.test.ts +++ b/src/__tests__/android-adb-public.test.ts @@ -17,6 +17,9 @@ test('public android-adb entrypoint exposes helpers but not resolver internals', assert.equal('resolveAndroidAdbProvider' in androidAdb, false); assert.equal('resolveAndroidAdbExecutor' in androidAdb, false); assert.equal('createDeviceAdbExecutor' in androidAdb, false); + assert.equal('installAndroidAdbPackage' in androidAdb, false); + assert.equal('pullAndroidAdbFile' in androidAdb, false); + assert.equal('pushAndroidAdbFile' in androidAdb, false); assert.equal('withAndroidAdbProvider' in androidAdb, false); assert.equal('spawnAndroidAdbBySerial' in androidAdb, false); }); diff --git a/src/android-adb.ts b/src/android-adb.ts index 750e6c066..b17aadbfb 100644 --- a/src/android-adb.ts +++ b/src/android-adb.ts @@ -5,8 +5,12 @@ export { type AndroidAdbExecutorOptions, type AndroidAdbProcess, type AndroidAdbExecutorResult, + type AndroidAdbInstallOptions, + type AndroidAdbInstaller, type AndroidAdbProvider, + type AndroidAdbPuller, type AndroidAdbSpawner, + type AndroidAdbTransferOptions, type AndroidPortReverseEndpoint, type AndroidPortReverseMapping, type AndroidPortReverseOptions, diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 6fe90034d..c32574e72 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -67,6 +67,7 @@ import { overlayRecordingTouches, } from '../../../recording/overlay.ts'; import { runCmd, runCmdBackground } from '../../../utils/exec.ts'; +import { withAndroidAdbProvider } from '../../../platforms/android/adb-executor.ts'; type RunnerCall = { command: string; @@ -1034,6 +1035,91 @@ test('record start/stop overlays Android gestures by default on devices', async } }); +test('record stop copies Android recording through provider pull capability', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-provider-pull'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'device', + booted: true, + }), + ); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + if ( + /^-s emulator-5554 shell screenrecord \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( + command, + ) + ) { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) + ) { + return { stdout: '1024\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['start', './android-provider.mp4'], + }); + + const pullCalls: Array<{ remotePath: string; localPath: string }> = []; + const execCalls: string[][] = []; + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) + ) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const responseStop = await withAndroidAdbProvider( + { + exec: async (args) => { + execCalls.push(args); + if (args.join(' ') === 'shell ps -o pid= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(args.join(' '))) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + pull: async (remotePath, localPath) => { + pullCalls.push({ remotePath, localPath }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }, + { serial: 'emulator-5554' }, + async () => + await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['stop'], + }), + ); + + expect(responseStop?.ok).toBe(true); + expect(pullCalls).toHaveLength(1); + expect(pullCalls[0]?.remotePath).toMatch(/^\/sdcard\/agent-device-recording-\d+\.mp4$/); + expect(pullCalls[0]?.localPath).toBe(path.resolve('./android-provider.mp4')); + expect(execCalls.some((args) => args[0] === 'pull')).toBe(false); +}); + test('record start passes scaled Android screenrecord size when quality is explicit', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-quality'; diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index 4874c48cc..8e4a95934 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -11,6 +11,7 @@ import type { AndroidAdbExecutorOptions, AndroidAdbExecutorResult, } from '../../platforms/android/adb-executor.ts'; +import { pullAndroidAdbFile } from '../../platforms/android/adb-executor.ts'; const ANDROID_REMOTE_FILE_POLL_MS = 250; const ANDROID_REMOTE_FILE_ATTEMPTS = 20; @@ -150,8 +151,10 @@ async function copyAndroidRecordingWithValidation(params: { // Ignore stale local file cleanup issues and let adb pull report the real failure. } - const pullResult = await runAndroidRecordingAdb(deviceId, ['pull', remotePath, outPath], { + const device = androidDeviceForSerial(deviceId); + const pullResult = await pullAndroidAdbFile(remotePath, outPath, { allowFailure: true, + device, }); if (pullResult.exitCode !== 0) { lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull'); diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index bf65200c4..d82e2d066 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -13,6 +13,8 @@ import { createAndroidPortReverseManager, createDeviceAdbExecutor, createLocalAndroidAdbProvider, + installAndroidAdbPackage, + pullAndroidAdbFile, resolveAndroidAdbExecutor, resolveAndroidAdbProvider, withAndroidAdbProvider, @@ -125,6 +127,39 @@ test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local ); }); +test('createLocalAndroidAdbProvider exposes local pull and install capabilities', async () => { + mockRunCmd.mockClear(); + const provider = createLocalAndroidAdbProvider({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel Emulator', + kind: 'emulator', + booted: true, + }); + + await provider.pull?.('/sdcard/video.mp4', '/tmp/video.mp4', { allowFailure: true }); + await provider.install?.('/tmp/app.apk', { + allowDowngrade: true, + allowTestPackages: true, + grantPermissions: true, + replace: true, + timeoutMs: 2000, + }); + + assert.deepEqual(mockRunCmd.mock.calls, [ + [ + 'adb', + ['-s', 'emulator-5554', 'pull', '/sdcard/video.mp4', '/tmp/video.mp4'], + { allowFailure: true }, + ], + [ + 'adb', + ['-s', 'emulator-5554', 'install', '-r', '-t', '-d', '-g', '/tmp/app.apk'], + { timeoutMs: 2000 }, + ], + ]); +}); + test('createAndroidPortReverseManager makes duplicate setup idempotent and cleans owner mappings', async () => { const calls: string[][] = []; const manager = createAndroidPortReverseManager(async (args) => { @@ -198,3 +233,52 @@ test('resolveAndroidAdbProvider does not infer reverse support for plain executo assert.equal(provider.reverse, undefined); }); + +test('explicit transfer helpers prefer provider capabilities over exec-shaped fallback', async () => { + const calls: string[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(`exec:${args.join(' ')}`); + return { stdout: 'exec', stderr: '', exitCode: 0 }; + }, + pull: async (remotePath, localPath) => { + calls.push(`pull:${remotePath}:${localPath}`); + return { stdout: 'pull', stderr: '', exitCode: 0 }; + }, + install: async (source, options) => { + calls.push(`install:${String(source)}:${options?.replace === true}`); + return { stdout: 'install', stderr: '', exitCode: 0 }; + }, + }, + { serial: 'emulator-5554' }, + async () => { + await pullAndroidAdbFile('/remote.mp4', '/local.mp4'); + await installAndroidAdbPackage('/app.apk', { replace: true }); + }, + ); + + assert.deepEqual(calls, ['pull:/remote.mp4:/local.mp4', 'install:/app.apk:true']); +}); + +test('explicit transfer helpers keep exec-shaped fallback for older providers', async () => { + const calls: string[][] = []; + + await withAndroidAdbProvider( + async (args) => { + calls.push(args); + return { stdout: 'ok', stderr: '', exitCode: 0 }; + }, + { serial: 'emulator-5554' }, + async () => { + await pullAndroidAdbFile('/remote.mp4', '/local.mp4'); + await installAndroidAdbPackage('/app.apk', { replace: true }); + }, + ); + + assert.deepEqual(calls, [ + ['pull', '/remote.mp4', '/local.mp4'], + ['install', '-r', '/app.apk'], + ]); +}); diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index d09f1e8c1..257ea34ec 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -25,6 +25,7 @@ import { typeAndroid, writeAndroidClipboardText, } from '../index.ts'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts'; @@ -341,6 +342,39 @@ test('installAndroidApp installs .apk via adb install -r', async () => { await fs.rm(apkPath, { force: true }); }); +test('installAndroidInstallablePath uses provider install capability when available', async () => { + const apkPath = path.join(os.tmpdir(), `agent-device-provider-install-${Date.now()}.apk`); + await fs.writeFile(apkPath, 'placeholder', 'utf8'); + const installCalls: Array<{ source: string; replace: boolean | undefined }> = []; + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + throw new Error(`unexpected adb exec: ${args.join(' ')}`); + }, + install: async (source, options) => { + installCalls.push({ source: String(source), replace: options?.replace }); + return { stdout: 'Success', stderr: '', exitCode: 0 }; + }, + }, + { serial: 'emulator-5554' }, + async () => await installAndroidInstallablePath(device, apkPath), + ); + } finally { + await fs.rm(apkPath, { force: true }); + } + + assert.deepEqual(installCalls, [{ source: apkPath, replace: true }]); +}); + test('installAndroidApp resolves packageName and launchTarget from nested archive artifacts', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-install-archive-')); const adbPath = path.join(tmpDir, 'adb'); diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index de3aef815..5bd98da4a 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -17,6 +17,7 @@ import { type AndroidAdbExecutor, type AndroidSnapshotHelperManifest, } from '../snapshot-helper.ts'; +import type { AndroidAdbProvider } from '../adb-executor.ts'; const manifest: AndroidSnapshotHelperManifest = { name: 'android-snapshot-helper', @@ -417,6 +418,107 @@ test('ensureAndroidSnapshotHelper uninstalls and retries when signatures differ' assert.deepEqual(calls[3], ['install', '-r', '-t', apkPath]); }); +test('ensureAndroidSnapshotHelper uses provider install capability and semantic install options', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-provider-install-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const installCalls: Array<{ + apkPath: string; + replace?: boolean; + allowTestPackages?: boolean; + allowDowngrade?: boolean; + grantPermissions?: boolean; + }> = []; + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { exitCode: 1, stdout: '', stderr: 'not found' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + const adbProvider: AndroidAdbProvider = { + exec: adb, + install: async (path, options) => { + installCalls.push({ + apkPath: path, + replace: options?.replace, + allowTestPackages: options?.allowTestPackages, + allowDowngrade: options?.allowDowngrade, + grantPermissions: options?.grantPermissions, + }); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + + const result = await ensureAndroidSnapshotHelper({ + adb, + adbProvider, + artifact: { + apkPath, + manifest: { + ...manifest, + installArgs: ['install', '-r', '-t', '-d', '-g'], + sha256: sha256Text('helper-apk'), + }, + }, + }); + + assert.equal(result.installed, true); + assert.deepEqual(installCalls, [ + { + apkPath, + replace: true, + allowTestPackages: true, + allowDowngrade: true, + grantPermissions: true, + }, + ]); +}); + +test('ensureAndroidSnapshotHelper retry install also uses provider install capability', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-provider-retry-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const adbCalls: string[][] = []; + const installCalls: string[] = []; + let installAttempts = 0; + const adb: AndroidAdbExecutor = async (args) => { + adbCalls.push(args); + if (args.includes('--show-versioncode')) { + return { + exitCode: 0, + stdout: 'package:com.callstack.agentdevice.snapshothelper versionCode:1', + stderr: '', + }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }; + const adbProvider: AndroidAdbProvider = { + exec: adb, + install: async (path) => { + installCalls.push(path); + installAttempts += 1; + if (installAttempts === 1) { + return { + exitCode: 1, + stdout: '', + stderr: 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]', + }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + + const result = await ensureAndroidSnapshotHelper({ + adb, + adbProvider, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + }); + + assert.equal(result.installed, true); + assert.deepEqual(installCalls, [apkPath, apkPath]); + assert.deepEqual(adbCalls[1], ['uninstall', 'com.callstack.agentdevice.snapshothelper']); +}); + test('captureAndroidSnapshotWithHelper uses injected adb executor', async () => { let capturedArgs: string[] | undefined; const adb: AndroidAdbExecutor = async (args, options) => { diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index caca6774b..45e099b59 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -71,10 +71,39 @@ export type AndroidPortReverseProvider = { list?(options?: AndroidPortReverseOptions): Promise; }; +export type AndroidAdbTransferOptions = AndroidAdbExecutorOptions; +export type AndroidAdbInstallOptions = AndroidAdbTransferOptions & { + replace?: boolean; + allowTestPackages?: boolean; + allowDowngrade?: boolean; + grantPermissions?: boolean; +}; + +export type AndroidAdbPuller = ( + remotePath: string, + localPath: string, + options?: AndroidAdbTransferOptions, +) => Promise; + +/** + * Installs an APK path. Implementations are responsible for honoring semantic + * install options such as replace/test/downgrade/grant-permissions. + */ +export type AndroidAdbInstaller = ( + apkPath: string, + options?: AndroidAdbInstallOptions, +) => Promise; + export type AndroidAdbProvider = { + /** + * Fallback executor for device-scoped adb arguments. Providers may omit explicit + * methods to keep the legacy exec-shaped pull/install fallback. + */ exec: AndroidAdbExecutor; spawn?: AndroidAdbSpawner; reverse?: AndroidPortReverseProvider; + pull?: AndroidAdbPuller; + install?: AndroidAdbInstaller; }; export type AndroidAdbProviderScopeOptions = { @@ -111,6 +140,12 @@ export function createLocalAndroidAdbProvider(device: DeviceInfo): AndroidAdbPro exec, spawn: createSerialAdbSpawner(device.id), reverse: createExecAndroidPortReverseProvider(exec), + pull: async (remotePath, localPath, options) => + await exec(['pull', remotePath, localPath], options), + install: async (apkPath, options) => { + const { installArgs, execOptions } = normalizeAndroidAdbInstallOptions(options); + return await exec(['install', ...installArgs, apkPath], execOptions); + }, }; } @@ -193,6 +228,64 @@ function normalizeAndroidAdbProvider( return provider; } +type AndroidAdbTransferProviderOptions = { + device?: DeviceInfo; + provider?: AndroidAdbProvider | AndroidAdbExecutor; +}; + +export async function pullAndroidAdbFile( + remotePath: string, + localPath: string, + options?: AndroidAdbTransferOptions & AndroidAdbTransferProviderOptions, +): Promise { + const { device, provider, ...transferOptions } = options ?? {}; + const resolved = resolveTransferProvider(device, provider); + const pull = resolved?.pull; + if (pull) { + return await withoutCommandExecutorOverride( + async () => await pull(remotePath, localPath, transferOptions), + ); + } + const exec = resolved?.exec; + if (!exec) { + throw new AppError('COMMAND_FAILED', 'Android adb pull requires an adb provider'); + } + return await withoutCommandExecutorOverride( + async () => await exec(['pull', remotePath, localPath], transferOptions), + ); +} + +export async function installAndroidAdbPackage( + apkPath: string, + options?: AndroidAdbInstallOptions & AndroidAdbTransferProviderOptions, +): Promise { + const { device, provider, ...installOptions } = options ?? {}; + const resolved = resolveTransferProvider(device, provider); + const install = resolved?.install; + if (install) { + return await withoutCommandExecutorOverride(async () => await install(apkPath, installOptions)); + } + const exec = resolved?.exec; + if (!exec) { + throw new AppError('COMMAND_FAILED', 'Android adb install requires an adb provider'); + } + const { installArgs, execOptions } = normalizeAndroidAdbInstallOptions(installOptions); + return await withoutCommandExecutorOverride( + async () => await exec(['install', ...installArgs, apkPath], execOptions), + ); +} + +function resolveTransferProvider( + device: DeviceInfo | undefined, + provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, +): AndroidAdbProvider | undefined { + if (provider) return normalizeAndroidAdbProvider(provider); + if (device) return resolveAndroidAdbProvider(device); + const scoped = androidAdbProviderScope.getStore(); + if (scoped) return normalizeAndroidAdbProvider(scoped.provider); + return undefined; +} + export async function withAndroidAdbProvider( provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, options: AndroidAdbProviderScopeOptions, @@ -306,3 +399,17 @@ function isMissingReverseMapping(stdout: string, stderr: string): boolean { const text = `${stdout}\n${stderr}`.toLowerCase(); return text.includes('listener') && text.includes('not found'); } + +function normalizeAndroidAdbInstallOptions(options?: AndroidAdbInstallOptions): { + installArgs: string[]; + execOptions: AndroidAdbTransferOptions; +} { + const { replace, allowTestPackages, allowDowngrade, grantPermissions, ...execOptions } = + options ?? {}; + const installArgs: string[] = []; + if (replace) installArgs.push('-r'); + if (allowTestPackages) installArgs.push('-t'); + if (allowDowngrade) installArgs.push('-d'); + if (grantPermissions) installArgs.push('-g'); + return { installArgs, execOptions }; +} diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index e47c3f629..9f3497cb9 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -8,6 +8,7 @@ import { isDeepLinkTarget } from '../../core/open-target.ts'; import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { waitForAndroidBoot } from './devices.ts'; import { runAndroidAdb } from './adb.ts'; +import { installAndroidAdbPackage } from './adb-executor.ts'; import { classifyAndroidAppTarget } from './open-target.ts'; import { prepareAndroidInstallArtifact } from './install-artifact.ts'; import { @@ -540,7 +541,10 @@ async function installAndroidAppFiles(device: DeviceInfo, appPath: string): Prom await installAndroidAppBundle(device, appPath); return; } - await runAndroidAdb(device, ['install', '-r', appPath]); + await installAndroidAdbPackage(appPath, { + device, + replace: true, + }); } async function listInstalledAndroidPackages(device: DeviceInfo): Promise> { diff --git a/src/platforms/android/snapshot-helper-artifact.ts b/src/platforms/android/snapshot-helper-artifact.ts index 2b34fa8db..795284c42 100644 --- a/src/platforms/android/snapshot-helper-artifact.ts +++ b/src/platforms/android/snapshot-helper-artifact.ts @@ -15,7 +15,24 @@ import { const ANDROID_SNAPSHOT_HELPER_MAX_MANIFEST_BYTES = 64 * 1024; const ANDROID_SNAPSHOT_HELPER_MAX_APK_BYTES = 20 * 1024 * 1024; -const ANDROID_SNAPSHOT_HELPER_ALLOWED_INSTALL_FLAGS = new Set(['-r', '-t', '-d', '-g']); + +export type AndroidSnapshotHelperInstallOptions = { + replace?: boolean; + allowTestPackages?: boolean; + allowDowngrade?: boolean; + grantPermissions?: boolean; +}; + +type AndroidSnapshotHelperInstallOptionName = keyof AndroidSnapshotHelperInstallOptions; + +const ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS = { + '-r': 'replace', + '-t': 'allowTestPackages', + '-d': 'allowDowngrade', + '-g': 'grantPermissions', +} as const satisfies Record; + +type AndroidSnapshotHelperInstallFlag = keyof typeof ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS; export async function verifyAndroidSnapshotHelperArtifact( artifact: AndroidSnapshotHelperArtifact, @@ -132,10 +149,11 @@ export function parseAndroidSnapshotHelperManifest(value: unknown): AndroidSnaps }; } -export function readAndroidSnapshotHelperInstallArgs( +export function readAndroidSnapshotHelperInstallOptions( manifest: AndroidSnapshotHelperManifest, -): string[] { - return readAndroidSnapshotHelperManifestInstallArgs(manifest.installArgs); +): AndroidSnapshotHelperInstallOptions { + const installArgs = readAndroidSnapshotHelperManifestInstallArgs(manifest.installArgs); + return installOptionsFromSnapshotHelperInstallArgs(installArgs); } async function readResponseBodyWithLimit( @@ -211,6 +229,23 @@ function readAndroidSnapshotHelperManifestInstallArgs(value: unknown): string[] return installArgs; } +function installOptionsFromSnapshotHelperInstallArgs( + installArgs: string[], +): AndroidSnapshotHelperInstallOptions { + const options: AndroidSnapshotHelperInstallOptions = {}; + for (const arg of installArgs.slice(1)) { + const optionName = installOptionForSnapshotHelperInstallFlag(arg); + if (!optionName) { + throw new AppError( + 'INVALID_ARGS', + `Android snapshot helper manifest installArgs contains unsupported install flag "${arg}".`, + ); + } + options[optionName] = true; + } + return options; +} + function readSha256(value: unknown): string { const sha256 = readString(value, 'sha256').trim().toLowerCase(); if (sha256.length !== 64 || !isLowerHex(sha256)) { @@ -283,7 +318,16 @@ function readStringArray(value: unknown, field: string): string[] { } function isAllowedInstallFlag(arg: string): boolean { - return ANDROID_SNAPSHOT_HELPER_ALLOWED_INSTALL_FLAGS.has(arg); + return installOptionForSnapshotHelperInstallFlag(arg) !== undefined; +} + +function installOptionForSnapshotHelperInstallFlag( + arg: string, +): AndroidSnapshotHelperInstallOptionName | undefined { + if (!Object.hasOwn(ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS, arg)) { + return undefined; + } + return ANDROID_SNAPSHOT_HELPER_INSTALL_FLAG_OPTIONS[arg as AndroidSnapshotHelperInstallFlag]; } function isLowerHex(value: string): boolean { diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index 463afa6ee..a4b0894e0 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -1,10 +1,15 @@ import { AppError } from '../../utils/errors.ts'; import { - readAndroidSnapshotHelperInstallArgs, + readAndroidSnapshotHelperInstallOptions, verifyAndroidSnapshotHelperArtifact, } from './snapshot-helper-artifact.ts'; +import { + installAndroidAdbPackage, + type AndroidAdbExecutor, + type AndroidAdbProvider, +} from './adb-executor.ts'; +import type { AndroidSnapshotHelperInstallOptions } from './snapshot-helper-artifact.ts'; import type { - AndroidAdbExecutor, AndroidSnapshotHelperArtifact, AndroidSnapshotHelperInstallPolicy, AndroidSnapshotHelperInstallResult, @@ -51,6 +56,7 @@ function forgetInstalledSnapshotHelper(cacheKey: string | undefined): void { export async function ensureAndroidSnapshotHelper(options: { adb: AndroidAdbExecutor; + adbProvider?: AndroidAdbProvider | AndroidAdbExecutor; artifact: AndroidSnapshotHelperArtifact; deviceKey?: string; installPolicy?: AndroidSnapshotHelperInstallPolicy; @@ -99,14 +105,16 @@ export async function ensureAndroidSnapshotHelper(options: { } await verifyAndroidSnapshotHelperArtifact(artifact); - const installArgs = [ - ...readAndroidSnapshotHelperInstallArgs(artifact.manifest), + const result = await installAndroidSnapshotHelper( + adb, + options.adbProvider ?? adb, artifact.apkPath, - ]; - const result = await installAndroidSnapshotHelper(adb, installArgs, { - packageName, - timeoutMs: options.timeoutMs, - }); + readAndroidSnapshotHelperInstallOptions(artifact.manifest), + { + packageName, + timeoutMs: options.timeoutMs, + }, + ); if (result.exitCode !== 0) { forgetInstalledSnapshotHelper(installCacheKey); throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { @@ -148,10 +156,20 @@ async function readInstalledVersionCode( async function installAndroidSnapshotHelper( adb: AndroidAdbExecutor, - installArgs: string[], + adbProvider: AndroidAdbProvider | AndroidAdbExecutor, + apkPath: string, + installOptions: AndroidSnapshotHelperInstallOptions, options: { packageName: string; timeoutMs?: number }, ): Promise>> { - const result = await adb(installArgs, { allowFailure: true, timeoutMs: options.timeoutMs }); + const install = async () => + await installAndroidAdbPackage(apkPath, { + allowFailure: true, + provider: adbProvider, + ...installOptions, + timeoutMs: options.timeoutMs, + }); + + const result = await install(); if (result.exitCode === 0 || !isInstallUpdateIncompatible(result)) { return result; } @@ -160,7 +178,7 @@ async function installAndroidSnapshotHelper( allowFailure: true, timeoutMs: options.timeoutMs, }); - const retry = await adb(installArgs, { allowFailure: true, timeoutMs: options.timeoutMs }); + const retry = await install(); if (retry.exitCode === 0) { return retry; } diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 4cc915c21..79643726d 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -20,7 +20,7 @@ import { type AndroidSnapshotAnalysis, type AndroidUiHierarchy, } from './ui-hierarchy.ts'; -import { resolveAndroidAdbExecutor } from './adb-executor.ts'; +import { resolveAndroidAdbExecutor, resolveAndroidAdbProvider } from './adb-executor.ts'; import { deriveAndroidScrollableContentHints } from './scroll-hints.ts'; import { captureAndroidSnapshotWithHelper, @@ -93,8 +93,10 @@ async function captureAndroidUiHierarchy( if (helper.artifact) { const helperDeviceKey = getAndroidSnapshotHelperDeviceKey(device); try { + const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); const install = await ensureAndroidSnapshotHelper({ adb, + adbProvider, artifact: helper.artifact, deviceKey: helperDeviceKey, installPolicy: options.helperInstallPolicy,