@switchlabs/verify-ai-react-native 2.4.11 → 2.4.15

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.
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState, useCallback, useEffect } from 'react';
1
+ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -22,6 +22,8 @@ import type {
22
22
  import { VerifyAIRequestError } from '../client';
23
23
  import { useTelemetry } from '../telemetry/TelemetryContext';
24
24
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
25
+ import { BikeOverlay } from './BikeOverlay';
26
+ import { ScooterOverlay } from './ScooterOverlay';
25
27
 
26
28
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
27
29
  const FALLBACK_QUALITY = 0.65;
@@ -29,20 +31,33 @@ const FALLBACK_QUALITY = 0.65;
29
31
  const MANIPULATOR_QUALITY = 0.8;
30
32
  /** Max dimension (px) on longest side when resize is available. */
31
33
  const MAX_DIMENSION = 1600;
34
+ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
35
+ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
36
+ const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
37
+ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
38
+ const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
39
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
40
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
32
41
 
33
42
  export interface VerifyAIScannerProps {
34
43
  /** Called with base64 image data when the user captures a photo. */
35
44
  onCapture: (base64: string) => Promise<VerificationResult | null>;
45
+ /** Optional policy ID used for policy-aware default scanner copy. */
46
+ policy?: string;
36
47
  /** Called when a terminal verification result is reached. */
37
48
  onResult?: (result: VerificationResult) => void;
38
49
  /** Called when an error occurs. */
39
50
  onError?: (error: Error) => void;
51
+ /** Called when the user presses the persistent close button. */
52
+ onClose?: (result: VerificationResult | null) => void;
40
53
  /** Overlay configuration for the camera view. */
41
54
  overlay?: ScannerOverlayConfig;
42
55
  /** Custom style for the container. */
43
56
  style?: ViewStyle;
44
57
  /** Whether to show the default capture button. Set false to use your own. */
45
58
  showCaptureButton?: boolean;
59
+ /** Whether to show the persistent close button. */
60
+ showCloseButton?: boolean;
46
61
  /** Ref to imperatively trigger capture from parent. */
47
62
  captureRef?: React.MutableRefObject<(() => void) | null>;
48
63
  /** Whether to enable the camera torch/flashlight. */
@@ -62,6 +77,94 @@ type ErrorWithDetails = Error & {
62
77
  };
63
78
  };
64
79
 
80
+ type ScannerTelemetryMetadata = Record<string, string | number>;
81
+
82
+ function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
83
+ const id = policy?.toLowerCase() ?? '';
84
+ const isForest = id.includes('forest') || id.includes('humanforest');
85
+ const isBike =
86
+ isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
87
+ const isScooter = id.includes('scooter');
88
+
89
+ if (!isBike && !isScooter) return null;
90
+
91
+ const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
92
+ return {
93
+ title: 'End Ride Photo',
94
+ instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
95
+ showGuideFrame: true,
96
+ guideFrameAspectRatio: 16 / 9,
97
+ guideOverlayContent: isScooter ? <ScooterOverlay /> : <BikeOverlay />,
98
+ guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
99
+ processingMessage: 'Checking parking compliance...',
100
+ failureMessage: 'Parking issue detected',
101
+ retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
102
+ };
103
+ }
104
+
105
+ function applyPolicyDefaults(
106
+ overlay: ScannerOverlayConfig | undefined,
107
+ policy: string | undefined,
108
+ ): ScannerOverlayConfig | undefined {
109
+ const defaults = getPolicyScannerDefaults(policy);
110
+ if (!defaults) return overlay;
111
+
112
+ return {
113
+ ...overlay,
114
+ title: overlay?.title ?? defaults.title,
115
+ instructions: overlay?.instructions ?? defaults.instructions,
116
+ showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
117
+ guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
118
+ guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
119
+ guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
120
+ processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
121
+ failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
122
+ retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
123
+ };
124
+ }
125
+
126
+ function createScannerError(message: string, code: string, name: string): ErrorWithDetails {
127
+ const error = new Error(message) as ErrorWithDetails;
128
+ error.name = name;
129
+ error.code = code;
130
+ return error;
131
+ }
132
+
133
+ function sleep(ms: number): Promise<void> {
134
+ return new Promise((resolve) => setTimeout(resolve, ms));
135
+ }
136
+
137
+ function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
138
+ const message = error.message.toLowerCase();
139
+ return (
140
+ error.code === CAMERA_CAPTURE_ERROR_CODE ||
141
+ error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
142
+ message === 'failed to capture photo' ||
143
+ message === 'failed to capture image' ||
144
+ message === 'image could not be captured'
145
+ );
146
+ }
147
+
148
+ function normalizeScannerError(err: unknown): ErrorWithDetails {
149
+ const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
150
+ if (!error.code && isNativeCameraCaptureError(error)) {
151
+ error.name = 'CameraCaptureError';
152
+ error.code = CAMERA_CAPTURE_ERROR_CODE;
153
+ }
154
+ return error;
155
+ }
156
+
157
+ function compactTelemetryMetadata(
158
+ metadata: Record<string, string | number | boolean | null | undefined>,
159
+ ): ScannerTelemetryMetadata {
160
+ const compacted: ScannerTelemetryMetadata = {};
161
+ for (const [key, value] of Object.entries(metadata)) {
162
+ if (value === null || value === undefined) continue;
163
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
164
+ }
165
+ return compacted;
166
+ }
167
+
65
168
  function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
