@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.
@@ -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.message === 'Failed to capture photo') {
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
- if (cancelled || !Accelerometer) return;
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
- let next: typeof physicalOrientation;
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
- return; // ambiguous tilt — ignore
517
+ ignoredReason = 'ambiguous_tilt';
304
518
  }
305
- if (next !== lastOrientation) {
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 within 3s, force remount.
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: 'Camera did not initialize within 3 seconds — remounting',
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
- }, 3000);
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', { component: 'scanner' });
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
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
506
- if (!cameraReadyRef.current) {
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 cameraRef.current.takePictureAsync({
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 cameraRef.current.takePictureAsync({
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
- { component: 'scanner', error },
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
  >