@switchlabs/verify-ai-react-native 2.4.14 → 2.4.16
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/lib/components/VerifyAIScanner.d.ts +4 -2
- package/lib/components/VerifyAIScanner.js +371 -44
- package/lib/index.d.ts +1 -1
- package/lib/types/index.d.ts +12 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +432 -44
- package/src/index.ts +1 -0
- package/src/types/index.ts +13 -0
- package/src/version.ts +1 -1
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
VerificationResult,
|
|
19
19
|
ScannerStatus,
|
|
20
20
|
ScannerOverlayConfig,
|
|
21
|
+
ScannerTelemetryContext,
|
|
21
22
|
} from '../types';
|
|
22
23
|
import { VerifyAIRequestError } from '../client';
|
|
23
24
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
@@ -35,6 +36,11 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
|
35
36
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
36
37
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
37
38
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
39
|
+
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
40
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
41
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
42
|
+
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
43
|
+
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
38
44
|
|
|
39
45
|
export interface VerifyAIScannerProps {
|
|
40
46
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -61,6 +67,8 @@ export interface VerifyAIScannerProps {
|
|
|
61
67
|
enableTorch?: boolean;
|
|
62
68
|
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
63
69
|
telemetry?: TelemetryReporter | null;
|
|
70
|
+
/** Optional host-app context attached to scanner telemetry. */
|
|
71
|
+
telemetryContext?: ScannerTelemetryContext;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
type ErrorWithDetails = Error & {
|
|
@@ -74,6 +82,8 @@ type ErrorWithDetails = Error & {
|
|
|
74
82
|
};
|
|
75
83
|
};
|
|
76
84
|
|
|
85
|
+
type ScannerTelemetryMetadata = Record<string, string | number>;
|
|
86
|
+
|
|
77
87
|
function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
|
|
78
88
|
const id = policy?.toLowerCase() ?? '';
|
|
79
89
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -125,15 +135,50 @@ function createScannerError(message: string, code: string, name: string): ErrorW
|
|
|
125
135
|
return error;
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
function sleep(ms: number): Promise<void> {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
|
|
143
|
+
const message = error.message.toLowerCase();
|
|
144
|
+
return (
|
|
145
|
+
error.code === CAMERA_CAPTURE_ERROR_CODE ||
|
|
146
|
+
error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
|
|
147
|
+
message === 'failed to capture photo' ||
|
|
148
|
+
message === 'failed to capture image' ||
|
|
149
|
+
message === 'image could not be captured'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
function normalizeScannerError(err: unknown): ErrorWithDetails {
|
|
129
154
|
const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
|
|
130
|
-
if (!error.code && error
|
|
155
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
131
156
|
error.name = 'CameraCaptureError';
|
|
132
157
|
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
133
158
|
}
|
|
134
159
|
return error;
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
function compactTelemetryMetadata(
|
|
163
|
+
metadata: Record<string, string | number | boolean | null | undefined>,
|
|
164
|
+
): ScannerTelemetryMetadata {
|
|
165
|
+
const compacted: ScannerTelemetryMetadata = {};
|
|
166
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
167
|
+
if (value === null || value === undefined) continue;
|
|
168
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
169
|
+
}
|
|
170
|
+
return compacted;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getPlatformConstantString(...keys: string[]): string | null {
|
|
174
|
+
const constants = Platform.constants as Record<string, unknown>;
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const value = constants[key];
|
|
177
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
137
182
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
138
183
|
if (!error) {
|
|
139
184
|
return {
|
|
@@ -230,6 +275,7 @@ export function VerifyAIScanner({
|
|
|
230
275
|
captureRef,
|
|
231
276
|
enableTorch,
|
|
232
277
|
telemetry: telemetryProp,
|
|
278
|
+
telemetryContext,
|
|
233
279
|
}: VerifyAIScannerProps) {
|
|
234
280
|
const contextTelemetry = useTelemetry();
|
|
235
281
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
@@ -255,6 +301,24 @@ export function VerifyAIScanner({
|
|
|
255
301
|
const cameraEverReadyRef = useRef(false);
|
|
256
302
|
const cameraInitFailedRef = useRef(false);
|
|
257
303
|
const permissionDeniedTrackedRef = useRef(false);
|
|
304
|
+
const lastCameraReadyAtRef = useRef<string | null>(null);
|
|
305
|
+
const lastAppStateRemountAtRef = useRef<string | null>(null);
|
|
306
|
+
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
307
|
+
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
308
|
+
const cameraRemountCountRef = useRef(0);
|
|
309
|
+
const androidOrientationSubscriptionActiveRef = useRef(false);
|
|
310
|
+
const androidOrientationEventCountRef = useRef(0);
|
|
311
|
+
const androidOrientationChangeCountRef = useRef(0);
|
|
312
|
+
const androidOrientationStartedAtRef = useRef<string | null>(null);
|
|
313
|
+
const lastAndroidOrientationEventAtRef = useRef<string | null>(null);
|
|
314
|
+
const lastAndroidOrientationTelemetryAtRef = useRef(0);
|
|
315
|
+
const lastAndroidOrientationXRef = useRef<number | null>(null);
|
|
316
|
+
const lastAndroidOrientationYRef = useRef<number | null>(null);
|
|
317
|
+
const lastAndroidOrientationZRef = useRef<number | null>(null);
|
|
318
|
+
const lastAndroidOrientationDerivedRef = useRef<string | null>(null);
|
|
319
|
+
const lastAndroidOrientationIgnoredReasonRef = useRef<string | null>(null);
|
|
320
|
+
const lastAndroidOrientationErrorAtRef = useRef<string | null>(null);
|
|
321
|
+
const lastAndroidOrientationErrorRef = useRef<string | null>(null);
|
|
258
322
|
|
|
259
323
|
// Track dimensions for orientation detection and responsive layout
|
|
260
324
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -270,12 +334,119 @@ export function VerifyAIScanner({
|
|
|
270
334
|
'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight'
|
|
271
335
|
>('portrait');
|
|
272
336
|
|
|
337
|
+
const overlayRotationDeg = (() => {
|
|
338
|
+
switch (physicalOrientation) {
|
|
339
|
+
case 'landscapeLeft':
|
|
340
|
+
return 90;
|
|
341
|
+
case 'landscapeRight':
|
|
342
|
+
return -90;
|
|
343
|
+
case 'portraitUpsideDown':
|
|
344
|
+
return 180;
|
|
345
|
+
case 'portrait':
|
|
346
|
+
default:
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
})();
|
|
350
|
+
|
|
351
|
+
const buildScannerTelemetryMetadata = useCallback((
|
|
352
|
+
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
353
|
+
): ScannerTelemetryMetadata => compactTelemetryMetadata({
|
|
354
|
+
policy,
|
|
355
|
+
status,
|
|
356
|
+
camera_ready: cameraReadyRef.current,
|
|
357
|
+
camera_ever_ready: cameraEverReadyRef.current,
|
|
358
|
+
camera_init_failed: cameraInitFailedRef.current,
|
|
359
|
+
camera_key: cameraKey,
|
|
360
|
+
camera_remount_count: cameraRemountCountRef.current,
|
|
361
|
+
terminated,
|
|
362
|
+
exhausted,
|
|
363
|
+
app_state: AppState.currentState,
|
|
364
|
+
sdk_platform: Platform.OS,
|
|
365
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
366
|
+
device_os_version: String(Platform.Version),
|
|
367
|
+
route_name: telemetryContext?.routeName,
|
|
368
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
369
|
+
window_width: windowWidth,
|
|
370
|
+
window_height: windowHeight,
|
|
371
|
+
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
372
|
+
physical_orientation: physicalOrientation,
|
|
373
|
+
overlay_rotation_deg: overlayRotationDeg,
|
|
374
|
+
last_camera_ready_at: lastCameraReadyAtRef.current,
|
|
375
|
+
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
376
|
+
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
377
|
+
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
378
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
379
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
380
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
381
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
382
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
383
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
384
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
385
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
386
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
387
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
388
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
389
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
390
|
+
...extra,
|
|
391
|
+
}), [
|
|
392
|
+
cameraKey,
|
|
393
|
+
exhausted,
|
|
394
|
+
isLandscape,
|
|
395
|
+
overlayRotationDeg,
|
|
396
|
+
physicalOrientation,
|
|
397
|
+
policy,
|
|
398
|
+
status,
|
|
399
|
+
telemetryContext?.isPortraitLocked,
|
|
400
|
+
telemetryContext?.routeName,
|
|
401
|
+
terminated,
|
|
402
|
+
windowHeight,
|
|
403
|
+
windowWidth,
|
|
404
|
+
]);
|
|
405
|
+
|
|
273
406
|
useEffect(() => {
|
|
274
407
|
if (Platform.OS !== 'android') return;
|
|
408
|
+
|
|
275
409
|
let subscription: { remove: () => void } | null = null;
|
|
276
410
|
let cancelled = false;
|
|
411
|
+
let noEventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
277
412
|
let lastOrientation: typeof physicalOrientation = 'portrait';
|
|
278
413
|
|
|
414
|
+
const androidMetadata = (
|
|
415
|
+
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
416
|
+
) => compactTelemetryMetadata({
|
|
417
|
+
policy,
|
|
418
|
+
sdk_platform: Platform.OS,
|
|
419
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
420
|
+
device_os_version: String(Platform.Version),
|
|
421
|
+
route_name: telemetryContext?.routeName,
|
|
422
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
423
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
424
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
425
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
426
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
427
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
428
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
429
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
430
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
431
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
432
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
433
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
434
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
435
|
+
...extra,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const trackAndroidOrientationEvent = (
|
|
439
|
+
eventType: string,
|
|
440
|
+
error: unknown,
|
|
441
|
+
metadata: Record<string, string | number | boolean | null | undefined> = {},
|
|
442
|
+
) => {
|
|
443
|
+
telemetry?.track(eventType, {
|
|
444
|
+
component: 'scanner',
|
|
445
|
+
error,
|
|
446
|
+
metadata: androidMetadata(metadata),
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
|
|
279
450
|
(async () => {
|
|
280
451
|
let Accelerometer: {
|
|
281
452
|
setUpdateInterval: (ms: number) => void;
|
|
@@ -287,47 +458,102 @@ export function VerifyAIScanner({
|
|
|
287
458
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
288
459
|
const mod = require('expo-sensors');
|
|
289
460
|
Accelerometer = mod?.Accelerometer ?? null;
|
|
290
|
-
} catch {
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const error = normalizeScannerError(err);
|
|
463
|
+
lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
|
|
464
|
+
lastAndroidOrientationErrorRef.current = error.message;
|
|
465
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (cancelled) return;
|
|
469
|
+
if (!Accelerometer) {
|
|
470
|
+
trackAndroidOrientationEvent(
|
|
471
|
+
'camera_android_accelerometer_start_failure',
|
|
472
|
+
'expo-sensors Accelerometer is unavailable',
|
|
473
|
+
);
|
|
291
474
|
return;
|
|
292
475
|
}
|
|
293
|
-
|
|
476
|
+
|
|
477
|
+
const startedAt = new Date().toISOString();
|
|
478
|
+
androidOrientationStartedAtRef.current = startedAt;
|
|
479
|
+
androidOrientationSubscriptionActiveRef.current = true;
|
|
480
|
+
androidOrientationEventCountRef.current = 0;
|
|
481
|
+
androidOrientationChangeCountRef.current = 0;
|
|
482
|
+
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
483
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
484
|
+
accelerometer_update_interval_ms: 500,
|
|
485
|
+
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
noEventTimer = setTimeout(() => {
|
|
489
|
+
if (cancelled || androidOrientationEventCountRef.current > 0) return;
|
|
490
|
+
trackAndroidOrientationEvent(
|
|
491
|
+
'camera_android_accelerometer_no_events',
|
|
492
|
+
`No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`,
|
|
493
|
+
);
|
|
494
|
+
}, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
|
|
294
495
|
|
|
295
496
|
Accelerometer.setUpdateInterval(500);
|
|
296
|
-
subscription = Accelerometer.addListener(({ x, y }) => {
|
|
297
|
-
|
|
497
|
+
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
498
|
+
const at = new Date();
|
|
499
|
+
const atIso = at.toISOString();
|
|
500
|
+
androidOrientationEventCountRef.current++;
|
|
501
|
+
lastAndroidOrientationEventAtRef.current = atIso;
|
|
502
|
+
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
503
|
+
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
504
|
+
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
505
|
+
if (noEventTimer) {
|
|
506
|
+
clearTimeout(noEventTimer);
|
|
507
|
+
noEventTimer = null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let next: typeof physicalOrientation | null = null;
|
|
511
|
+
let ignoredReason: string | null = null;
|
|
298
512
|
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
299
513
|
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
300
514
|
} else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
301
515
|
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
302
516
|
} else {
|
|
303
|
-
|
|
517
|
+
ignoredReason = 'ambiguous_tilt';
|
|
304
518
|
}
|
|
305
|
-
|
|
519
|
+
|
|
520
|
+
lastAndroidOrientationDerivedRef.current = next;
|
|
521
|
+
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
522
|
+
|
|
523
|
+
if (next && next !== lastOrientation) {
|
|
524
|
+
const previous = lastOrientation;
|
|
306
525
|
lastOrientation = next;
|
|
526
|
+
androidOrientationChangeCountRef.current++;
|
|
307
527
|
setPhysicalOrientation(next);
|
|
528
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
529
|
+
previous_physical_orientation: previous,
|
|
530
|
+
next_physical_orientation: next,
|
|
531
|
+
accelerometer_sampled_at: atIso,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const shouldTrackSample =
|
|
537
|
+
androidOrientationEventCountRef.current === 1 ||
|
|
538
|
+
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
539
|
+
if (shouldTrackSample) {
|
|
540
|
+
lastAndroidOrientationTelemetryAtRef.current = at.getTime();
|
|
541
|
+
trackAndroidOrientationEvent(
|
|
542
|
+
'camera_android_accelerometer_sample',
|
|
543
|
+
`accelerometer_sample_${androidOrientationEventCountRef.current}`,
|
|
544
|
+
{ accelerometer_sampled_at: atIso },
|
|
545
|
+
);
|
|
308
546
|
}
|
|
309
547
|
});
|
|
310
548
|
})();
|
|
311
549
|
|
|
312
550
|
return () => {
|
|
313
551
|
cancelled = true;
|
|
552
|
+
androidOrientationSubscriptionActiveRef.current = false;
|
|
553
|
+
if (noEventTimer) clearTimeout(noEventTimer);
|
|
314
554
|
subscription?.remove();
|
|
315
555
|
};
|
|
316
|
-
}, []);
|
|
317
|
-
|
|
318
|
-
const overlayRotationDeg = (() => {
|
|
319
|
-
switch (physicalOrientation) {
|
|
320
|
-
case 'landscapeLeft':
|
|
321
|
-
return 90;
|
|
322
|
-
case 'landscapeRight':
|
|
323
|
-
return -90;
|
|
324
|
-
case 'portraitUpsideDown':
|
|
325
|
-
return 180;
|
|
326
|
-
case 'portrait':
|
|
327
|
-
default:
|
|
328
|
-
return 0;
|
|
329
|
-
}
|
|
330
|
-
})();
|
|
556
|
+
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
331
557
|
|
|
332
558
|
// Detect orientation changes and remount camera after rotation settles.
|
|
333
559
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
@@ -340,9 +566,16 @@ export function VerifyAIScanner({
|
|
|
340
566
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
341
567
|
|
|
342
568
|
if (orientationChanged && !terminated) {
|
|
569
|
+
const at = new Date().toISOString();
|
|
570
|
+
lastOrientationRemountAtRef.current = at;
|
|
571
|
+
cameraRemountCountRef.current++;
|
|
343
572
|
telemetry?.track('camera_orientation_remount', {
|
|
344
573
|
component: 'scanner',
|
|
345
574
|
metadata: {
|
|
575
|
+
...buildScannerTelemetryMetadata({
|
|
576
|
+
remount_reason: 'orientation_change',
|
|
577
|
+
remount_requested_at: at,
|
|
578
|
+
}),
|
|
346
579
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
347
580
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
348
581
|
},
|
|
@@ -357,7 +590,7 @@ export function VerifyAIScanner({
|
|
|
357
590
|
}, 400);
|
|
358
591
|
return () => clearTimeout(timer);
|
|
359
592
|
}
|
|
360
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
593
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
361
594
|
|
|
362
595
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
363
596
|
useEffect(() => {
|
|
@@ -365,10 +598,17 @@ export function VerifyAIScanner({
|
|
|
365
598
|
|
|
366
599
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
367
600
|
if (nextState === 'active') {
|
|
601
|
+
const at = new Date().toISOString();
|
|
602
|
+
lastAppStateRemountAtRef.current = at;
|
|
603
|
+
cameraRemountCountRef.current++;
|
|
368
604
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
369
605
|
// its preview layer after returning from the notification bar or control center.
|
|
370
606
|
telemetry?.track('camera_appstate_remount', {
|
|
371
607
|
component: 'scanner',
|
|
608
|
+
metadata: buildScannerTelemetryMetadata({
|
|
609
|
+
remount_reason: 'appstate_active',
|
|
610
|
+
remount_requested_at: at,
|
|
611
|
+
}),
|
|
372
612
|
});
|
|
373
613
|
setCameraReady(false);
|
|
374
614
|
cameraReadyRef.current = false;
|
|
@@ -377,7 +617,7 @@ export function VerifyAIScanner({
|
|
|
377
617
|
});
|
|
378
618
|
|
|
379
619
|
return () => subscription.remove();
|
|
380
|
-
}, [terminated]);
|
|
620
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
381
621
|
|
|
382
622
|
const pausePreview = useCallback(() => {
|
|
383
623
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -438,6 +678,7 @@ export function VerifyAIScanner({
|
|
|
438
678
|
|
|
439
679
|
// Camera init callbacks
|
|
440
680
|
const onCameraReady = useCallback(() => {
|
|
681
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
441
682
|
setCameraReady(true);
|
|
442
683
|
cameraReadyRef.current = true;
|
|
443
684
|
cameraEverReadyRef.current = true;
|
|
@@ -456,16 +697,23 @@ export function VerifyAIScanner({
|
|
|
456
697
|
telemetry?.track('camera_init_failure', {
|
|
457
698
|
component: 'scanner',
|
|
458
699
|
error,
|
|
700
|
+
metadata: buildScannerTelemetryMetadata({
|
|
701
|
+
mount_error_message: event.message,
|
|
702
|
+
}),
|
|
459
703
|
});
|
|
460
|
-
}, [onError, telemetry]);
|
|
704
|
+
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
461
705
|
|
|
462
|
-
// Startup watchdog — if camera hasn't fired onCameraReady
|
|
706
|
+
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
463
707
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
464
708
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
465
709
|
// recreate the CameraView, which starts a fresh native session.
|
|
466
710
|
useEffect(() => {
|
|
467
711
|
if (!permission?.granted || terminated) return;
|
|
468
712
|
|
|
713
|
+
const watchdogMs = Platform.OS === 'android'
|
|
714
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
715
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
716
|
+
|
|
469
717
|
const timer = setTimeout(() => {
|
|
470
718
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
471
719
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -474,19 +722,24 @@ export function VerifyAIScanner({
|
|
|
474
722
|
// surfacing new information. If the camera truly stays broken the
|
|
475
723
|
// user will see capture failures, which is a more meaningful signal.
|
|
476
724
|
if (!cameraEverReadyRef.current) {
|
|
725
|
+
cameraRemountCountRef.current++;
|
|
477
726
|
telemetry?.track('camera_preview_timeout', {
|
|
478
727
|
component: 'scanner',
|
|
479
|
-
error:
|
|
728
|
+
error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
|
|
729
|
+
metadata: buildScannerTelemetryMetadata({
|
|
730
|
+
watchdog_ms: watchdogMs,
|
|
731
|
+
remount_reason: 'startup_watchdog',
|
|
732
|
+
}),
|
|
480
733
|
});
|
|
481
734
|
setCameraReady(false);
|
|
482
735
|
cameraReadyRef.current = false;
|
|
483
736
|
setCameraKey((k) => k + 1);
|
|
484
737
|
}
|
|
485
738
|
}
|
|
486
|
-
},
|
|
739
|
+
}, watchdogMs);
|
|
487
740
|
|
|
488
741
|
return () => clearTimeout(timer);
|
|
489
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
742
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
490
743
|
|
|
491
744
|
// Track permission denied
|
|
492
745
|
useEffect(() => {
|
|
@@ -497,13 +750,39 @@ export function VerifyAIScanner({
|
|
|
497
750
|
!permissionDeniedTrackedRef.current
|
|
498
751
|
) {
|
|
499
752
|
permissionDeniedTrackedRef.current = true;
|
|
500
|
-
telemetry?.track('camera_permission_denied', {
|
|
753
|
+
telemetry?.track('camera_permission_denied', {
|
|
754
|
+
component: 'scanner',
|
|
755
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
756
|
+
});
|
|
501
757
|
}
|
|
502
|
-
}, [permission, telemetry]);
|
|
758
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
503
759
|
|
|
504
760
|
const handleCapture = useCallback(async () => {
|
|
505
|
-
|
|
506
|
-
|
|
761
|
+
const blockedReason =
|
|
762
|
+
!cameraRef.current
|
|
763
|
+
? 'camera_ref_null'
|
|
764
|
+
: !cameraReadyRef.current
|
|
765
|
+
? 'camera_not_ready'
|
|
766
|
+
: status === 'capturing'
|
|
767
|
+
? 'already_capturing'
|
|
768
|
+
: status === 'processing'
|
|
769
|
+
? 'already_processing'
|
|
770
|
+
: terminated
|
|
771
|
+
? 'terminated'
|
|
772
|
+
: exhausted
|
|
773
|
+
? 'exhausted'
|
|
774
|
+
: null;
|
|
775
|
+
|
|
776
|
+
telemetry?.track('camera_capture_request', {
|
|
777
|
+
component: 'scanner',
|
|
778
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
779
|
+
metadata: buildScannerTelemetryMetadata({
|
|
780
|
+
capture_blocked_reason: blockedReason,
|
|
781
|
+
}),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (blockedReason && blockedReason !== 'camera_not_ready') return;
|
|
785
|
+
if (blockedReason === 'camera_not_ready') {
|
|
507
786
|
const error = createScannerError(
|
|
508
787
|
'Camera is not ready yet. Please wait a moment and try again.',
|
|
509
788
|
CAMERA_NOT_READY_ERROR_CODE,
|
|
@@ -516,6 +795,7 @@ export function VerifyAIScanner({
|
|
|
516
795
|
telemetry?.track('camera_not_ready', {
|
|
517
796
|
component: 'scanner',
|
|
518
797
|
error,
|
|
798
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
519
799
|
});
|
|
520
800
|
setTimeout(() => setStatus('idle'), 2000);
|
|
521
801
|
return;
|
|
@@ -531,7 +811,24 @@ export function VerifyAIScanner({
|
|
|
531
811
|
terminalResultTimerRef.current = null;
|
|
532
812
|
}
|
|
533
813
|
|
|
814
|
+
let nativeCaptureAttempts = 0;
|
|
815
|
+
let captureRetryAttempted = false;
|
|
816
|
+
let captureRetryReady = false;
|
|
817
|
+
let lastNativeCaptureErrorMessage: string | null = null;
|
|
818
|
+
|
|
534
819
|
try {
|
|
820
|
+
const capturePhysicalOrientation = physicalOrientation;
|
|
821
|
+
const captureOverlayRotationDeg = overlayRotationDeg;
|
|
822
|
+
telemetry?.track('camera_capture_orientation_context', {
|
|
823
|
+
component: 'scanner',
|
|
824
|
+
error: 'capture_orientation_context',
|
|
825
|
+
metadata: buildScannerTelemetryMetadata({
|
|
826
|
+
capture_physical_orientation: capturePhysicalOrientation,
|
|
827
|
+
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
828
|
+
capture_rotation_applied: 0,
|
|
829
|
+
}),
|
|
830
|
+
});
|
|
831
|
+
|
|
535
832
|
// --- Capture + best-effort resize ---
|
|
536
833
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
537
834
|
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
@@ -551,12 +848,85 @@ export function VerifyAIScanner({
|
|
|
551
848
|
// Not installed — fall back to camera-only base64 below
|
|
552
849
|
}
|
|
553
850
|
|
|
851
|
+
const waitForCameraReady = async (timeoutMs: number): Promise<boolean> => {
|
|
852
|
+
const start = Date.now();
|
|
853
|
+
while (Date.now() - start < timeoutMs) {
|
|
854
|
+
if (cameraReadyRef.current && cameraRef.current) {
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
await sleep(100);
|
|
858
|
+
}
|
|
859
|
+
return cameraReadyRef.current && !!cameraRef.current;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const takePictureWithRetry = async (
|
|
863
|
+
options: { base64?: boolean; quality?: number; exif?: boolean },
|
|
864
|
+
requiredField: 'uri' | 'base64',
|
|
865
|
+
) => {
|
|
866
|
+
let lastError: ErrorWithDetails | null = null;
|
|
867
|
+
|
|
868
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
869
|
+
nativeCaptureAttempts = attempt;
|
|
870
|
+
try {
|
|
871
|
+
const photo = await cameraRef.current?.takePictureAsync(options);
|
|
872
|
+
if (!photo?.[requiredField]) {
|
|
873
|
+
throw createScannerError(
|
|
874
|
+
'Failed to capture photo',
|
|
875
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
876
|
+
'CameraCaptureError',
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return photo;
|
|
880
|
+
} catch (captureErr) {
|
|
881
|
+
const normalized = normalizeScannerError(captureErr);
|
|
882
|
+
lastError = normalized;
|
|
883
|
+
lastNativeCaptureErrorMessage = normalized.message;
|
|
884
|
+
|
|
885
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
886
|
+
captureRetryAttempted = true;
|
|
887
|
+
const retryAt = new Date().toISOString();
|
|
888
|
+
lastCaptureRetryAtRef.current = retryAt;
|
|
889
|
+
cameraRemountCountRef.current++;
|
|
890
|
+
setCameraReady(false);
|
|
891
|
+
cameraReadyRef.current = false;
|
|
892
|
+
setCameraKey((key) => key + 1);
|
|
893
|
+
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
894
|
+
captureRetryReady = await waitForCameraReady(2500);
|
|
895
|
+
telemetry?.track('camera_capture_retry', {
|
|
896
|
+
component: 'scanner',
|
|
897
|
+
error: normalized,
|
|
898
|
+
errorCode: normalized.code,
|
|
899
|
+
metadata: buildScannerTelemetryMetadata({
|
|
900
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
901
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
902
|
+
capture_retry_ready: captureRetryReady,
|
|
903
|
+
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
904
|
+
capture_retry_ready_timeout_ms: 2500,
|
|
905
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
906
|
+
}),
|
|
907
|
+
});
|
|
908
|
+
if (captureRetryReady) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
throw normalized;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
throw lastError ?? createScannerError(
|
|
918
|
+
'Failed to capture photo',
|
|
919
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
920
|
+
'CameraCaptureError',
|
|
921
|
+
);
|
|
922
|
+
};
|
|
923
|
+
|
|
554
924
|
if (ImageManipulator) {
|
|
555
925
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
556
|
-
const photo = await
|
|
926
|
+
const photo = await takePictureWithRetry({
|
|
557
927
|
quality: 0.8,
|
|
558
928
|
exif: false,
|
|
559
|
-
});
|
|
929
|
+
}, 'uri');
|
|
560
930
|
|
|
561
931
|
if (!photo?.uri) {
|
|
562
932
|
throw createScannerError(
|
|
@@ -600,11 +970,11 @@ export function VerifyAIScanner({
|
|
|
600
970
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
601
971
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
602
972
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
603
|
-
const photo = await
|
|
973
|
+
const photo = await takePictureWithRetry({
|
|
604
974
|
base64: true,
|
|
605
975
|
quality: FALLBACK_QUALITY,
|
|
606
976
|
exif: false,
|
|
607
|
-
});
|
|
977
|
+
}, 'base64');
|
|
608
978
|
|
|
609
979
|
if (!photo?.base64) {
|
|
610
980
|
throw createScannerError(
|
|
@@ -624,14 +994,19 @@ export function VerifyAIScanner({
|
|
|
624
994
|
// Best-effort telemetry — never blocks capture
|
|
625
995
|
telemetry?.track('image_processed', {
|
|
626
996
|
component: 'scanner',
|
|
627
|
-
metadata: {
|
|
997
|
+
metadata: buildScannerTelemetryMetadata({
|
|
628
998
|
original_width: origWidth,
|
|
629
999
|
original_height: origHeight,
|
|
630
1000
|
processed_width: processedWidth,
|
|
631
1001
|
processed_height: processedHeight,
|
|
632
1002
|
resized: didResize ? 1 : 0,
|
|
633
1003
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
634
|
-
|
|
1004
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
1005
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
1006
|
+
capture_retry_ready: captureRetryReady,
|
|
1007
|
+
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
1008
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1009
|
+
}),
|
|
635
1010
|
});
|
|
636
1011
|
|
|
637
1012
|
setStatus('processing');
|
|
@@ -699,7 +1074,17 @@ export function VerifyAIScanner({
|
|
|
699
1074
|
isCaptureFail ? 'capture_failure'
|
|
700
1075
|
: isImageFail ? 'image_manipulation_failure'
|
|
701
1076
|
: 'unknown_error',
|
|
702
|
-
{
|
|
1077
|
+
{
|
|
1078
|
+
component: 'scanner',
|
|
1079
|
+
error,
|
|
1080
|
+
metadata: buildScannerTelemetryMetadata({
|
|
1081
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
1082
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
1083
|
+
capture_retry_ready: captureRetryReady,
|
|
1084
|
+
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
1085
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1086
|
+
}),
|
|
1087
|
+
},
|
|
703
1088
|
);
|
|
704
1089
|
}
|
|
705
1090
|
|
|
@@ -707,7 +1092,7 @@ export function VerifyAIScanner({
|
|
|
707
1092
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
708
1093
|
}
|
|
709
1094
|
}
|
|
710
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
1095
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
711
1096
|
|
|
712
1097
|
// Expose capture to parent via ref
|
|
713
1098
|
if (captureRef) {
|
|
@@ -753,6 +1138,7 @@ export function VerifyAIScanner({
|
|
|
753
1138
|
<Text
|
|
754
1139
|
style={[
|
|
755
1140
|
styles.titleText,
|
|
1141
|
+
overlay.theme?.titleStyle,
|
|
756
1142
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
757
1143
|
]}
|
|
758
1144
|
>
|
|
@@ -788,6 +1174,7 @@ export function VerifyAIScanner({
|
|
|
788
1174
|
<Text
|
|
789
1175
|
style={[
|
|
790
1176
|
styles.guideCaptionText,
|
|
1177
|
+
overlay.theme?.feedbackStyle,
|
|
791
1178
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
792
1179
|
]}
|
|
793
1180
|
>
|
|
@@ -801,7 +1188,7 @@ export function VerifyAIScanner({
|
|
|
801
1188
|
{status === 'processing' && (
|
|
802
1189
|
<View style={styles.processingOverlay}>
|
|
803
1190
|
<ActivityIndicator size="large" color="#fff" />
|
|
804
|
-
<Text style={styles.statusText}>
|
|
1191
|
+
<Text style={[styles.statusText, overlay?.theme?.feedbackStyle]}>
|
|
805
1192
|
{overlay?.processingMessage || 'Analyzing photo...'}
|
|
806
1193
|
</Text>
|
|
807
1194
|
</View>
|
|
@@ -846,7 +1233,7 @@ export function VerifyAIScanner({
|
|
|
846
1233
|
: (overlay?.failureMessage || 'Not Verified')}
|
|
847
1234
|
</Text>
|
|
848
1235
|
</View>
|
|
849
|
-
<Text style={styles.feedbackText}>{result.feedback}</Text>
|
|
1236
|
+
<Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{result.feedback}</Text>
|
|
850
1237
|
{overlay?.showTerminalActionButton !== false && onResult && (
|
|
851
1238
|
<TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
|
|
852
1239
|
<Text style={styles.cardButtonText}>
|
|
@@ -893,7 +1280,7 @@ export function VerifyAIScanner({
|
|
|
893
1280
|
{errorTitle}
|
|
894
1281
|
</Text>
|
|
895
1282
|
</View>
|
|
896
|
-
<Text style={styles.feedbackText}>{errorMessage}</Text>
|
|
1283
|
+
<Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{errorMessage}</Text>
|
|
897
1284
|
{showCloseAction && (
|
|
898
1285
|
<TouchableOpacity style={styles.cardButton} onPress={handleClose}>
|
|
899
1286
|
<Text style={styles.cardButtonText}>Close</Text>
|
|
@@ -909,6 +1296,7 @@ export function VerifyAIScanner({
|
|
|
909
1296
|
<Text
|
|
910
1297
|
style={[
|
|
911
1298
|
styles.instructionsText,
|
|
1299
|
+
overlay.theme?.feedbackStyle,
|
|
912
1300
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
913
1301
|
]}
|
|
914
1302
|
>
|