Skip to content

Commit 0267fe2

Browse files
committed
feat: support Android emulator camera video files
1 parent 712b675 commit 0267fe2

10 files changed

Lines changed: 219 additions & 8 deletions

File tree

src/client-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,8 @@ type RepeatedPressOptions = {
555555

556556
export type DeviceBootOptions = DeviceCommandBaseOptions & {
557557
headless?: boolean;
558+
cameraFront?: string;
559+
cameraBack?: string;
558560
};
559561

560562
export type DeviceShutdownOptions = DeviceCommandBaseOptions;
@@ -850,6 +852,8 @@ type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
850852
pauseMs?: number;
851853
pattern?: SwipePattern;
852854
headless?: boolean;
855+
cameraFront?: string;
856+
cameraBack?: string;
853857
restart?: boolean;
854858
replayUpdate?: boolean;
855859
replayBackend?: string;

src/commands/cli-grammar/apps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export const appCliReaders = {
2828
boot: (_positionals, flags) => ({
2929
...commonInputFromFlags(flags),
3030
headless: flags.headless,
31+
cameraFront: flags.cameraFront,
32+
cameraBack: flags.cameraBack,
3133
}),
3234
shutdown: (_positionals, flags) => commonInputFromFlags(flags),
3335
prepare: (positionals, flags) => ({

src/commands/client-command-metadata.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export const clientCommandMetadata = [
3737
defineClientCommandMetadata('devices', {}),
3838
defineClientCommandMetadata('boot', {
3939
headless: booleanField('Boot without showing simulator UI when supported.'),
40+
cameraFront: stringField('Android emulator front camera mode or video file path used at boot.'),
41+
cameraBack: stringField('Android emulator back camera mode or video file path used at boot.'),
4042
}),
4143
defineClientCommandMetadata('shutdown', {}),
4244
defineClientCommandMetadata('prepare', {

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,61 @@ test('boot launches Android emulator with GUI when no running device matches', a
937937
}
938938
});
939939

940+
test('boot launches Android emulator with camera video files', async () => {
941+
const sessionStore = makeSessionStore();
942+
mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found'));
943+
const launchCalls: Array<{
944+
avdName: string;
945+
serial?: string;
946+
headless?: boolean;
947+
cameraFront?: string;
948+
cameraBack?: string;
949+
}> = [];
950+
mockEnsureAndroidEmulatorBooted.mockImplementation(
951+
async ({ avdName, serial, headless, cameraFront, cameraBack }) => {
952+
launchCalls.push({ avdName, serial, headless, cameraFront, cameraBack });
953+
return {
954+
platform: 'android',
955+
id: 'emulator-5554',
956+
name: 'Pixel_9_Pro_XL',
957+
kind: 'emulator',
958+
target: 'mobile',
959+
booted: true,
960+
};
961+
},
962+
);
963+
const response = await handleSessionCommands({
964+
req: {
965+
token: 't',
966+
session: 'default',
967+
command: 'boot',
968+
positionals: [],
969+
flags: {
970+
platform: 'android',
971+
device: 'Pixel_9_Pro_XL',
972+
cameraFront: '/tmp/front.mp4',
973+
cameraBack: '/tmp/back.mp4',
974+
},
975+
},
976+
sessionName: 'default',
977+
logPath: path.join(os.tmpdir(), 'daemon.log'),
978+
sessionStore,
979+
invoke: noopInvoke,
980+
});
981+
982+
expect(response).toBeTruthy();
983+
expect(response?.ok).toBe(true);
984+
expect(launchCalls).toEqual([
985+
{
986+
avdName: 'Pixel_9_Pro_XL',
987+
serial: undefined,
988+
headless: false,
989+
cameraFront: '/tmp/front.mp4',
990+
cameraBack: '/tmp/back.mp4',
991+
},
992+
]);
993+
});
994+
940995
test('boot --headless requires avd selector when device cannot be resolved', async () => {
941996
const sessionStore = makeSessionStore();
942997
mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found'));

src/daemon/handlers/session-state.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ async function ensureAndroidEmulatorBoot(params: {
1818
avdName: string;
1919
serial?: string;
2020
headless?: boolean;
21+
cameraFront?: string;
22+
cameraBack?: string;
2123
}): Promise<DeviceInfo> {
2224
const { ensureAndroidEmulatorBooted } = await import('../../platforms/android/devices.ts');
2325
return await ensureAndroidEmulatorBooted(params);
@@ -148,12 +150,19 @@ export async function handleSessionStateCommands(params: {
148150
normalizePlatformSelector(flags.platform) ?? session?.device.platform;
149151
const targetsAndroid = normalizedPlatform === 'android';
150152
const wantsAndroidHeadless = flags.headless === true;
153+
const wantsAndroidCamera = Boolean(flags.cameraFront || flags.cameraBack);
151154
if (wantsAndroidHeadless && !targetsAndroid) {
152155
return errorResponse(
153156
'INVALID_ARGS',
154157
'boot --headless is supported only for Android emulators.',
155158
);
156159
}
160+
if (wantsAndroidCamera && !targetsAndroid) {
161+
return errorResponse(
162+
'INVALID_ARGS',
163+
'boot --camera-front/--camera-back is supported only for Android emulators.',
164+
);
165+
}
157166

158167
const fallbackAvdName = resolveAndroidEmulatorAvdName({
159168
flags,
@@ -173,13 +182,13 @@ export async function handleSessionStateCommands(params: {
173182
const appErr = asAppError(error);
174183
if (
175184
targetsAndroid &&
176-
wantsAndroidHeadless &&
185+
(wantsAndroidHeadless || wantsAndroidCamera) &&
177186
!fallbackAvdName &&
178187
appErr.code === 'DEVICE_NOT_FOUND'
179188
) {
180189
return errorResponse(
181190
'INVALID_ARGS',
182-
'boot --headless requires --device <avd-name> (or an Android emulator session target).',
191+
androidBootOptionsRequireDeviceMessage(wantsAndroidCamera),
183192
);
184193
}
185194
if (
@@ -193,6 +202,8 @@ export async function handleSessionStateCommands(params: {
193202
avdName: fallbackAvdName,
194203
serial: flags.serial,
195204
headless: wantsAndroidHeadless,
205+
cameraFront: typeof flags.cameraFront === 'string' ? flags.cameraFront : undefined,
206+
cameraBack: typeof flags.cameraBack === 'string' ? flags.cameraBack : undefined,
196207
});
197208
launchedAndroidEmulator = true;
198209
}
@@ -204,11 +215,13 @@ export async function handleSessionStateCommands(params: {
204215
);
205216
}
206217

207-
if (targetsAndroid && wantsAndroidHeadless) {
218+
if (targetsAndroid && (wantsAndroidHeadless || wantsAndroidCamera)) {
208219
if (device.platform !== 'android' || device.kind !== 'emulator') {
209220
return errorResponse(
210221
'INVALID_ARGS',
211-
'boot --headless is supported only for Android emulators.',
222+
wantsAndroidCamera
223+
? 'boot --camera-front/--camera-back is supported only for Android emulators.'
224+
: 'boot --headless is supported only for Android emulators.',
212225
);
213226
}
214227
if (!launchedAndroidEmulator) {
@@ -220,13 +233,15 @@ export async function handleSessionStateCommands(params: {
220233
if (!avdName) {
221234
return errorResponse(
222235
'INVALID_ARGS',
223-
'boot --headless requires --device <avd-name> (or an Android emulator session target).',
236+
androidBootOptionsRequireDeviceMessage(wantsAndroidCamera),
224237
);
225238
}
226239
device = await ensureAndroidEmulatorBoot({
227240
avdName,
228241
serial: flags.serial,
229-
headless: true,
242+
headless: wantsAndroidHeadless,
243+
cameraFront: typeof flags.cameraFront === 'string' ? flags.cameraFront : undefined,
244+
cameraBack: typeof flags.cameraBack === 'string' ? flags.cameraBack : undefined,
230245
});
231246
}
232247
await ensureDeviceReady(device);
@@ -332,6 +347,12 @@ export async function handleSessionStateCommands(params: {
332347
return null;
333348
}
334349

350+
function androidBootOptionsRequireDeviceMessage(wantsAndroidCamera: boolean): string {
351+
return wantsAndroidCamera
352+
? 'boot --camera-front/--camera-back requires --device <avd-name> (or an Android emulator session target).'
353+
: 'boot --headless requires --device <avd-name> (or an Android emulator session target).';
354+
}
355+
335356
function shutdownFailureMessage(
336357
shutdown: Awaited<ReturnType<typeof shutdownDeviceTarget>>,
337358
): string {

src/platforms/android/__tests__/devices.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,47 @@ test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async
355355
});
356356
}, 10_000);
357357

358+
test('ensureAndroidEmulatorBooted launches emulator with camera video files', async () => {
359+
await withMockedAndroidTools(async ({ emulatorLogPath }) => {
360+
const frontVideo = path.join(os.tmpdir(), 'front-camera.mp4');
361+
const backVideo = path.join(os.tmpdir(), 'back-camera.mp4');
362+
await fs.writeFile(frontVideo, 'front', 'utf8');
363+
await fs.writeFile(backVideo, 'back', 'utf8');
364+
365+
const device = await ensureAndroidEmulatorBooted({
366+
avdName: 'Pixel_9_Pro_XL',
367+
timeoutMs: 5_000,
368+
cameraFront: frontVideo,
369+
cameraBack: backVideo,
370+
});
371+
assert.equal(device.id, 'emulator-5554');
372+
const log = await fs.readFile(emulatorLogPath, 'utf8');
373+
assert.match(
374+
log,
375+
new RegExp(`-camera-front videofile:${escapeRegExp(path.resolve(frontVideo))}`),
376+
);
377+
assert.match(
378+
log,
379+
new RegExp(`-camera-back videofile:${escapeRegExp(path.resolve(backVideo))}`),
380+
);
381+
});
382+
}, 10_000);
383+
384+
test('ensureAndroidEmulatorBooted rejects camera inputs for a running emulator', async () => {
385+
await withMockedAndroidTools(async ({ emulatorBootedPath }) => {
386+
await fs.writeFile(emulatorBootedPath, 'ready', 'utf8');
387+
await assert.rejects(
388+
async () =>
389+
await ensureAndroidEmulatorBooted({
390+
avdName: 'Pixel_9_Pro_XL',
391+
timeoutMs: 5_000,
392+
cameraBack: '/tmp/back-camera.mp4',
393+
}),
394+
/camera inputs can only be applied when starting an emulator/,
395+
);
396+
});
397+
}, 10_000);
398+
358399
test('ensureAndroidEmulatorBooted falls back to ANDROID_SDK_ROOT when PATH is incomplete', async () => {
359400
await withMockedAndroidSdkRoot(async ({ emulatorLogPath, sdkRoot }) => {
360401
const device = await ensureAndroidEmulatorBooted({
@@ -370,3 +411,7 @@ test('ensureAndroidEmulatorBooted falls back to ANDROID_SDK_ROOT when PATH is in
370411
assert.equal(process.env.ANDROID_HOME, sdkRoot);
371412
});
372413
}, 10_000);
414+
415+
function escapeRegExp(value: string): string {
416+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
417+
}

src/platforms/android/devices.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { existsSync } from 'node:fs';
2+
import path from 'node:path';
13
import { runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts';
24
import type { ExecResult } from '../../utils/exec.ts';
35
import { sleep } from '../../utils/timeouts.ts';
@@ -403,6 +405,8 @@ export async function ensureAndroidEmulatorBooted(params: {
403405
serial?: string;
404406
timeoutMs?: number;
405407
headless?: boolean;
408+
cameraFront?: string;
409+
cameraBack?: string;
406410
}): Promise<DeviceInfo> {
407411
await ensureAndroidSdkPathConfigured();
408412
const requestedAvdName = params.avdName.trim();
@@ -434,11 +438,26 @@ export async function ensureAndroidEmulatorBooted(params: {
434438
resolvedAvdName,
435439
params.serial,
436440
);
441+
if (existing && (params.cameraFront || params.cameraBack)) {
442+
throw new AppError(
443+
'INVALID_STATE',
444+
'Android emulator camera inputs can only be applied when starting an emulator.',
445+
{
446+
avdName: resolvedAvdName,
447+
serial: existing.id,
448+
hint: 'Shut down the emulator first, then run boot again with --camera-front or --camera-back.',
449+
},
450+
);
451+
}
437452
if (!existing) {
438453
const launchArgs = ['-avd', resolvedAvdName];
439454
if (params.headless) {
440455
launchArgs.push('-no-window', '-no-audio');
441456
}
457+
const cameraFront = resolveAndroidEmulatorCameraMode(params.cameraFront, 'front');
458+
if (cameraFront) launchArgs.push('-camera-front', cameraFront);
459+
const cameraBack = resolveAndroidEmulatorCameraMode(params.cameraBack, 'back');
460+
if (cameraBack) launchArgs.push('-camera-back', cameraBack);
442461
runCmdDetached('emulator', launchArgs);
443462
}
444463

@@ -468,6 +487,38 @@ export async function ensureAndroidEmulatorBooted(params: {
468487
};
469488
}
470489

490+
function resolveAndroidEmulatorCameraMode(
491+
value: string | undefined,
492+
camera: 'front' | 'back',
493+
): string | undefined {
494+
const trimmed = value?.trim();
495+
if (!trimmed) return undefined;
496+
if (trimmed.startsWith('videofile:')) {
497+
const videoPath = trimmed.slice('videofile:'.length);
498+
const resolvedPath = path.resolve(videoPath);
499+
if (videoPath && existsSync(resolvedPath)) return `videofile:${resolvedPath}`;
500+
}
501+
if (isAndroidEmulatorCameraMode(trimmed, camera)) return trimmed;
502+
const resolvedPath = path.resolve(trimmed);
503+
if (existsSync(resolvedPath)) return `videofile:${resolvedPath}`;
504+
throw new AppError(
505+
'INVALID_ARGS',
506+
`Android emulator ${camera} camera input is not valid: ${trimmed}`,
507+
{
508+
hint:
509+
camera === 'back'
510+
? 'Use a video file path, videofile:<path>, emulated, virtualscene, webcam<N>, or none.'
511+
: 'Use a video file path, videofile:<path>, emulated, webcam<N>, or none.',
512+
},
513+
);
514+
}
515+
516+
function isAndroidEmulatorCameraMode(value: string, camera: 'front' | 'back'): boolean {
517+
if (value === 'emulated' || value === 'none') return true;
518+
if (/^webcam\d+$/.test(value)) return true;
519+
return camera === 'back' && value === 'virtualscene';
520+
}
521+
471522
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
472523
const timeoutBudget = timeoutMs;
473524
const deadline = Deadline.fromTimeoutMs(timeoutBudget);

src/utils/__tests__/args.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,26 @@ test('parseArgs recognizes command-specific flag combinations', async () => {
3434
},
3535
{
3636
label: 'boot --headless on android',
37-
argv: ['boot', '--platform', 'android', '--device', 'Pixel_9_Pro_XL', '--headless'],
37+
argv: [
38+
'boot',
39+
'--platform',
40+
'android',
41+
'--device',
42+
'Pixel_9_Pro_XL',
43+
'--headless',
44+
'--camera-front',
45+
'/tmp/front.mp4',
46+
'--camera-back',
47+
'/tmp/back.mp4',
48+
],
3849
strictFlags: true,
3950
assertParsed: (parsed) => {
4051
assert.equal(parsed.command, 'boot');
4152
assert.equal(parsed.flags.platform, 'android');
4253
assert.equal(parsed.flags.device, 'Pixel_9_Pro_XL');
4354
assert.equal(parsed.flags.headless, true);
55+
assert.equal(parsed.flags.cameraFront, '/tmp/front.mp4');
56+
assert.equal(parsed.flags.cameraBack, '/tmp/back.mp4');
4457
},
4558
},
4659
{

src/utils/cli-command-overrides.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
6363
const CLI_COMMAND_OVERRIDES = {
6464
boot: {
6565
summary: 'Boot target device/simulator',
66-
allowedFlags: ['headless'],
66+
allowedFlags: ['headless', 'cameraFront', 'cameraBack'],
6767
},
6868
shutdown: {
6969
summary: 'Shutdown target simulator/emulator',

0 commit comments

Comments
 (0)