Skip to content

Commit 86a9ad9

Browse files
limpbrainsclaude
andcommitted
feat(android): add biometrics support for Android emulators
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bb13942 commit 86a9ad9

11 files changed

Lines changed: 624 additions & 20 deletions

File tree

detox/detox.d.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,23 @@ declare global {
697697
type Digit = 0 | DigitWithoutZero;
698698
type BatteryLevel = `${Digit}` | `${DigitWithoutZero}${Digit}` | "100";
699699

700+
/**
701+
* Options for {@link Device.setBiometricEnrollment}.
702+
*
703+
* On iOS, these options have no effect: iOS simulators enroll both Face ID and Touch ID together.
704+
* On Android, the default modality is fingerprint (Virtual Fingerprint HAL); pass `androidFace: true`
705+
* to instead enroll the Virtual Face HAL — note this requires a userdebug AVD and incurs an emulator
706+
* reboot (~30s) due to the Virtual Face HAL's feature-flag + sensor-props setup.
707+
*/
708+
interface BiometricEnrollmentOptions {
709+
/**
710+
* Android emulator only. When `true`, `setBiometricEnrollment` sets up the Virtual Face HAL
711+
* (enables the feature flag, sets sensor type/strength, reboots the emulator, then enrolls a
712+
* virtual face). When `false` or omitted, the Virtual Fingerprint HAL path is used (no reboot).
713+
*/
714+
androidFace?: boolean;
715+
}
716+
700717
interface Device {
701718
/**
702719
* Holds the environment-unique ID of the device, namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS -
@@ -1064,29 +1081,40 @@ declare global {
10641081
shake(): Promise<void>;
10651082

10661083
/**
1067-
* Toggles device enrollment in biometric auth (TouchID or FaceID) (iOS Only)
1084+
* Toggles device enrollment in biometric auth (TouchID/FaceID on iOS; fingerprint by default on Android emulators).
1085+
*
1086+
* Android: supported on Android emulators (AVDs) only; requires a userdebug emulator image.
1087+
* Defaults to fingerprint enrollment via the Virtual Fingerprint HAL. Pass `{ androidFace: true }`
1088+
* to enroll via the Virtual Face HAL instead — note this requires enabling a feature flag and
1089+
* reboots the emulator, so expect ~30s extra latency per call.
1090+
*
10681091
* @example await device.setBiometricEnrollment(true);
10691092
* @example await device.setBiometricEnrollment(false);
1093+
* @example await device.setBiometricEnrollment(true, { androidFace: true });
10701094
*/
1071-
setBiometricEnrollment(enabled: boolean): Promise<void>;
1095+
setBiometricEnrollment(enabled: boolean, options?: BiometricEnrollmentOptions): Promise<void>;
10721096

10731097
/**
1074-
* Simulates the success of a face match via FaceID (iOS Only)
1098+
* Simulates the success of a face match via FaceID (iOS) or the Virtual Face HAL (Android emulator).
1099+
* Android: emulator (AVD) only, and requires a prior call to `setBiometricEnrollment(true, { androidFace: true })`.
10751100
*/
10761101
matchFace(): Promise<void>;
10771102

10781103
/**
1079-
* Simulates the failure of a face match via FaceID (iOS Only)
1104+
* Simulates the failure of a face match via FaceID (iOS) or the Virtual Face HAL (Android emulator).
1105+
* Android: emulator (AVD) only, and requires a prior call to `setBiometricEnrollment(true, { androidFace: true })`.
10801106
*/
10811107
unmatchFace(): Promise<void>;
10821108

10831109
/**
1084-
* Simulates the success of a finger match via TouchID (iOS Only)
1110+
* Simulates the success of a finger match via TouchID (iOS) or the Virtual Fingerprint HAL (Android emulator).
1111+
* Android: emulator (AVD) only.
10851112
*/
10861113
matchFinger(): Promise<void>;
10871114

10881115
/**
1089-
* Simulates the failure of a finger match via TouchID (iOS Only)
1116+
* Simulates the failure of a finger match via TouchID (iOS) or the Virtual Fingerprint HAL (Android emulator).
1117+
* Android: emulator (AVD) only.
10901118
*/
10911119
unmatchFinger(): Promise<void>;
10921120

detox/src/devices/common/drivers/android/exec/ADB.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require
66
const { getAdbPath } = require('../../../../../utils/environment');
77
const logger = require('../../../../../utils/logger');
88
const { escape } = require('../../../../../utils/pipeCommands');
9+
const retry = require('../../../../../utils/retry');
910
const DeviceHandle = require('../tools/DeviceHandle');
1011
const EmulatorHandle = require('../tools/EmulatorHandle');
1112

@@ -19,6 +20,14 @@ const DEFAULT_INSTALL_OPTIONS = {
1920
retries: 3,
2021
};
2122

23+
const ENROLLED_FINGER_ID = 1;
24+
const UNENROLLED_FINGER_ID = 99;
25+
const DEFAULT_BIOMETRIC_PIN = '0000';
26+
const ENROLLED_FACE_HIT = 1;
27+
const UNENROLLED_FACE_HIT = 2;
28+
const BOOT_WAIT_RETRIES = 240;
29+
const BOOT_WAIT_INTERVAL_MS = 2500;
30+
2231
class ADB {
2332
constructor() {
2433
this._cachedApiLevels = new Map();
@@ -46,6 +55,11 @@ class ADB {
4655
return { devices, stdout };
4756
}
4857

58+
async root(deviceId) {
59+
await this.adbCmd(deviceId, 'root');
60+
await this.adbCmd(deviceId, 'wait-for-device');
61+
}
62+
4963
async getState(deviceId) {
5064
try {
5165
const output = await this.adbCmd(deviceId, `get-state`, {
@@ -375,6 +389,111 @@ class ADB {
375389
return (await this.adbCmd(deviceId, `emu "${escape.inQuotedString(cmd)}"`, options)).stdout.trim();
376390
}
377391

392+
async matchFinger(deviceId) {
393+
await this.emu(deviceId, `finger touch ${ENROLLED_FINGER_ID}`);
394+
await this.emu(deviceId, `finger remove`);
395+
}
396+
397+
async unmatchFinger(deviceId) {
398+
await this.emu(deviceId, `finger touch ${UNENROLLED_FINGER_ID}`);
399+
await this.emu(deviceId, `finger remove`);
400+
}
401+
402+
async setBiometricEnrollment(deviceId, enabled) {
403+
await this.root(deviceId);
404+
if (enabled) {
405+
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
406+
await this.shell(deviceId, `locksettings set-pin ${DEFAULT_BIOMETRIC_PIN}`);
407+
await this.shell(deviceId, `setprop persist.vendor.fingerprint.virtual.enrollments 1`);
408+
await this.shell(deviceId, `cmd fingerprint sync`);
409+
} else {
410+
await this.shell(deviceId, `cmd fingerprint sync`);
411+
await this.shell(deviceId, `setprop persist.vendor.fingerprint.virtual.enrollments 0`);
412+
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
413+
}
414+
}
415+
416+
async setFaceEnrollment(deviceId, enabled) {
417+
await this.root(deviceId);
418+
if (enabled) {
419+
const alreadyActive = await this._isFaceVirtualHalActive(deviceId);
420+
if (!alreadyActive) {
421+
await this.shell(deviceId, `device_config set_sync_disabled_for_tests persistent`).catch(() => {});
422+
await this.shell(deviceId, `device_config put biometrics_framework com.android.server.biometrics.face_vhal_feature true`);
423+
await this.shell(deviceId, `settings put secure biometric_virtual_enabled 1`);
424+
await this.shell(deviceId, `setprop persist.vendor.face.virtual.strength strong`);
425+
await this.shell(deviceId, `setprop persist.vendor.face.virtual.type RGB`);
426+
const reverses = await this._listReverses(deviceId);
427+
await this.reboot(deviceId);
428+
await this.root(deviceId);
429+
await this._restoreReverses(deviceId, reverses);
430+
}
431+
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
432+
await this.shell(deviceId, `locksettings set-pin ${DEFAULT_BIOMETRIC_PIN}`);
433+
await this.shell(deviceId, `setprop persist.vendor.face.virtual.enrollments 1`);
434+
await this.shell(deviceId, `cmd face sync`);
435+
} else {
436+
await this.shell(deviceId, `cmd face sync`).catch(() => {});
437+
await this.shell(deviceId, `setprop persist.vendor.face.virtual.enrollments 0`);
438+
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
439+
}
440+
}
441+
442+
async _isFaceVirtualHalActive(deviceId) {
443+
try {
444+
const virtualEnabled = await this.shell(deviceId, `settings get secure biometric_virtual_enabled`, { silent: true, retries: 0 });
445+
const featureFlag = await this.shell(deviceId, `device_config get biometrics_framework com.android.server.biometrics.face_vhal_feature`, { silent: true, retries: 0 });
446+
return String(virtualEnabled).trim() === '1' && String(featureFlag).trim() === 'true';
447+
} catch (_) {
448+
return false;
449+
}
450+
}
451+
452+
async _listReverses(deviceId) {
453+
try {
454+
const { stdout } = await this.adbCmd(deviceId, 'reverse --list');
455+
return (stdout || '')
456+
.split('\n')
457+
.map(line => line.match(/(tcp:\d+)\s+(tcp:\d+)/))
458+
.filter(Boolean)
459+
.map(m => ({ local: m[1], remote: m[2] }));
460+
} catch (_) {
461+
return [];
462+
}
463+
}
464+
465+
async _restoreReverses(deviceId, reverses) {
466+
for (const { local, remote } of reverses) {
467+
await this.adbCmd(deviceId, `reverse ${local} ${remote}`).catch(() => {});
468+
}
469+
}
470+
471+
async matchFace(deviceId) {
472+
await this.root(deviceId);
473+
await this.shell(deviceId, `setprop vendor.face.virtual.enrollment_hit ${ENROLLED_FACE_HIT}`);
474+
}
475+
476+
async unmatchFace(deviceId) {
477+
await this.root(deviceId);
478+
await this.shell(deviceId, `setprop vendor.face.virtual.enrollment_hit ${UNENROLLED_FACE_HIT}`);
479+
}
480+
481+
async reboot(deviceId) {
482+
await this.adbCmd(deviceId, 'reboot');
483+
await this.adbCmd(deviceId, 'wait-for-device');
484+
await this._waitForBootComplete(deviceId);
485+
}
486+
487+
async _waitForBootComplete(deviceId) {
488+
await retry({ retries: BOOT_WAIT_RETRIES, interval: BOOT_WAIT_INTERVAL_MS, shouldUnref: true }, async () => {
489+
if (!await this.isBootComplete(deviceId)) {
490+
throw new DetoxRuntimeError({
491+
message: `Waited for ${deviceId} to complete booting for too long!`,
492+
});
493+
}
494+
});
495+
}
496+
378497
async reverse(deviceId, port) {
379498
return this.adbCmd(deviceId, `reverse tcp:${port} tcp:${port}`);
380499
}

0 commit comments

Comments
 (0)