Skip to content

Commit 803ba25

Browse files
authored
Add Touch ID and Android fingerprint simulation for settings (#135)
* fix: add touchid/fingerprint settings simulation and harden error handling * chore: tighten agent docs and simplify iOS biometric settings flow
1 parent 18364bd commit 803ba25

8 files changed

Lines changed: 469 additions & 35 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ agent-device scrollintoview @e42
155155
- `settings wifi|airplane|location on|off`
156156
- `settings appearance light|dark|toggle`
157157
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
158+
- `settings touchid match|nonmatch|enroll|unenroll` (iOS simulator only)
159+
- `settings fingerprint match|nonmatch` (Android emulator/device where supported)
158160
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
159161
- `appstate`, `apps`, `devices`, `session list`
160162
- `perf` (alias: `metrics`)
@@ -362,6 +364,9 @@ Settings helpers:
362364
- `settings airplane on|off`
363365
- `settings location on|off` (iOS uses per-app permission for the current session app)
364366
- `settings appearance light|dark|toggle` (iOS simulator appearance + Android night mode)
367+
- `settings faceid|touchid match|nonmatch|enroll|unenroll` (iOS simulator only)
368+
- `settings fingerprint match|nonmatch` (Android emulator/device where supported)
369+
On physical Android devices, fingerprint simulation depends on `cmd fingerprint` support.
365370
- `settings permission grant|deny|reset <camera|microphone|photos|contacts|notifications> [full|limited]` (session app required)
366371
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
367372
- iOS permission targets map to `simctl privacy`: `camera`, `microphone`, `photos` (`full` => `photos`, `limited` => `photos-add`), `contacts`, `notifications`.

skills/agent-device/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
129129
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
130130
- Clipboard helpers: `clipboard read` / `clipboard write <text>` are supported on Android and iOS simulators; iOS physical devices are not supported yet.
131131
- `network dump` is best-effort and parses HTTP(s) entries from the session app log file.
132-
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
132+
- Biometric settings: iOS simulator supports `settings faceid|touchid <match|nonmatch|enroll|unenroll>`; Android supports `settings fingerprint <match|nonmatch>` where runtime tooling is available.
133133
- For AndroidTV/tvOS selection, always pair `--target` with `--platform` (`ios`, `android`, or `apple` alias); target-only selection is invalid.
134134
- `push` simulates notification delivery:
135135
- iOS simulator uses APNs-style payload JSON.

src/core/settings-contract.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
export const SETTINGS_WIFI_USAGE = '<wifi|airplane|location> <on|off>';
22
export const SETTINGS_APPEARANCE_USAGE = 'appearance <light|dark|toggle>';
33
export const SETTINGS_FACEID_USAGE = 'faceid <match|nonmatch|enroll|unenroll>';
4+
export const SETTINGS_TOUCHID_USAGE = 'touchid <match|nonmatch|enroll|unenroll>';
5+
export const SETTINGS_FINGERPRINT_USAGE = 'fingerprint <match|nonmatch>';
46
export const SETTINGS_PERMISSION_USAGE =
57
'permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]';
68

79
export const SETTINGS_USAGE_OVERRIDE = [
810
`settings ${SETTINGS_WIFI_USAGE}`,
911
`settings ${SETTINGS_APPEARANCE_USAGE}`,
1012
`settings ${SETTINGS_FACEID_USAGE}`,
13+
`settings ${SETTINGS_TOUCHID_USAGE}`,
14+
`settings ${SETTINGS_FINGERPRINT_USAGE}`,
1115
`settings ${SETTINGS_PERMISSION_USAGE}`,
1216
].join(' | ');
1317

1418
export const SETTINGS_INVALID_ARGS_MESSAGE =
15-
`settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, or ${SETTINGS_PERMISSION_USAGE}`;
19+
`settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, ${SETTINGS_TOUCHID_USAGE}, ${SETTINGS_FINGERPRINT_USAGE}, or ${SETTINGS_PERMISSION_USAGE}`;

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,131 @@ test('setAndroidSetting appearance toggle rejects unknown current mode output',
392392
);
393393
});
394394

