@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.
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +14 -0
- package/.releaserc +30 -0
- package/ARCHITECTURE.md +8 -0
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +15 -5
- package/README.md +150 -11
- package/android/build.gradle +33 -10
- package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
- package/android/src/main/AndroidManifest.xml +9 -3
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
- package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/app.plugin.js +593 -60
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js +21 -2
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +106 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +190 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
- package/lefthook.yml +14 -0
- package/package.json +115 -25
- package/pnpm-workspace.yaml +36 -0
- package/src/ReactNativePasskeyAutofill.types.ts +38 -0
- package/src/ReactNativePasskeyAutofillModule.ts +66 -3
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
- package/.eslintrc.js +0 -5
- /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
|
+
}
|