@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.2 → 1.0.0-canary.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -0,0 +1,885 @@
1
+ import { PLATFORM, ANDROID_PACKAGE } from "./capabilities";
2
+ import type { Driver } from "./session";
3
+ import { confirmSystemPrompt, byTestId } from "./selectors";
4
+ import { adbAsync, adbSilent, getAdbSerial } from "./adb";
5
+
6
+ /**
7
+ * Fingerprint enrollment + trigger helpers for the Android emulator.
8
+ *
9
+ * Why this file exists
10
+ * --------------------
11
+ * The Android Credential Manager / passkey flow presents a biometric
12
+ * bottom-sheet that Appium can't dismiss by tapping a button (the sheet
13
+ * is served by the system, and on a device with no enrolled finger the
14
+ * ceremony never completes). On an emulator we *can* synthesise touches
15
+ * via the host emulator console — but only once a finger is actually
16
+ * enrolled in Settings, and the emulator image boots without one.
17
+ *
18
+ * So this helper:
19
+ * 1. Drives `android.settings.BIOMETRIC_ENROLL` (preferred on API 30+)
20
+ * / `FINGERPRINT_ENROLL` (legacy) via UiAutomator2,
21
+ * 2. Feeds "finger touch" events through Appium's
22
+ * `mobile: sendFingerprint` extension (a.k.a. WebdriverIO's
23
+ * `driver.fingerPrint(id)`). This is the Sauce-Labs-compatible
24
+ * path: on a local emulator Appium routes it to the host emulator
25
+ * console, on Sauce Labs' device cloud the same command is served
26
+ * by their sensor API. We keep a host-side `adb emu finger touch`
27
+ * fallback for the pre-session enrollment wizard, since the driver
28
+ * isn't available until after the Appium session is created.
29
+ * 3. Exposes `triggerFingerprintTouch()` / `pumpFingerprintUntil()`
30
+ * for tests to call while a biometric prompt is on screen.
31
+ *
32
+ * Everything is focused on emulators: on non-Android runs
33
+ * the helpers are a no-op.
34
+ */
35
+
36
+ const DEFAULT_PIN = process.env.E2E_DEVICE_PIN ?? "1111";
37
+ const FINGER_ID = Number(process.env.E2E_FINGER_ID ?? 1);
38
+ const DEBUG = process.env.E2E_DEBUG_FINGERPRINT === "1" || process.env.CI === "true";
39
+
40
+ const PLACE_FINGER_MARKERS = [
41
+ /Touch the (fingerprint )?sensor/i,
42
+ /Place (your )?finger/i,
43
+ /Lift,? then touch/i,
44
+ /Keep lifting your finger/i,
45
+ /Add more fingerprint/i,
46
+ /Lift your finger/i,
47
+ /Setup is complete/i,
48
+ /Fingerprint added/i,
49
+ ];
50
+
51
+ function log(...parts: unknown[]): void {
52
+ if (!DEBUG) return;
53
+ // eslint-disable-next-line no-console
54
+ console.log("[e2e:fingerprint]", ...parts);
55
+ }
56
+
57
+ function isAndroidEmulator(): boolean {
58
+ if (PLATFORM !== "android") return false;
59
+ // `adb emu` commands only exist against an `emulator-XXXX` serial.
60
+ return getAdbSerial().startsWith("emulator-");
61
+ }
62
+
63
+ /**
64
+ * Sends a single synthesised fingerprint touch via Appium's
65
+ * `mobile: sendFingerprint` extension when a driver is available
66
+ * (portable to Sauce Labs and other device farms), falling back to
67
+ * host-side `adb emu finger touch` for the pre-session enrollment
68
+ * wizard where no driver exists yet.
69
+ */
70
+ export async function triggerFingerprintTouch(driver?: Driver): Promise<void> {
71
+ if (PLATFORM !== "android") return;
72
+ if (driver) {
73
+ // Preferred path: portable to Sauce Labs & other clouds.
74
+ try {
75
+ const anyDriver = driver as unknown as {
76
+ execute: (script: string, args: unknown) => Promise<unknown>;
77
+ fingerPrint?: (id: number) => Promise<void>;
78
+ };
79
+ if (typeof anyDriver.fingerPrint === "function") {
80
+ await anyDriver.fingerPrint(FINGER_ID);
81
+ return;
82
+ }
83
+ await anyDriver.execute("mobile: sendFingerprint", {
84
+ fingerprintId: FINGER_ID,
85
+ });
86
+ return;
87
+ } catch {
88
+ // Fall through to adb fallback for local emulator runs where
89
+ // the extension isn't wired up.
90
+ }
91
+ }
92
+ if (isAndroidEmulator()) {
93
+ adbAsync(["emu", "finger", "touch", String(FINGER_ID)]);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Spam touches at ~200ms cadence while `predicate()` is false. Designed
99
+ * for the brief window when the biometric sheet is on screen — stops as
100
+ * soon as the prompt closes (predicate returns true) or the deadline
101
+ * elapses.
102
+ */
103
+ export async function pumpFingerprintUntil(
104
+ driver: Driver,
105
+ predicate: () => Promise<boolean> | boolean,
106
+ {
107
+ timeoutMs = 45_000,
108
+ intervalMs = 1200,
109
+ waitFirst = true,
110
+ }: { timeoutMs?: number; intervalMs?: number; waitFirst?: boolean } = {},
111
+ ): Promise<boolean> {
112
+ if (PLATFORM !== "android") return false;
113
+ const deadline = Date.now() + timeoutMs;
114
+ log(`pump: starting (timeout=${timeoutMs}ms, waitFirst=${waitFirst})`);
115
+
116
+ const confirmIds = ["create-passkey-confirm", "get-passkey-confirm"];
117
+ const systemLabels = [
118
+ "Continue",
119
+ "Create",
120
+ "Save",
121
+ "Allow",
122
+ "Use",
123
+ "Got it",
124
+ "Select",
125
+ "OK",
126
+ "Sign In",
127
+ ];
128
+
129
+ if (waitFirst) {
130
+ log("pump: waiting for app to lose focus...");
131
+ while (Date.now() < deadline) {
132
+ if (!(await predicate())) {
133
+ log("pump: focus lost, starting pump");
134
+ break;
135
+ }
136
+ await new Promise((r) => setTimeout(r, 500));
137
+ }
138
+ }
139
+
140
+ let lastTriggerTime = 0;
141
+
142
+ while (Date.now() < deadline) {
143
+ if (await predicate()) {
144
+ log("pump: predicate true, stopping");
145
+ return true;
146
+ }
147
+
148
+ const src = await getPageSource(driver);
149
+
150
+ // 1. Try to click our own app's confirmation buttons if visible.
151
+ for (const id of confirmIds) {
152
+ const el = await driver.$(byTestId(id));
153
+ if (await el.isExisting().catch(() => false)) {
154
+ log(`pump: clicking app button ${id}`);
155
+ await el.click().catch(() => undefined);
156
+ }
157
+ }
158
+
159
+ // 2. Try to dismiss any system prompt that might be blocking us.
160
+ await confirmSystemPrompt(driver, systemLabels, 200).catch(() => undefined);
161
+
162
+ // 3. Trigger fingerprint sensor if the prompt is visible.
163
+ const isPromptVisible = PLACE_FINGER_MARKERS.some((re) => re.test(src));
164
+ const timeSinceLastTrigger = Date.now() - lastTriggerTime;
165
+
166
+ if (isPromptVisible) {
167
+ if (timeSinceLastTrigger > 2000) {
168
+ log("pump: fingerprint prompt visible, triggering touch");
169
+ await triggerFingerprintTouch(driver);
170
+ lastTriggerTime = Date.now();
171
+ }
172
+ } else if (src.length < 500) {
173
+ // If screen is "empty", it might be a secure window containing the prompt.
174
+ // In this case we still pump but slower.
175
+ if (timeSinceLastTrigger > 3000) {
176
+ log("pump: screen empty (secure window?), triggering fallback touch");
177
+ await triggerFingerprintTouch(driver);
178
+ lastTriggerTime = Date.now();
179
+ }
180
+ }
181
+
182
+ await new Promise((r) => setTimeout(r, intervalMs));
183
+ }
184
+ log("pump: timed out");
185
+ return false;
186
+ }
187
+
188
+ async function getPageSource(driver: Driver): Promise<string> {
189
+ const start = Date.now();
190
+ try {
191
+ let src = await driver.getPageSource().catch(() => "");
192
+ if ((!src || src.length < 200) && PLATFORM === "android") {
193
+ // Fallback to adb uiautomator dump for secure/system screens.
194
+ adbSilent(["shell", "uiautomator", "dump", "/sdcard/view.xml"]);
195
+ const dumped = adbSilent(["shell", "cat", "/sdcard/view.xml"]);
196
+ if (dumped && dumped.length > 200) src = dumped;
197
+ }
198
+ const elapsed = Date.now() - start;
199
+ if (elapsed > 3000) log(`getPageSource took ${elapsed}ms`);
200
+ return src;
201
+ } catch {
202
+ return "";
203
+ }
204
+ }
205
+
206
+ async function pageSourceContains(driver: Driver, patterns: RegExp[]): Promise<RegExp | null> {
207
+ const src = await getPageSource(driver);
208
+ for (const p of patterns) if (p.test(src)) return p;
209
+ return null;
210
+ }
211
+
212
+ export async function currentActivity(): Promise<string> {
213
+ // `dumpsys window` is the most reliable source for the focused
214
+ // window's package/activity across API 28..34.
215
+ const out = adbSilent(["shell", "dumpsys", "window"]);
216
+ // Match mCurrentFocus=Window{... u0 pkg/activity} or mCurrentFocus=null
217
+ const focusMatch = out.match(/mCurrentFocus=Window\{.*?\s([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.$]+)\}/);
218
+ if (focusMatch) return `${focusMatch[1]}/${focusMatch[2]}`;
219
+
220
+ const windowMatch = out.match(
221
+ /mFocusedWindow=Window\{.*?\s([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.$]+)\}/,
222
+ );
223
+ if (windowMatch) return `${windowMatch[1]}/${windowMatch[2]}`;
224
+
225
+ // Fallback 1: mFocusedApp (sometimes focus is in transition)
226
+ const appMatch = out.match(/mFocusedApp=.*?\b([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.$]+)/);
227
+ if (appMatch) return `${appMatch[1]}/${appMatch[2]}`;
228
+
229
+ // Fallback 2: mResumedActivity
230
+ const resumedMatch = out.match(/mResumedActivity:.*?\b([a-zA-Z0-9_.]+)\/([a-zA-Z0-9_.$]+)/);
231
+ if (resumedMatch) return `${resumedMatch[1]}/${resumedMatch[2]}`;
232
+
233
+ return "";
234
+ }
235
+
236
+ export async function currentPackage(): Promise<string> {
237
+ const act = await currentActivity();
238
+ return act.split("/")[0] || "";
239
+ }
240
+
241
+ /** True if `dumpsys fingerprint` reports at least one enrolled finger. */
242
+ function fingerprintEnrolledOnDevice(): boolean {
243
+ const out = adbSilent(["shell", "dumpsys", "fingerprint"]);
244
+ // Match both legacy and API-34 formats:
245
+ // "enrolledFingerprints (1): [Fingerprint: ...]"
246
+ // "hasEnrolledFingerprints=true"
247
+ // "Enrollments: 1"
248
+ if (/hasEnrolledFingerprints\s*=\s*true/i.test(out)) return true;
249
+ if (/enrolledFingerprints\s*\(\s*[1-9]\d*\s*\)/i.test(out)) return true;
250
+ if (/Enrollments?:\s*[1-9]\d*\b/i.test(out)) return true;
251
+ if (/"count"\s*:\s*[1-9]\d*/i.test(out)) return true;
252
+ // `dumpsys biometric` fallback (API 34).
253
+ const bio = adbSilent(["shell", "dumpsys", "biometric"]);
254
+ if (/authenticatorIds=\{[^}]*[1-9]/i.test(bio)) return true;
255
+ return false;
256
+ }
257
+
258
+ /**
259
+ * Extract visible button-like text/content-desc nodes from a UiAutomator
260
+ * page source for diagnostic logging + label-driven wizard navigation.
261
+ */
262
+ function extractClickableLabels(src: string): string[] {
263
+ const labels = new Set<string>();
264
+ // Match any text="..." or content-desc="..." attribute in the XML.
265
+ // We use a broad regex because Appium's getPageSource output varies by
266
+ // driver version and we want maximum visibility in logs.
267
+ const rx = /(?:text|content-desc)="([^"]+)"/g;
268
+ let m: RegExpExecArray | null;
269
+ while ((m = rx.exec(src))) {
270
+ const label = m[1].trim();
271
+ if (label && label.length > 0 && label.length < 80) {
272
+ labels.add(label);
273
+ }
274
+ }
275
+ return [...labels];
276
+ }
277
+
278
+ /**
279
+ * Perform a vertical swipe to scroll down (moving content up).
280
+ * Uses adb for robustness on system/secure screens.
281
+ */
282
+ async function swipeUp(): Promise<void> {
283
+ log("wizard: swiping up...");
284
+ // Swipe from middle-bottom to middle-top.
285
+ adbSilent(["shell", "input", "swipe", "500", "1500", "500", "500", "300"]);
286
+ await new Promise((r) => setTimeout(r, 800));
287
+ }
288
+
289
+ async function tapAnyButton(
290
+ driver: Driver,
291
+ labels: RegExp[],
292
+ shouldSwipe = false,
293
+ ): Promise<string | null> {
294
+ const tryTap = async () => {
295
+ // 1. Try text and content-desc with clickable(true)
296
+ for (const re of labels) {
297
+ const flags = re.flags.includes("i") ? "(?i)" : "";
298
+ const pattern = `${flags}${re.source}`;
299
+
300
+ // Try text
301
+ let sel = `android=new UiSelector().textMatches("${pattern}").clickable(true)`;
302
+ let el = await driver.$(sel);
303
+ if (await el.isExisting().catch(() => false)) {
304
+ if (
305
+ await el
306
+ .click()
307
+ .then(() => true)
308
+ .catch(() => false)
309
+ )
310
+ return re.source;
311
+ }
312
+
313
+ // Try content-desc
314
+ sel = `android=new UiSelector().descriptionMatches("${pattern}").clickable(true)`;
315
+ el = await driver.$(sel);
316
+ if (await el.isExisting().catch(() => false)) {
317
+ if (
318
+ await el
319
+ .click()
320
+ .then(() => true)
321
+ .catch(() => false)
322
+ )
323
+ return `(desc) ${re.source}`;
324
+ }
325
+ }
326
+
327
+ // 2. Resource-ids (very common for "Next" buttons)
328
+ const commonIds = [
329
+ "button1",
330
+ "next_button",
331
+ "continue_button",
332
+ "suw_navbar_next",
333
+ "sud_navbar_next",
334
+ ];
335
+ for (const id of commonIds) {
336
+ const sel = `android=new UiSelector().resourceIdMatches(".*:id/${id}").clickable(true)`;
337
+ const el = await driver.$(sel);
338
+ if (await el.isExisting().catch(() => false)) {
339
+ if (
340
+ await el
341
+ .click()
342
+ .then(() => true)
343
+ .catch(() => false)
344
+ )
345
+ return `(id) ${id}`;
346
+ }
347
+ }
348
+
349
+ // 3. Ultimate fallback: try clicking by text even if NOT marked clickable="true"
350
+ for (const re of labels) {
351
+ const flags = re.flags.includes("i") ? "(?i)" : "";
352
+ const sel = `android=new UiSelector().textMatches("${flags}${re.source}")`;
353
+ const el = await driver.$(sel);
354
+ if (await el.isExisting().catch(() => false)) {
355
+ if (
356
+ await el
357
+ .click()
358
+ .then(() => true)
359
+ .catch(() => false)
360
+ )
361
+ return `(unclickable) ${re.source}`;
362
+ }
363
+ }
364
+ return null;
365
+ };
366
+
367
+ let hit = await tryTap();
368
+ if (hit) return hit;
369
+
370
+ if (shouldSwipe) {
371
+ // If not found, maybe it's off-screen. Try one swipe and look again.
372
+ // This is helpful for "Pixel Imprint" / "I agree" screens.
373
+ await swipeUp();
374
+ hit = await tryTap();
375
+ return hit;
376
+ }
377
+ return null;
378
+ }
379
+
380
+ async function enterPinIfPrompted(driver: Driver, pin = DEFAULT_PIN): Promise<boolean> {
381
+ const hit = await pageSourceContains(driver, [
382
+ /Enter your PIN/i,
383
+ /Confirm your PIN/i,
384
+ /Re-enter your PIN/i,
385
+ /Enter PIN/i,
386
+ /PIN area/i,
387
+ ]);
388
+
389
+ // Also check activity if screen is "empty" (potential FLAG_SECURE).
390
+ const activity = await currentActivity();
391
+ const isPinScreen = /ConfirmDeviceCredential|ConfirmLockPassword/i.test(activity);
392
+
393
+ if (!hit && !isPinScreen) return false;
394
+
395
+ if (isPinScreen && !hit) {
396
+ log(`wizard: possible secure PIN screen (activity=${activity}), attempting blind entry...`);
397
+ } else {
398
+ log("wizard: entering PIN...");
399
+ }
400
+
401
+ const typeAndEnter = async () => {
402
+ // 1. Try input text (fastest, works on most AOSP images).
403
+ log("wizard: attempting 'input text' for PIN...");
404
+ adbSilent(["shell", "input", "text", pin]);
405
+ adbSilent(["shell", "input", "keyevent", "66"]); // KEYCODE_ENTER
406
+ await new Promise((r) => setTimeout(r, 800));
407
+
408
+ // 2. If still on PIN screen, try individual keyevents (works on custom/secure PIN pads).
409
+ const src = await getPageSource(driver);
410
+ const stillThere = [/PIN area/i, /Enter.*PIN/i, /Confirm.*PIN/i, /Re-enter/i].some((re) =>
411
+ re.test(src),
412
+ );
413
+ if (stillThere) {
414
+ log("wizard: PIN screen still visible after 'input text', trying keyevents...");
415
+ // Backspace a bunch to clear the previous attempt.
416
+ for (let i = 0; i < 12; i++) adbSilent(["shell", "input", "keyevent", "67"]);
417
+
418
+ for (const char of pin) {
419
+ const code = 7 + Number(char); // KEYCODE_0 is 7, KEYCODE_1 is 8...
420
+ adbSilent(["shell", "input", "keyevent", String(code)]);
421
+ await new Promise((r) => setTimeout(r, 150));
422
+ }
423
+ adbSilent(["shell", "input", "keyevent", "66"]); // KEYCODE_ENTER
424
+ await new Promise((r) => setTimeout(r, 1000));
425
+
426
+ // 3. Try clicking the "Enter" button if it exists in the source we just got.
427
+ if (src.includes('text="Enter"') || src.includes('content-desc="Enter"')) {
428
+ log("wizard: found 'Enter' button, tapping...");
429
+ await tapAnyButton(driver, [/Enter/i]);
430
+ }
431
+ }
432
+ };
433
+
434
+ await typeAndEnter();
435
+
436
+ // If we are still on the PIN screen after a moment, try clicking buttons.
437
+ // We poll activity for a faster transition.
438
+ let isStillPin = true;
439
+ for (let i = 0; i < 6; i++) {
440
+ const activity = await currentActivity();
441
+ if (!/ConfirmDeviceCredential|ConfirmLockPassword/i.test(activity)) {
442
+ isStillPin = false;
443
+ break;
444
+ }
445
+ await new Promise((r) => setTimeout(r, 400));
446
+ }
447
+
448
+ if (
449
+ isStillPin &&
450
+ (await pageSourceContains(driver, [/PIN area/i, /Enter.*PIN/i, /Confirm.*PIN/i, /Re-enter/i]))
451
+ ) {
452
+ log("wizard: PIN screen still visible, trying button clicks...");
453
+ // Click "PIN area" first to focus it.
454
+ await tapAnyButton(driver, [/PIN area/i]);
455
+
456
+ for (const char of pin) {
457
+ // Tap the digit button.
458
+ await tapAnyButton(driver, [new RegExp(`^${char}$`)]);
459
+ }
460
+ // Try clicking any "Next" or "Enter" button that appeared or was already there.
461
+ await tapAnyButton(driver, [/Next/i, /Enter/i, /Confirm/i, /Done/i, /OK/i, /Continue/i]);
462
+ // And another Enter keyevent.
463
+ adbSilent(["shell", "input", "keyevent", "66"]);
464
+ }
465
+
466
+ return true;
467
+ }
468
+
469
+ let enrollmentAttempted = false;
470
+ let enrollmentSucceeded = false;
471
+
472
+ /**
473
+ * Unified entry point to ensure biometrics (Face ID / Fingerprint) are
474
+ * enrolled on the current platform's virtual device. Throws if
475
+ * enrollment fails on a platform that supports it.
476
+ */
477
+ export async function ensureBiometricEnrolled(driver: Driver): Promise<boolean> {
478
+ if (PLATFORM === "ios") {
479
+ return ensureIosBiometricEnrolled(driver);
480
+ }
481
+ if (PLATFORM === "android") {
482
+ return ensureAndroidFingerprintEnrolled(driver);
483
+ }
484
+ return true;
485
+ }
486
+
487
+ /**
488
+ * Enable Face ID enrollment on the iOS simulator via Appium's
489
+ * `mobile: enrollBiometric` extension.
490
+ */
491
+ export async function ensureIosBiometricEnrolled(driver: Driver): Promise<boolean> {
492
+ if (PLATFORM !== "ios") return true;
493
+ try {
494
+ const anyDriver = driver as unknown as {
495
+ execute: (script: string, args: unknown) => Promise<unknown>;
496
+ };
497
+ await anyDriver.execute("mobile: enrollBiometric", { isEnabled: true });
498
+ log("ios: biometric enrollment enabled");
499
+ return true;
500
+ } catch (e) {
501
+ log("ios: biometric enrollment failed:", e);
502
+ return false;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Reset the device state by clearing all fingerprints and PINs.
508
+ * This ensures the AVD is "fresh" for the test run.
509
+ */
510
+ async function resetDeviceState(): Promise<void> {
511
+ log("resetting device state...");
512
+ // Clear any existing PIN. This also clears fingerprints.
513
+ // The magic number 1234 is a common default or previously set PIN.
514
+ // Try with common PINs just in case.
515
+ for (const p of [DEFAULT_PIN, "1234", "0000", "1111"]) {
516
+ adbSilent(["shell", "locksettings", "clear", "--old", p]);
517
+ }
518
+ // Force a clean state.
519
+ adbSilent(["shell", "locksettings", "clear"]);
520
+ // Clear settings app data to reset wizard states.
521
+ adbSilent(["shell", "pm", "clear", "com.android.settings"]);
522
+ log("device state reset complete");
523
+ }
524
+
525
+ /**
526
+ * Drive `android.settings.BIOMETRIC_ENROLL` / `FINGERPRINT_ENROLL` to
527
+ * completion on the emulator so subsequent passkey / biometric prompts
528
+ * can be satisfied with `adb emu finger touch`.
529
+ *
530
+ * Idempotent: if the fingerprint is already enrolled (detected via
531
+ * `dumpsys fingerprint`) the wizard is skipped. Returns `true` if the
532
+ * emulator has at least one finger enrolled when the call returns.
533
+ */
534
+ export async function ensureAndroidFingerprintEnrolled(driver: Driver): Promise<boolean> {
535
+ if (!isAndroidEmulator()) return false;
536
+ if (enrollmentSucceeded) return true;
537
+ if (enrollmentAttempted && !enrollmentSucceeded) return false;
538
+ enrollmentAttempted = true;
539
+
540
+ await resetDeviceState();
541
+
542
+ // Pre-flight diagnostics (always printed; cheap and invaluable when
543
+ // CI flakes on a new image revision).
544
+ log(
545
+ "preflight:",
546
+ "fingerprint-feature=" +
547
+ (adbSilent(["shell", "pm", "list", "features"]).includes("fingerprint") ? "yes" : "no"),
548
+ );
549
+ log(
550
+ "preflight: BIOMETRIC_ENROLL resolves to:",
551
+ adbSilent([
552
+ "shell",
553
+ "cmd",
554
+ "package",
555
+ "resolve-activity",
556
+ "-a",
557
+ "android.settings.BIOMETRIC_ENROLL",
558
+ ])
559
+ .replace(/\s+/g, " ")
560
+ .slice(0, 200),
561
+ );
562
+
563
+ // Quick exit if a finger is already enrolled (e.g. AVD cache warm).
564
+ if (fingerprintEnrolledOnDevice()) {
565
+ log("preflight: finger already enrolled, skipping wizard");
566
+ enrollmentSucceeded = true;
567
+ return true;
568
+ }
569
+
570
+ // Make sure a device credential exists — the enrollment wizard will
571
+ // hand off to the "Confirm your PIN" screen otherwise and stall.
572
+ adbSilent(["shell", "locksettings", "set-pin", DEFAULT_PIN]);
573
+
574
+ // After `locksettings set-pin` the keyguard can be up (on top of
575
+ // `com.android.systemui`), which prevents any `am start` activity
576
+ // from becoming the focused window. Wake the device, dismiss the
577
+ // keyguard (requires PIN since API 28, so feed it), and only then
578
+ // launch the enrollment intent.
579
+ const unlockDevice = async (): Promise<void> => {
580
+ log("unlocking device...");
581
+ adbSilent(["shell", "input", "keyevent", "KEYCODE_WAKEUP"]);
582
+ adbSilent(["shell", "wm", "dismiss-keyguard"]);
583
+ // Keyevent 82 is "Menu", which on many emulator images bypasses
584
+ // the "Swipe up to unlock" screen.
585
+ adbSilent(["shell", "input", "keyevent", "82"]);
586
+ adbSilent(["shell", "input", "keyevent", "KEYCODE_ESCAPE"]);
587
+ await new Promise((r) => setTimeout(r, 500));
588
+
589
+ // If the PIN entry is showing (post-dismiss), feed it.
590
+ for (let i = 0; i < 3; i++) {
591
+ const policy = adbSilent(["shell", "dumpsys", "window", "policy"]);
592
+ const isLocked = /mKeyguardShowing=true|isKeyguardShowing=true|mShowingLockscreen=true/i.test(
593
+ policy,
594
+ );
595
+ const pkg = await currentPackage();
596
+
597
+ if (isLocked || /systemui|keyguard/i.test(pkg)) {
598
+ log(`unlock: device is locked (pkg=${pkg}), entering PIN (attempt ${i + 1})`);
599
+ // Swipe up as a fallback for newer images
600
+ adbSilent(["shell", "input", "swipe", "500", "1500", "500", "500"]);
601
+ await new Promise((r) => setTimeout(r, 500));
602
+ adbSilent(["shell", "input", "text", DEFAULT_PIN]);
603
+ adbSilent(["shell", "input", "keyevent", "KEYCODE_ENTER"]);
604
+ await new Promise((r) => setTimeout(r, 1000));
605
+ } else {
606
+ break;
607
+ }
608
+ }
609
+ };
610
+
611
+ // Launch the enrollment activity. Prioritize FINGERPRINT_ENROLL on
612
+ // Play Store images as it's often more direct.
613
+ const tryLaunch = async (action: string, extras: string[] = []): Promise<boolean> => {
614
+ adbSilent(["shell", "am", "force-stop", "com.android.settings"]);
615
+ await unlockDevice();
616
+ log(`wizard: launching ${action}...`);
617
+ // `am start -W` blocks until the target Activity reports launched
618
+ const startOut = adbSilent(["shell", "am", "start", "-W", "-a", action, ...extras]);
619
+ if (startOut.includes("Error") || startOut.includes("Exception")) {
620
+ log(`wizard: launch failed: ${startOut.replace(/\s+/g, " ").slice(0, 100)}`);
621
+ }
622
+
623
+ for (let i = 0; i < 40; i++) {
624
+ const act = await currentActivity();
625
+ if (/settings/i.test(act)) {
626
+ log(`wizard: Settings foreground (act=${act}) after ${i * 400}ms`);
627
+ return true;
628
+ }
629
+ // If we landed back on keyguard (e.g. PIN entry required before
630
+ // Settings opens biometric enroll), dismiss + enter PIN again.
631
+ if (/systemui|keyguard/i.test(act)) {
632
+ adbSilent(["shell", "wm", "dismiss-keyguard"]);
633
+ adbSilent(["shell", "input", "text", DEFAULT_PIN]);
634
+ adbSilent(["shell", "input", "keyevent", "66"]);
635
+ }
636
+ await new Promise((r) => setTimeout(r, 400));
637
+ }
638
+ return false;
639
+ };
640
+
641
+ const settingsLaunched =
642
+ (await tryLaunch("android.settings.FINGERPRINT_ENROLL")) ||
643
+ (await tryLaunch("android.settings.BIOMETRIC_ENROLL", [
644
+ "--ei",
645
+ "android.provider.extra.BIOMETRIC_AUTHENTICATORS_ALLOWED",
646
+ "15",
647
+ ])) ||
648
+ (await tryLaunch("android.settings.FINGERPRINT_SETTINGS"));
649
+ if (!settingsLaunched) {
650
+ const act = await currentActivity();
651
+ const keyguard =
652
+ adbSilent(["shell", "dumpsys", "window", "policy"]).match(/mKeyguardShowing=(\w+)/)?.[1] ??
653
+ "unknown";
654
+ log(
655
+ `wizard: Settings never came to foreground; bailing. focused-act=${act || "(none)"} keyguard=${keyguard}`,
656
+ );
657
+ log(
658
+ "wizard: dumpsys power (partial) =",
659
+ adbSilent(["shell", "dumpsys", "power"])
660
+ .match(/mWakefulness=\w+|mScreenOn=\w+|mInteractive=\w+/g)
661
+ ?.join(", ") || "unknown",
662
+ );
663
+ log("wizard: dumpsys window (tail) =", adbSilent(["shell", "dumpsys", "window"]).slice(-1000));
664
+ await restoreAppFocus(driver);
665
+ return false;
666
+ }
667
+
668
+ // Wait a moment for the initial wizard screen to render.
669
+ await new Promise((r) => setTimeout(r, 200));
670
+
671
+ const overallDeadline = Date.now() + 120_000;
672
+ const doneMarkers = [/^Done$/i, /^Finish$/i, /^Got it$/i, /^I'm done$/i];
673
+ const stepForwardMarkers = [
674
+ /^Next$/i,
675
+ /^Continue$/i,
676
+ /^Start$/i,
677
+ /^I agree$/i,
678
+ /^Agree$/i,
679
+ /^Agree & continue$/i,
680
+ /^Accept$/i,
681
+ /^OK$/i,
682
+ /^Okay$/i,
683
+ /^Got it$/i,
684
+ /^Done$/i,
685
+ /^Finish$/i,
686
+ /^Set up$/i,
687
+ /^Use fingerprint$/i,
688
+ /^Yes, I'm in$/i,
689
+ /^I'm in$/i,
690
+ /^Turn on$/i,
691
+ /^Confirm$/i,
692
+ /^Enter$/i,
693
+ // Catch-all for varied labels
694
+ /^Fingerprint unlock$/i,
695
+ /^Add fingerprint$/i,
696
+ ];
697
+
698
+ let lastScreenSignature = "";
699
+ let sameScreenCount = 0;
700
+
701
+ while (Date.now() < overallDeadline) {
702
+ if (fingerprintEnrolledOnDevice()) {
703
+ log("wizard: dumpsys reports enrolled, finishing");
704
+ enrollmentSucceeded = true;
705
+ // Tap Done if the summary screen is up; harmless if not.
706
+ await tapAnyButton(driver, doneMarkers);
707
+ break;
708
+ }
709
+
710
+ const src = await getPageSource(driver);
711
+ const sig = src.slice(0, 4096);
712
+ if (sig === lastScreenSignature) {
713
+ sameScreenCount++;
714
+ if (sameScreenCount === 4) {
715
+ log("wizard: screen unchanged, swiping up to reveal more...");
716
+ await swipeUp();
717
+ }
718
+ } else {
719
+ sameScreenCount = 0;
720
+ lastScreenSignature = sig;
721
+ const act = await currentActivity();
722
+ const pkg = await currentPackage();
723
+ const labels = extractClickableLabels(src);
724
+ log(`wizard: screen change (act=${act}), clickable labels:`, labels.slice(0, 30));
725
+
726
+ // If focus lost unexpectedly, try to bring Settings back.
727
+ if (
728
+ !/settings|systemui|keyguard|confirm|enroll/i.test(pkg) &&
729
+ pkg !== "" &&
730
+ Date.now() < overallDeadline - 5000
731
+ ) {
732
+ log(`wizard: focus lost to ${pkg} (act=${act}), attempting to re-launch Settings...`);
733
+ const ok = await tryLaunch("android.settings.FINGERPRINT_ENROLL");
734
+ if (ok) {
735
+ log("wizard: successfully re-launched Settings");
736
+ sameScreenCount = 0;
737
+ continue;
738
+ } else {
739
+ log("wizard: failed to re-launch Settings; continuing anyway");
740
+ }
741
+ }
742
+
743
+ if (labels.length === 0 && src.length < 500) {
744
+ log("wizard: screen source is very short/empty, possible secure window or loading state.");
745
+ }
746
+ }
747
+
748
+ // 1. Are we on the "place your finger" screen? Pump touches hard.
749
+ if (PLACE_FINGER_MARKERS.some((re) => re.test(src))) {
750
+ log("wizard: place-finger screen; pumping touches");
751
+ for (let i = 0; i < 8; i++) {
752
+ await triggerFingerprintTouch(driver);
753
+ await new Promise((r) => setTimeout(r, 400));
754
+ if (fingerprintEnrolledOnDevice()) break;
755
+ }
756
+ // After the sensor screen usually a "Done" / "Next" follows.
757
+ continue;
758
+ }
759
+
760
+ // 2. PIN confirmation.
761
+ if (await enterPinIfPrompted(driver)) {
762
+ sameScreenCount = 0; // Reset count as we just interacted
763
+ await new Promise((r) => setTimeout(r, 200));
764
+ continue;
765
+ }
766
+
767
+ // 3. Terminal "Done".
768
+ const doneHit = await tapAnyButton(driver, doneMarkers);
769
+ if (doneHit && fingerprintEnrolledOnDevice()) {
770
+ log("wizard: Done clicked:", doneHit);
771
+ enrollmentSucceeded = true;
772
+ break;
773
+ }
774
+
775
+ // 4. "More" button (common on Pixel Imprint/Agree screens).
776
+ // Prioritize this to navigate to Agree without a blind swipe.
777
+ const moreHit = await tapAnyButton(driver, [/^More$/i]);
778
+ if (moreHit) {
779
+ log("wizard: 'More' clicked, scrolling down");
780
+ await new Promise((r) => setTimeout(r, 500));
781
+ continue;
782
+ }
783
+
784
+ // 5. Step-forward button.
785
+ const stepHit = await tapAnyButton(driver, stepForwardMarkers);
786
+ if (stepHit) {
787
+ log("wizard: stepped forward via:", stepHit);
788
+ await new Promise((r) => setTimeout(r, 400));
789
+ continue;
790
+ }
791
+
792
+ // 6. Scroll if we seem stuck or on a long page (like Pixel Imprint)
793
+ if (sameScreenCount > 3) {
794
+ await swipeUp();
795
+ sameScreenCount = 0;
796
+ continue;
797
+ }
798
+
799
+ // 7. Unrecognised screen — feed a touch (harmless if not on sensor
800
+ // screen) and wait. If nothing changes for too long, bail.
801
+ await triggerFingerprintTouch(driver);
802
+ await new Promise((r) => setTimeout(r, 500));
803
+
804
+ if (sameScreenCount > 20) {
805
+ log(
806
+ "wizard: screen unchanged for ~10s, giving up. Last labels:",
807
+ extractClickableLabels(src),
808
+ );
809
+ break;
810
+ }
811
+ }
812
+
813
+ // Double-check by querying the service directly.
814
+ if (!enrollmentSucceeded && fingerprintEnrolledOnDevice()) {
815
+ enrollmentSucceeded = true;
816
+ }
817
+
818
+ if (!enrollmentSucceeded) {
819
+ log("wizard: enrollment FAILED; final dumpsys fingerprint =");
820
+ log(adbSilent(["shell", "dumpsys", "fingerprint"]).slice(0, 800));
821
+ } else {
822
+ log("wizard: enrollment SUCCEEDED");
823
+ }
824
+
825
+ // Return focus to the example app regardless of outcome so the first
826
+ // test can still run its non-biometric assertions.
827
+ await restoreAppFocus(driver);
828
+
829
+ return enrollmentSucceeded;
830
+ }
831
+
832
+ /**
833
+ * Bring the RN example app back to the foreground after we navigated
834
+ * into Settings for fingerprint enrollment. Uses Appium's
835
+ * `mobile: activateApp` first (correct native API, survives Settings
836
+ * task being on top), falling back to `am start` / `monkey` if the
837
+ * Appium command is unavailable on the current driver build.
838
+ */
839
+ export async function restoreAppFocus(driver: Driver): Promise<void> {
840
+ // 0. If we're already in the app, do nothing.
841
+ const pkg = await currentPackage();
842
+ if (pkg === ANDROID_PACKAGE) {
843
+ log("restoreAppFocus: already in app, skipping");
844
+ return;
845
+ }
846
+
847
+ log(`restoreAppFocus: in ${pkg}, returning to ${ANDROID_PACKAGE}`);
848
+
849
+ // 1. Close any Settings-owned activity cluttering the back stack.
850
+ adbSilent(["shell", "input", "keyevent", "KEYCODE_HOME"]);
851
+ await new Promise((r) => setTimeout(r, 300));
852
+
853
+ // 2. Ask Appium to bring the RN app forward (preferred path).
854
+ try {
855
+ // `activateApp` is exposed on UiAutomator2 sessions.
856
+ const anyDriver = driver as unknown as {
857
+ activateApp?: (pkg: string) => Promise<void>;
858
+ execute?: (script: string, args: unknown) => Promise<unknown>;
859
+ };
860
+ if (typeof anyDriver.activateApp === "function") {
861
+ await anyDriver.activateApp(ANDROID_PACKAGE);
862
+ } else if (typeof anyDriver.execute === "function") {
863
+ await anyDriver.execute("mobile: activateApp", { appId: ANDROID_PACKAGE });
864
+ }
865
+ } catch {
866
+ // Fall through to adb fallbacks below.
867
+ }
868
+
869
+ // 3. Best-effort adb fallbacks.
870
+ adbSilent([
871
+ "shell",
872
+ "monkey",
873
+ "-p",
874
+ ANDROID_PACKAGE,
875
+ "-c",
876
+ "android.intent.category.LAUNCHER",
877
+ "1",
878
+ ]);
879
+
880
+ // 4. Verify focus returned; poll briefly.
881
+ for (let i = 0; i < 15; i++) {
882
+ if ((await currentPackage()) === ANDROID_PACKAGE) return;
883
+ await new Promise((r) => setTimeout(r, 400));
884
+ }
885
+ }