66
169
  if (!error) {
67
170
  return {
@@ -80,8 +183,14 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
80
183
  message = 'Verification timed out. Please try again.';
81
184
  } else if (code === 'network_error' || status === 0) {
82
185
  message = 'Network request failed. Check your connection and try again.';
186
+ } else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
187
+ message = 'The camera had trouble taking a photo. Please try again.';
188
+ } else if (code === CAMERA_NOT_READY_ERROR_CODE) {
189
+ message = 'Camera is not ready yet. Please wait a moment and try again.';
83
190
  } else if (status === 401) {
84
191
  message = 'Verification is not configured correctly.';
192
+ } else if (status === 403) {
193
+ message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
85
194
  } else if (status === 413) {
86
195
  message = 'Image is too large. Please try again — the photo will be resized automatically.';
87
196
  } else if (status === 429) {
@@ -107,6 +216,17 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
107
216
  };
108
217
  }
109
218
 
219
+ function isTerminalRequestError(error: ErrorWithDetails): boolean {
220
+ const status = error.status ?? error.body?.status;
221
+ return (
222
+ status === 400 ||
223
+ status === 401 ||
224
+ status === 403 ||
225
+ status === 422 ||
226
+ (status !== undefined && status >= 500)
227
+ );
228
+ }
229
+
110
230
  /**
111
231
  * Camera scanner component for capturing verification photos.
112
232
  * Uses expo-camera for the camera view and provides a simple capture UI.
@@ -130,17 +250,24 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
130
250
  */