395+
test('setAndroidSetting fingerprint match uses adb shell cmd fingerprint touch', async () => {
396+
await withMockedAdb(
397+
'agent-device-android-fingerprint-match-',
398+
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
399+
async ({ argsLogPath, device }) => {
400+
await setAndroidSetting(device, 'fingerprint', 'match');
401+
const logged = await fs.readFile(argsLogPath, 'utf8');
402+
assert.match(logged, /shell\ncmd\nfingerprint\ntouch\n1/);
403+
},
404+
);
405+
});
406+
407+
test('setAndroidSetting fingerprint retries emulator command when shell cmd fingerprint fails', async () => {
408+
await withMockedAdb(
409+
'agent-device-android-fingerprint-fallback-',
410+
[
411+
'#!/bin/sh',
412+
'printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
413+
'printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
414+
'if [ "$1" = "-s" ]; then',
415+
' shift',
416+
' shift',
417+
'fi',
418+
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "fingerprint" ]; then',
419+
' echo "fingerprint cmd unavailable" >&2',
420+
' exit 1',
421+
'fi',
422+
'if [ "$1" = "emu" ] && [ "$2" = "finger" ] && [ "$3" = "touch" ] && [ "$4" = "1" ]; then',
423+
' exit 0',
424+
'fi',
425+
'echo "unexpected args: $@" >&2',
426+
'exit 1',
427+
'',
428+
].join('\n'),
429+
async ({ argsLogPath, device }) => {
430+
await setAndroidSetting(device, 'fingerprint', 'match');
431+
const logged = await fs.readFile(argsLogPath, 'utf8');
432+
assert.match(logged, /shell\ncmd\nfingerprint\ntouch\n1/);
433+
assert.match(logged, /shell\ncmd\nfingerprint\nfinger\n1/);
434+
assert.match(logged, /emu\nfinger\ntouch\n1/);
435+
},
436+
);
437+
});
438+
439+
test('setAndroidSetting fingerprint rejects unsupported action', async () => {
440+
const device: DeviceInfo = {
441+
platform: 'android',
442+
id: 'emulator-5554',
443+
name: 'Pixel',
444+
kind: 'emulator',
445+
booted: true,
446+
};
447+
await assert.rejects(
448+
() => setAndroidSetting(device, 'fingerprint', 'enroll'),
449+
(error: unknown) => {
450+
assert.equal(error instanceof AppError, true);
451+
assert.equal((error as AppError).code, 'INVALID_ARGS');
452+
assert.match((error as AppError).message, /Invalid fingerprint state/);
453+
return true;
454+
},
455+
);
456+
});
457+
458+
test('setAndroidSetting fingerprint returns COMMAND_FAILED for transport/runtime failures', async () => {
459+
await withMockedAdb(
460+
'agent-device-android-fingerprint-command-failed-',
461+
[
462+
'#!/bin/sh',
463+
'echo "error: device offline" >&2',
464+
'exit 1',
465+
'',
466+
].join('\n'),
467+
async ({ device }) => {
468+
await assert.rejects(
469+
() => setAndroidSetting(device, 'fingerprint', 'match'),
470+
(error: unknown) => {
471+
assert.equal(error instanceof AppError, true);
472+
assert.equal((error as AppError).code, 'COMMAND_FAILED');
473+
assert.match((error as AppError).message, /Failed to simulate Android fingerprint/);
474+
return true;
475+
},
476+
);
477+
},
478+
);
479+
});
480+
481+
test('setAndroidSetting fingerprint does not use adb emu command on physical devices', async () => {
482+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-fingerprint-device-'));
483+
const adbPath = path.join(tmpDir, 'adb');
484+
const argsLogPath = path.join(tmpDir, 'args.log');
485+
await fs.writeFile(
486+
adbPath,
487+
'#!/bin/sh\nprintf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\nprintf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"\necho "unknown command" >&2\nexit 1\n',
488+
'utf8',
489+
);
490+
await fs.chmod(adbPath, 0o755);
491+
492+
const previousPath = process.env.PATH;
493+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
494+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
495+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
496+
497+
const device: DeviceInfo = {
498+
platform: 'android',
499+
id: 'R5CT11',
500+
name: 'Pixel Device',
501+
kind: 'device',
502+
booted: true,
503+
};
504+
505+
try {
506+
await assert.rejects(() => setAndroidSetting(device, 'fingerprint', 'match'));
507+
const logged = await fs.readFile(argsLogPath, 'utf8');
508+
assert.doesNotMatch(logged, /\nemu\nfinger\ntouch\n/);
509+
} finally {
510+
process.env.PATH = previousPath;
511+
if (previousArgsFile === undefined) {
512+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
513+
} else {
514+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
515+
}
516+
await fs.rm(tmpDir, { recursive: true, force: true });
517+
}
518+
});
519+
395520
test('swipeAndroid invokes adb input swipe with duration', async () => {
396521
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
397522
const adbPath = path.join(tmpDir, 'adb');

src/platforms/android/index.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,11 @@ export async function setAndroidSetting(
682682
await runCmd('adb', adbArgs(device, ['shell', 'cmd', 'uimode', 'night', target === 'dark' ? 'yes' : 'no']));
683683
return;
684684
}
685+
case 'fingerprint': {
686+
const action = parseAndroidFingerprintAction(state);
687+
await runAndroidFingerprintCommand(device, action);
688+
return;
689+
}
685690
case 'permission': {
686691
if (!appPackage) {
687692
throw new AppError(
@@ -708,6 +713,91 @@ export async function setAndroidSetting(
708713
}
709714
}
710715

716+
type AndroidFingerprintAction = 'match' | 'nonmatch';
717+
718+
function parseAndroidFingerprintAction(state: string): AndroidFingerprintAction {
719+
const normalized = state.trim().toLowerCase();
720+
if (normalized === 'match') return 'match';
721+
if (normalized === 'nonmatch') return 'nonmatch';
722+
throw new AppError(
723+
'INVALID_ARGS',
724+
`Invalid fingerprint state: ${state}. Use match|nonmatch.`,
725+
);
726+
}
727+
728+
async function runAndroidFingerprintCommand(
729+
device: DeviceInfo,
730+
action: AndroidFingerprintAction,
731+
): Promise<void> {
732+
const attempts = androidFingerprintCommandAttempts(device, action);
733+
const failures: Array<{ args: string[]; stdout: string; stderr: string; exitCode: number }> = [];
734+
735+
for (const args of attempts) {
736+
const result = await runCmd('adb', adbArgs(device, args), { allowFailure: true });
737+
if (result.exitCode === 0) return;
738+
failures.push({
739+
args,
740+
stdout: result.stdout,
741+
stderr: result.stderr,
742+
exitCode: result.exitCode,
743+
});
744+
}
745+
746+
const attemptsPayload = failures.map((failure) => ({
747+
args: failure.args.join(' '),
748+
exitCode: failure.exitCode,
749+
stderr: failure.stderr.slice(0, 400),
750+
}));
751+
const capabilityMissing = failures.length > 0 && failures.every((failure) =>
752+
isAndroidFingerprintCapabilityMissing(failure.stdout, failure.stderr)
753+
);
754+
if (capabilityMissing) {
755+
throw new AppError(
756+
'UNSUPPORTED_OPERATION',
757+
'Android fingerprint simulation is not supported on this target/runtime.',
758+
{
759+
deviceId: device.id,
760+
action,
761+
hint: 'Use an Android emulator with biometric support, or a device/runtime that exposes cmd fingerprint.',
762+
attempts: attemptsPayload,
763+
},
764+
);
765+
}
766+
throw new AppError('COMMAND_FAILED', 'Failed to simulate Android fingerprint.', {
767+
deviceId: device.id,
768+
action,
769+
attempts: attemptsPayload,
770+
});
771+
}
772+
773+
function androidFingerprintCommandAttempts(
774+
device: DeviceInfo,
775+
action: AndroidFingerprintAction,
776+
): string[][] {
777+
const fingerprintId = action === 'match' ? '1' : '9999';
778+
const attempts: string[][] = [
779+
['shell', 'cmd', 'fingerprint', 'touch', fingerprintId],
780+
['shell', 'cmd', 'fingerprint', 'finger', fingerprintId],
781+
];
782+
if (device.kind === 'emulator') {
783+
attempts.push(['emu', 'finger', 'touch', fingerprintId]);
784+
}
785+
return attempts;
786+
}
787+
788+
function isAndroidFingerprintCapabilityMissing(stdout: string, stderr: string): boolean {
789+
const text = `${stdout}\n${stderr}`.toLowerCase();
790+
return (
791+
text.includes('unknown command') ||
792+
text.includes("can't find service: fingerprint") ||
793+
text.includes('service fingerprint was not found') ||
794+
text.includes('fingerprint cmd unavailable') ||
795+
text.includes('emu command is not supported') ||
796+
text.includes('emulator console is not running') ||
797+
(text.includes('fingerprint') && text.includes('not found'))
798+
);
799+
}
800+
711801
export async function pushAndroidNotification(
712802
device: DeviceInfo,
713803
packageName: string,

0 commit comments

Comments
 (0)