Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,23 @@ declare global {
type Digit = 0 | DigitWithoutZero;
type BatteryLevel = `${Digit}` | `${DigitWithoutZero}${Digit}` | "100";

/**
* Options for {@link Device.setBiometricEnrollment}.
*
* On iOS, these options have no effect: iOS simulators enroll both Face ID and Touch ID together.
* On Android, the default modality is fingerprint (Virtual Fingerprint HAL); pass `androidFace: true`
* to instead enroll the Virtual Face HAL — note this requires a userdebug AVD and incurs an emulator
* reboot (~30s) due to the Virtual Face HAL's feature-flag + sensor-props setup.
*/
interface BiometricEnrollmentOptions {
/**
* Android emulator only. When `true`, `setBiometricEnrollment` sets up the Virtual Face HAL
* (enables the feature flag, sets sensor type/strength, reboots the emulator, then enrolls a
* virtual face). When `false` or omitted, the Virtual Fingerprint HAL path is used (no reboot).
*/
androidFace?: boolean;
}

interface Device {
/**
* 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 -
Expand Down Expand Up @@ -1064,29 +1081,40 @@ declare global {
shake(): Promise<void>;

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

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

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

/**
* Simulates the success of a finger match via TouchID (iOS Only)
* Simulates the success of a finger match via TouchID (iOS) or the Virtual Fingerprint HAL (Android emulator).
* Android: emulator (AVD) only.
*/
matchFinger(): Promise<void>;

/**
* Simulates the failure of a finger match via TouchID (iOS Only)
* Simulates the failure of a finger match via TouchID (iOS) or the Virtual Fingerprint HAL (Android emulator).
* Android: emulator (AVD) only.
*/
unmatchFinger(): Promise<void>;

Expand Down
119 changes: 119 additions & 0 deletions detox/src/devices/common/drivers/android/exec/ADB.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { execWithRetriesAndLogs, spawnWithRetriesAndLogs, spawnAndLog } = require
const { getAdbPath } = require('../../../../../utils/environment');
const logger = require('../../../../../utils/logger');
const { escape } = require('../../../../../utils/pipeCommands');
const retry = require('../../../../../utils/retry');
const DeviceHandle = require('../tools/DeviceHandle');
const EmulatorHandle = require('../tools/EmulatorHandle');

Expand All @@ -19,6 +20,14 @@ const DEFAULT_INSTALL_OPTIONS = {
retries: 3,
};

const ENROLLED_FINGER_ID = 1;
const UNENROLLED_FINGER_ID = 99;
const DEFAULT_BIOMETRIC_PIN = '0000';
const ENROLLED_FACE_HIT = 1;
const UNENROLLED_FACE_HIT = 2;
const BOOT_WAIT_RETRIES = 240;
const BOOT_WAIT_INTERVAL_MS = 2500;

class ADB {
constructor() {
this._cachedApiLevels = new Map();
Expand Down Expand Up @@ -46,6 +55,11 @@ class ADB {
return { devices, stdout };
}

async root(deviceId) {
await this.adbCmd(deviceId, 'root');
await this.adbCmd(deviceId, 'wait-for-device');
}

async getState(deviceId) {
try {
const output = await this.adbCmd(deviceId, `get-state`, {
Expand Down Expand Up @@ -375,6 +389,111 @@ class ADB {
return (await this.adbCmd(deviceId, `emu "${escape.inQuotedString(cmd)}"`, options)).stdout.trim();
}

async matchFinger(deviceId) {
await this.emu(deviceId, `finger touch ${ENROLLED_FINGER_ID}`);
await this.emu(deviceId, `finger remove`);
}

async unmatchFinger(deviceId) {
await this.emu(deviceId, `finger touch ${UNENROLLED_FINGER_ID}`);
await this.emu(deviceId, `finger remove`);
}

async setBiometricEnrollment(deviceId, enabled) {
await this.root(deviceId);
if (enabled) {
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
await this.shell(deviceId, `locksettings set-pin ${DEFAULT_BIOMETRIC_PIN}`);
await this.shell(deviceId, `setprop persist.vendor.fingerprint.virtual.enrollments 1`);
await this.shell(deviceId, `cmd fingerprint sync`);
} else {
await this.shell(deviceId, `cmd fingerprint sync`);
await this.shell(deviceId, `setprop persist.vendor.fingerprint.virtual.enrollments 0`);
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
}
}

async setFaceEnrollment(deviceId, enabled) {
await this.root(deviceId);
if (enabled) {
const alreadyActive = await this._isFaceVirtualHalActive(deviceId);
if (!alreadyActive) {
await this.shell(deviceId, `device_config set_sync_disabled_for_tests persistent`).catch(() => {});
await this.shell(deviceId, `device_config put biometrics_framework com.android.server.biometrics.face_vhal_feature true`);
await this.shell(deviceId, `settings put secure biometric_virtual_enabled 1`);
await this.shell(deviceId, `setprop persist.vendor.face.virtual.strength strong`);
await this.shell(deviceId, `setprop persist.vendor.face.virtual.type RGB`);
const reverses = await this._listReverses(deviceId);
await this.reboot(deviceId);
await this.root(deviceId);
await this._restoreReverses(deviceId, reverses);
}
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
await this.shell(deviceId, `locksettings set-pin ${DEFAULT_BIOMETRIC_PIN}`);
await this.shell(deviceId, `setprop persist.vendor.face.virtual.enrollments 1`);
await this.shell(deviceId, `cmd face sync`);
} else {
await this.shell(deviceId, `cmd face sync`).catch(() => {});
await this.shell(deviceId, `setprop persist.vendor.face.virtual.enrollments 0`);
await this.shell(deviceId, `locksettings clear --old ${DEFAULT_BIOMETRIC_PIN}`).catch(() => {});
}
}

async _isFaceVirtualHalActive(deviceId) {
try {
const virtualEnabled = await this.shell(deviceId, `settings get secure biometric_virtual_enabled`, { silent: true, retries: 0 });
const featureFlag = await this.shell(deviceId, `device_config get biometrics_framework com.android.server.biometrics.face_vhal_feature`, { silent: true, retries: 0 });
return String(virtualEnabled).trim() === '1' && String(featureFlag).trim() === 'true';
} catch (_) {
return false;
}
}

async _listReverses(deviceId) {
try {
const { stdout } = await this.adbCmd(deviceId, 'reverse --list');
return (stdout || '')
.split('\n')
.map(line => line.match(/(tcp:\d+)\s+(tcp:\d+)/))
.filter(Boolean)
.map(m => ({ local: m[1], remote: m[2] }));
} catch (_) {
return [];
}
}

async _restoreReverses(deviceId, reverses) {
for (const { local, remote } of reverses) {
await this.adbCmd(deviceId, `reverse ${local} ${remote}`).catch(() => {});
}
}

async matchFace(deviceId) {
await this.root(deviceId);
await this.shell(deviceId, `setprop vendor.face.virtual.enrollment_hit ${ENROLLED_FACE_HIT}`);
}

async unmatchFace(deviceId) {
await this.root(deviceId);
await this.shell(deviceId, `setprop vendor.face.virtual.enrollment_hit ${UNENROLLED_FACE_HIT}`);
}

async reboot(deviceId) {
await this.adbCmd(deviceId, 'reboot');
await this.adbCmd(deviceId, 'wait-for-device');
await this._waitForBootComplete(deviceId);
}

async _waitForBootComplete(deviceId) {
await retry({ retries: BOOT_WAIT_RETRIES, interval: BOOT_WAIT_INTERVAL_MS, shouldUnref: true }, async () => {
if (!await this.isBootComplete(deviceId)) {
throw new DetoxRuntimeError({
message: `Waited for ${deviceId} to complete booting for too long!`,
});
}
});
}

async reverse(deviceId, port) {
return this.adbCmd(deviceId, `reverse tcp:${port} tcp:${port}`);
}
Expand Down
Loading