131
251
  export function VerifyAIScanner({
132
252
  onCapture,
253
+ policy,
133
254
  onResult,
134
255
  onError,
135
- overlay,
256
+ onClose,
257
+ overlay: overlayProp,
136
258
  style,
137
259
  showCaptureButton = true,
260
+ showCloseButton = false,
138
261
  captureRef,
139
262
  enableTorch,
140
263
  telemetry: telemetryProp,
141
264
  }: VerifyAIScannerProps) {
142
265
  const contextTelemetry = useTelemetry();
143
266
  const telemetry = telemetryProp ?? contextTelemetry;
267
+ const overlay = useMemo(
268
+ () => applyPolicyDefaults(overlayProp, policy),
269
+ [overlayProp, policy],
270
+ );
144
271
 
145
272
  const cameraRef = useRef<CameraView>(null);
146
273
  const [status, setStatus] = useState<ScannerStatus>('idle');
@@ -152,10 +279,18 @@ export function VerifyAIScanner({
152
279
  const [terminated, setTerminated] = useState(false);
153
280
  const [cameraReady, setCameraReady] = useState(false);
154
281
  const [cameraKey, setCameraKey] = useState(0);
282
+ const terminalResultRef = useRef<VerificationResult | null>(null);
283
+ const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
284
+ const terminalResultDeliveredRef = useRef(false);
155
285
  const cameraReadyRef = useRef(false);
156
286
  const cameraEverReadyRef = useRef(false);
157
287
  const cameraInitFailedRef = useRef(false);
158
288
  const permissionDeniedTrackedRef = useRef(false);
289
+ const lastCameraReadyAtRef = useRef<string | null>(null);
290
+ const lastAppStateRemountAtRef = useRef<string | null>(null);
291
+ const lastOrientationRemountAtRef = useRef<string | null>(null);
292
+ const lastCaptureRetryAtRef = useRef<string | null>(null);
293
+ const cameraRemountCountRef = useRef(0);
159
294
 
160
295
  // Track dimensions for orientation detection and responsive layout
161
296
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -230,6 +365,43 @@ export function VerifyAIScanner({
230
365
  }
231
366
  })();
232
367
 
368
+ const buildScannerTelemetryMetadata = useCallback((
369
+ extra: Record<string, string | number | boolean | null | undefined> = {},
370
+ ): ScannerTelemetryMetadata => compactTelemetryMetadata({
371
+ policy,
372
+ status,
373
+ camera_ready: cameraReadyRef.current,
374
+ camera_ever_ready: cameraEverReadyRef.current,
375
+ camera_init_failed: cameraInitFailedRef.current,
376
+ camera_key: cameraKey,
377
+ camera_remount_count: cameraRemountCountRef.current,
378
+ terminated,
379
+ exhausted,
380
+ app_state: AppState.currentState,
381
+ sdk_platform: Platform.OS,
382
+ window_width: windowWidth,
383
+ window_height: windowHeight,
384
+ interface_orientation: isLandscape ? 'landscape' : 'portrait',
385
+ physical_orientation: physicalOrientation,
386
+ overlay_rotation_deg: overlayRotationDeg,
387
+ last_camera_ready_at: lastCameraReadyAtRef.current,
388
+ last_appstate_remount_at: lastAppStateRemountAtRef.current,
389
+ last_orientation_remount_at: lastOrientationRemountAtRef.current,
390
+ last_capture_retry_at: lastCaptureRetryAtRef.current,
391
+ ...extra,
392
+ }), [
393
+ cameraKey,
394
+ exhausted,
395
+ isLandscape,
396
+ overlayRotationDeg,
397
+ physicalOrientation,
398
+ policy,
399
+ status,
400
+ terminated,
401
+ windowHeight,
402
+ windowWidth,
403
+ ]);
404
+
233
405
  // Detect orientation changes and remount camera after rotation settles.
234
406
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
235
407
  // animation — the native preview layer initializes with transitional bounds.
@@ -241,9 +413,16 @@ export function VerifyAIScanner({
241
413
  prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
242
414
 
243
415
  if (orientationChanged && !terminated) {
416
+ const at = new Date().toISOString();
417
+ lastOrientationRemountAtRef.current = at;
418
+ cameraRemountCountRef.current++;
244
419
  telemetry?.track('camera_orientation_remount', {
245
420
  component: 'scanner',
246
421
  metadata: {
422
+ ...buildScannerTelemetryMetadata({
423
+ remount_reason: 'orientation_change',
424
+ remount_requested_at: at,
425
+ }),
247
426
  from: prev.width > prev.height ? 'landscape' : 'portrait',
248
427
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
249
428
  },
@@ -258,7 +437,7 @@ export function VerifyAIScanner({
258
437
  }, 400);
259
438
  return () => clearTimeout(timer);
260
439
  }
261
- }, [windowWidth, windowHeight, terminated]);
440
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
262
441
 
263
442
  // Resume camera when app returns from background/inactive (e.g. notification bar)
264
443
  useEffect(() => {
@@ -266,10 +445,17 @@ export function VerifyAIScanner({
266
445
 
267
446
  const subscription = AppState.addEventListener('change', (nextState) => {
268
447
  if (nextState === 'active') {
448
+ const at = new Date().toISOString();
449
+ lastAppStateRemountAtRef.current = at;
450
+ cameraRemountCountRef.current++;
269
451
  // Force camera remount — on iOS, AVCaptureSession often fails to resume
270
452
  // its preview layer after returning from the notification bar or control center.
271
453
  telemetry?.track('camera_appstate_remount', {
272
454
  component: 'scanner',
455
+ metadata: buildScannerTelemetryMetadata({
456
+ remount_reason: 'appstate_active',
457
+ remount_requested_at: at,
458
+ }),
273
459
  });
274
460
  setCameraReady(false);
275
461
  cameraReadyRef.current = false;
@@ -278,7 +464,7 @@ export function VerifyAIScanner({
278
464
  });
279
465
 
280
466
  return () => subscription.remove();
281
- }, [terminated]);
467
+ }, [terminated, buildScannerTelemetryMetadata, telemetry]);
282
468
 
283
469
  const pausePreview = useCallback(() => {
284
470
  cameraRef.current?.pausePreview?.().catch(() => {});
@@ -291,11 +477,55 @@ export function VerifyAIScanner({
291
477
  }, [pausePreview]);
292
478
 
293
479
  useEffect(() => {
294
- return pausePreview;
480
+ return () => {
481
+ pausePreview();
482
+ if (terminalResultTimerRef.current) {
483
+ clearTimeout(terminalResultTimerRef.current);
484
+ terminalResultTimerRef.current = null;
485
+ }
486
+ };
295
487
  }, [pausePreview]);
296
488
 
489
+ const deliverTerminalResult = useCallback((nextResult: VerificationResult) => {
490
+ if (terminalResultDeliveredRef.current) return;
491
+ terminalResultDeliveredRef.current = true;
492
+ if (terminalResultTimerRef.current) {
493
+ clearTimeout(terminalResultTimerRef.current);
494
+ terminalResultTimerRef.current = null;
495
+ }
496
+ onResult?.(nextResult);
497
+ }, [onResult]);
498
+
499
+ const scheduleTerminalResult = useCallback((nextResult: VerificationResult) => {
500
+ terminalResultRef.current = nextResult;
501
+ terminalResultDeliveredRef.current = false;
502
+ if (!onResult) return;
503
+
504
+ if (terminalResultTimerRef.current) {
505
+ clearTimeout(terminalResultTimerRef.current);
506
+ }
507
+
508
+ terminalResultTimerRef.current = setTimeout(() => {
509
+ deliverTerminalResult(nextResult);
510
+ }, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
511
+ }, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
512
+
513
+ const deliverVisibleTerminalResult = useCallback(() => {
514
+ const current = terminalResultRef.current ?? result;
515
+ if (current) deliverTerminalResult(current);
516
+ }, [deliverTerminalResult, result]);
517
+
518
+ const handleClose = useCallback(() => {
519
+ if (terminalResultTimerRef.current) {
520
+ clearTimeout(terminalResultTimerRef.current);
521
+ terminalResultTimerRef.current = null;
522
+ }
523
+ onClose?.(terminalResultRef.current ?? result);
524
+ }, [onClose, result]);
525
+
297
526
  // Camera init callbacks
298
527
  const onCameraReady = useCallback(() => {
528
+ lastCameraReadyAtRef.current = new Date().toISOString();
299
529
  setCameraReady(true);
300
530
  cameraReadyRef.current = true;
301
531
  cameraEverReadyRef.current = true;
@@ -314,16 +544,23 @@ export function VerifyAIScanner({
314
544
  telemetry?.track('camera_init_failure', {
315
545
  component: 'scanner',
316
546
  error,
547
+ metadata: buildScannerTelemetryMetadata({
548
+ mount_error_message: event.message,
549
+ }),
317
550
  });
318
- }, [onError, telemetry]);
551
+ }, [buildScannerTelemetryMetadata, onError, telemetry]);
319
552
 
320
- // Startup watchdog — if camera hasn't fired onCameraReady within 3s, force remount.
553
+ // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
321
554
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
322
555
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
323
556
  // recreate the CameraView, which starts a fresh native session.
324
557
  useEffect(() => {
325
558
  if (!permission?.granted || terminated) return;
326
559
 
560
+ const watchdogMs = Platform.OS === 'android'
561
+ ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
562
+ : IOS_CAMERA_STARTUP_WATCHDOG_MS;
563
+
327
564
  const timer = setTimeout(() => {
328
565
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
329
566
  // Only track + remount on first-ever mount. A rotation/app-resume
@@ -332,19 +569,24 @@ export function VerifyAIScanner({
332
569
  // surfacing new information. If the camera truly stays broken the
333
570
  // user will see capture failures, which is a more meaningful signal.
334
571
  if (!cameraEverReadyRef.current) {
572
+ cameraRemountCountRef.current++;
335
573
  telemetry?.track('camera_preview_timeout', {
336
574
  component: 'scanner',
337
- error: 'Camera did not initialize within 3 seconds — remounting',
575
+ error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
576
+ metadata: buildScannerTelemetryMetadata({
577
+ watchdog_ms: watchdogMs,
578
+ remount_reason: 'startup_watchdog',
579
+ }),
338
580
  });
339
581
  setCameraReady(false);
340
582
  cameraReadyRef.current = false;
341
583
  setCameraKey((k) => k + 1);
342
584
  }
343
585
  }
344
- }, 3000);
586
+ }, watchdogMs);
345
587
 
346
588
  return () => clearTimeout(timer);
347
- }, [permission?.granted, terminated, cameraKey, telemetry]);
589
+ }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
348
590
 
349
591
  // Track permission denied
350
592
  useEffect(() => {
@@ -355,14 +597,21 @@ export function VerifyAIScanner({
355
597
  !permissionDeniedTrackedRef.current
356
598
  ) {
357
599
  permissionDeniedTrackedRef.current = true;
358
- telemetry?.track('camera_permission_denied', { component: 'scanner' });
600
+ telemetry?.track('camera_permission_denied', {
601
+ component: 'scanner',
602
+ metadata: buildScannerTelemetryMetadata(),
603
+ });
359
604
  }
360
- }, [permission, telemetry]);
605
+ }, [buildScannerTelemetryMetadata, permission, telemetry]);
361
606
 
362
607
  const handleCapture = useCallback(async () => {
363
608
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
364
609
  if (!cameraReadyRef.current) {
365
- const error = new Error('Camera is not ready yet. Please wait a moment and try again.') as ErrorWithDetails;
610
+ const error = createScannerError(
611
+ 'Camera is not ready yet. Please wait a moment and try again.',
612
+ CAMERA_NOT_READY_ERROR_CODE,
613
+ 'CameraNotReadyError',
614
+ );
366
615
  setResult(null);
367
616
  setLastError(error);
368
617
  setStatus('error');
@@ -370,6 +619,7 @@ export function VerifyAIScanner({
370
619
  telemetry?.track('camera_not_ready', {
371
620
  component: 'scanner',
372
621
  error,
622
+ metadata: buildScannerTelemetryMetadata(),
373
623
  });
374
624
  setTimeout(() => setStatus('idle'), 2000);
375
625
  return;
@@ -378,6 +628,17 @@ export function VerifyAIScanner({
378
628
  setStatus('capturing');
379
629
  setResult(null);
380
630
  setLastError(null);
631
+ terminalResultRef.current = null;
632
+ terminalResultDeliveredRef.current = false;
633
+ if (terminalResultTimerRef.current) {
634
+ clearTimeout(terminalResultTimerRef.current);
635
+ terminalResultTimerRef.current = null;
636
+ }
637
+
638
+ let nativeCaptureAttempts = 0;
639
+ let captureRetryAttempted = false;
640
+ let captureRetryReady = false;
641
+ let lastNativeCaptureErrorMessage: string | null = null;
381
642
 
382
643
  try {
383
644
  // --- Capture + best-effort resize ---
@@ -399,15 +660,79 @@ export function VerifyAIScanner({
399
660
  // Not installed — fall back to camera-only base64 below
400
661
  }
401
662
 
663
+ const waitForCameraReady = async (timeoutMs: number): Promise<boolean> => {
664
+ const start = Date.now();
665
+ while (Date.now() - start < timeoutMs) {
666
+ if (cameraReadyRef.current && cameraRef.current) {
667
+ return true;
668
+ }
669
+ await sleep(100);
670
+ }
671
+ return cameraReadyRef.current && !!cameraRef.current;
672
+ };
673
+
674
+ const takePictureWithRetry = async (
675
+ options: { base64?: boolean; quality?: number; exif?: boolean },
676
+ requiredField: 'uri' | 'base64',
677
+ ) => {
678
+ let lastError: ErrorWithDetails | null = null;
679
+
680
+ for (let attempt = 1; attempt <= 2; attempt++) {
681
+ nativeCaptureAttempts = attempt;
682
+ try {
683
+ const photo = await cameraRef.current?.takePictureAsync(options);
684
+ if (!photo?.[requiredField]) {
685
+ throw createScannerError(
686
+ 'Failed to capture photo',
687
+ CAMERA_CAPTURE_ERROR_CODE,
688
+ 'CameraCaptureError',
689
+ );
690
+ }
691
+ return photo;
692
+ } catch (captureErr) {
693
+ const normalized = normalizeScannerError(captureErr);
694
+ lastError = normalized;
695
+ lastNativeCaptureErrorMessage = normalized.message;
696
+
697
+ if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
698
+ captureRetryAttempted = true;
699
+ const retryAt = new Date().toISOString();
700
+ lastCaptureRetryAtRef.current = retryAt;
701
+ cameraRemountCountRef.current++;
702
+ setCameraReady(false);
703
+ cameraReadyRef.current = false;
704
+ setCameraKey((key) => key + 1);
705
+ await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
706
+ captureRetryReady = await waitForCameraReady(2500);
707
+ if (captureRetryReady) {
708
+ continue;
709
+ }
710
+ }
711
+
712
+ throw normalized;
713
+ }
714
+ }
715
+
716
+ throw lastError ?? createScannerError(
717
+ 'Failed to capture photo',
718
+ CAMERA_CAPTURE_ERROR_CODE,
719
+ 'CameraCaptureError',
720
+ );
721
+ };
722
+
402
723
  if (ImageManipulator) {
403
724
  // Capture without base64 — ImageManipulator will produce it after resize.
404
- const photo = await cameraRef.current.takePictureAsync({
725
+ const photo = await takePictureWithRetry({
405
726
  quality: 0.8,
406
727
  exif: false,
407
- });
728
+ }, 'uri');
408
729
 
409
730
  if (!photo?.uri) {
410
- throw new Error('Failed to capture photo');
731
+ throw createScannerError(
732
+ 'Failed to capture photo',
733
+ CAMERA_CAPTURE_ERROR_CODE,
734
+ 'CameraCaptureError',
735
+ );
411
736
  }
412
737
 
413
738
  origWidth = photo.width ?? 0;
@@ -444,14 +769,18 @@ export function VerifyAIScanner({
444
769
  // Fallback: capture base64 directly from the camera at reduced quality.
445
770
  // No resize is possible without ImageManipulator, but the lower quality
446
771
  // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
447
- const photo = await cameraRef.current.takePictureAsync({
772
+ const photo = await takePictureWithRetry({
448
773
  base64: true,
449
774
  quality: FALLBACK_QUALITY,
450
775
  exif: false,
451
- });
776
+ }, 'base64');
452
777
 
453
778
  if (!photo?.base64) {
454
- throw new Error('Failed to capture photo');
779
+ throw createScannerError(
780
+ 'Failed to capture photo',
781
+ CAMERA_CAPTURE_ERROR_CODE,
782
+ 'CameraCaptureError',
783
+ );
455
784
  }
456
785
 
457
786
  origWidth = photo.width ?? 0;
@@ -464,14 +793,19 @@ export function VerifyAIScanner({
464
793
  // Best-effort telemetry — never blocks capture
465
794
  telemetry?.track('image_processed', {
466
795
  component: 'scanner',
467
- metadata: {
796
+ metadata: buildScannerTelemetryMetadata({
468
797
  original_width: origWidth,
469
798
  original_height: origHeight,
470
799
  processed_width: processedWidth,
471
800
  processed_height: processedHeight,
472
801
  resized: didResize ? 1 : 0,
473
802
  has_manipulator: ImageManipulator ? 1 : 0,
474
- },
803
+ native_capture_attempts: nativeCaptureAttempts,
804
+ capture_retry_attempted: captureRetryAttempted,
805
+ capture_retry_ready: captureRetryReady,
806
+ recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
807
+ last_native_capture_error: lastNativeCaptureErrorMessage,
808
+ }),
475
809
  });
476
810
 
477
811
  setStatus('processing');
@@ -493,11 +827,11 @@ export function VerifyAIScanner({
493
827
  const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
494
828
  setResult(approvedResult);
495
829
  setStatus('success');
496
- onResult?.(approvedResult);
830
+ scheduleTerminalResult(approvedResult);
497
831
  } else {
498
832
  setResult(verificationResult);
499
833
  setStatus('error');
500
- onResult?.(verificationResult);
834
+ scheduleTerminalResult(verificationResult);
501
835
  }
502
836
  return;
503
837
  }
@@ -514,13 +848,18 @@ export function VerifyAIScanner({
514
848
  releaseCamera();
515
849
  setResult(verificationResult);
516
850
  setStatus('success');
517
- onResult?.(verificationResult);
851
+ scheduleTerminalResult(verificationResult);
518
852
  } else {
519
853
  // null result means queued for offline
520
854
  setStatus('idle');
521
855
  }
522
856
  } catch (err) {
523
- const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
857
+ const error = normalizeScannerError(err);
858
+ const terminalRequestError = isTerminalRequestError(error);
859
+ if (terminalRequestError) {
860
+ releaseCamera();
861
+ }
862
+ setResult(null);
524
863
  setLastError(error);
525
864
  setStatus('error');
526
865
  onError?.(error);
@@ -534,14 +873,25 @@ export function VerifyAIScanner({
534
873
  isCaptureFail ? 'capture_failure'
535
874
  : isImageFail ? 'image_manipulation_failure'
536
875
  : 'unknown_error',
537
- { component: 'scanner', error },
876
+ {
877
+ component: 'scanner',
878
+ error,
879
+ metadata: buildScannerTelemetryMetadata({
880
+ native_capture_attempts: nativeCaptureAttempts,
881
+ capture_retry_attempted: captureRetryAttempted,
882
+ capture_retry_ready: captureRetryReady,
883
+ is_native_camera_capture_error: isNativeCameraCaptureError(error),
884
+ last_native_capture_error: lastNativeCaptureErrorMessage,
885
+ }),
886
+ },
538
887
  );
539
888
  }
540
889
 
541
- // Reset after a brief pause
542
- setTimeout(() => setStatus('idle'), 2000);
890
+ if (!terminalRequestError) {
891
+ setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
892
+ }
543
893
  }
544
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
894
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
545
895
 
546
896
  // Expose capture to parent via ref
547
897
  if (captureRef) {
@@ -681,12 +1031,20 @@ export function VerifyAIScanner({
681
1031
  </Text>
682
1032
  </View>
683
1033
  <Text style={styles.feedbackText}>{result.feedback}</Text>
1034
+ {overlay?.showTerminalActionButton !== false && onResult && (
1035
+ <TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
1036
+ <Text style={styles.cardButtonText}>
1037
+ {overlay?.terminalActionLabel || 'Continue'}
1038
+ </Text>
1039
+ </TouchableOpacity>
1040
+ )}
684
1041
  </View>
685
1042
  )}
686
1043
 
687
1044
  {status === 'error' && (() => {
688
1045
  let errorTitle: string;
689
1046
  let errorMessage: string;
1047
+ let showCloseAction = false;
690
1048
 
691
1049
  if (exhausted) {
692
1050
  errorTitle = 'Attempts Exhausted';
@@ -706,6 +1064,7 @@ export function VerifyAIScanner({
706
1064
  const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
707
1065
  errorTitle = display.title;
708
1066
  errorMessage = display.message;
1067
+ showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
709
1068
  }
710
1069
 
711
1070
  return (
@@ -718,9 +1077,12 @@ export function VerifyAIScanner({
718
1077
  {errorTitle}
719
1078
  </Text>
720
1079
  </View>
721
- <Text style={styles.feedbackText}>
722
- {errorMessage}
723
- </Text>
1080
+ <Text style={styles.feedbackText}>{errorMessage}</Text>
1081
+ {showCloseAction && (
1082
+ <TouchableOpacity style={styles.cardButton} onPress={handleClose}>
1083
+ <Text style={styles.cardButtonText}>Close</Text>
1084
+ </TouchableOpacity>
1085
+ )}
724
1086
  </View>
725
1087
  );
726
1088
  })()}
@@ -757,6 +1119,16 @@ export function VerifyAIScanner({
757
1119
  </>
758
1120
  )}
759
1121
  </View>
1122
+ {showCloseButton && onClose && (
1123
+ <TouchableOpacity
1124
+ accessibilityRole="button"
1125
+ accessibilityLabel="Close scanner"
1126
+ style={styles.closeButton}
1127
+ onPress={handleClose}
1128
+ >
1129
+ <Text style={styles.closeButtonText}>X</Text>
1130
+ </TouchableOpacity>
1131
+ )}
760
1132
  </View>
761
1133
  </CameraView>
762
1134
  </View>
@@ -968,15 +1340,45 @@ const styles = StyleSheet.create({
968
1340
  resultLabelExhausted: {
969
1341
  color: '#92400e',
970
1342
  },
971
- feedbackText: {
972
- color: '#4b5563',
973
- fontSize: 15,
974
- textAlign: 'center',
975
- lineHeight: 22,
976
- },
977
-
978
- // Permission screen
979
- permissionContainer: {
1343
+ feedbackText: {
1344
+ color: '#4b5563',
1345
+ fontSize: 15,
1346
+ textAlign: 'center',
1347
+ lineHeight: 22,
1348
+ },
1349
+ cardButton: {
1350
+ alignSelf: 'stretch',
1351
+ backgroundColor: '#111827',
1352
+ borderRadius: 8,
1353
+ paddingVertical: 12,
1354
+ paddingHorizontal: 16,
1355
+ alignItems: 'center',
1356
+ marginTop: 8,
1357
+ },
1358
+ cardButtonText: {
1359
+ color: '#fff',
1360
+ fontSize: 16,
1361
+ fontWeight: '700',
1362
+ },
1363
+ closeButton: {
1364
+ position: 'absolute',
1365
+ top: 52,
1366
+ right: 16,
1367
+ width: 44,
1368
+ height: 44,
1369
+ borderRadius: 22,
1370
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
1371
+ justifyContent: 'center',
1372
+ alignItems: 'center',
1373
+ },
1374
+ closeButtonText: {
1375
+ color: '#fff',
1376
+ fontSize: 18,
1377
+ fontWeight: '700',
1378
+ },
1379
+
1380
+ // Permission screen
1381
+ permissionContainer: {
980
1382
  justifyContent: 'center',
981
1383
  alignItems: 'center',
982
1384
  paddingHorizontal: 40,