@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,15 +1,95 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
3
3
  import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState, Platform, useWindowDimensions, } from 'react-native';
4
4
  import { CameraView, useCameraPermissions, } from 'expo-camera';
5
5
  import { VerifyAIRequestError } from '../client';
6
6
  import { useTelemetry } from '../telemetry/TelemetryContext';
7
+ import { BikeOverlay } from './BikeOverlay';
8
+ import { ScooterOverlay } from './ScooterOverlay';
7
9
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
8
10
  const FALLBACK_QUALITY = 0.65;
9
11
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
10
12
  const MANIPULATOR_QUALITY = 0.8;
11
13
  /** Max dimension (px) on longest side when resize is available. */
12
14
  const MAX_DIMENSION = 1600;
15
+ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
16
+ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
17
+ const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
18
+ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
19
+ const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
20
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
21
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
22
+ function getPolicyScannerDefaults(policy) {
23
+ const id = policy?.toLowerCase() ?? '';
24
+ const isForest = id.includes('forest') || id.includes('humanforest');
25
+ const isBike = isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
26
+ const isScooter = id.includes('scooter');
27
+ if (!isBike && !isScooter)
28
+ return null;
29
+ const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
30
+ return {
31
+ title: 'End Ride Photo',
32
+ instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
33
+ showGuideFrame: true,
34
+ guideFrameAspectRatio: 16 / 9,
35
+ guideOverlayContent: isScooter ? _jsx(ScooterOverlay, {}) : _jsx(BikeOverlay, {}),
36
+ guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
37
+ processingMessage: 'Checking parking compliance...',
38
+ failureMessage: 'Parking issue detected',
39
+ retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
40
+ };
41
+ }
42
+ function applyPolicyDefaults(overlay, policy) {
43
+ const defaults = getPolicyScannerDefaults(policy);
44
+ if (!defaults)
45
+ return overlay;
46
+ return {
47
+ ...overlay,
48
+ title: overlay?.title ?? defaults.title,
49
+ instructions: overlay?.instructions ?? defaults.instructions,
50
+ showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
51
+ guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
52
+ guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
53
+ guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
54
+ processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
55
+ failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
56
+ retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
57
+ };
58
+ }
59
+ function createScannerError(message, code, name) {
60
+ const error = new Error(message);
61
+ error.name = name;
62
+ error.code = code;
63
+ return error;
64
+ }
65
+ function sleep(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ function isNativeCameraCaptureError(error) {
69
+ const message = error.message.toLowerCase();
70
+ return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
71
+ error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
72
+ message === 'failed to capture photo' ||
73
+ message === 'failed to capture image' ||
74
+ message === 'image could not be captured');
75
+ }
76
+ function normalizeScannerError(err) {
77
+ const error = (err instanceof Error ? err : new Error(String(err)));
78
+ if (!error.code && isNativeCameraCaptureError(error)) {
79
+ error.name = 'CameraCaptureError';
80
+ error.code = CAMERA_CAPTURE_ERROR_CODE;
81
+ }
82
+ return error;
83
+ }
84
+ function compactTelemetryMetadata(metadata) {
85
+ const compacted = {};
86
+ for (const [key, value] of Object.entries(metadata)) {
87
+ if (value === null || value === undefined)
88
+ continue;
89
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
90
+ }
91
+ return compacted;
92
+ }
13
93
  function getErrorDisplay(error, showTechnicalDetails) {
14
94
  if (!error) {
15
95
  return {
@@ -27,9 +107,18 @@ function getErrorDisplay(error, showTechnicalDetails) {
27
107
  else if (code === 'network_error' || status === 0) {
28
108
  message = 'Network request failed. Check your connection and try again.';
29
109
  }
110
+ else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
111
+ message = 'The camera had trouble taking a photo. Please try again.';
112
+ }
113
+ else if (code === CAMERA_NOT_READY_ERROR_CODE) {
114
+ message = 'Camera is not ready yet. Please wait a moment and try again.';
115
+ }
30
116
  else if (status === 401) {
31
117
  message = 'Verification is not configured correctly.';
32
118
  }
119
+ else if (status === 403) {
120
+ message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
121
+ }
33
122
  else if (status === 413) {
34
123
  message = 'Image is too large. Please try again — the photo will be resized automatically.';
35
124
  }
@@ -53,6 +142,14 @@ function getErrorDisplay(error, showTechnicalDetails) {
53
142
  message,
54
143
  };
55
144
  }
145
+ function isTerminalRequestError(error) {
146
+ const status = error.status ?? error.body?.status;
147
+ return (status === 400 ||
148
+ status === 401 ||
149
+ status === 403 ||
150
+ status === 422 ||
151
+ (status !== undefined && status >= 500));
152
+ }
56
153
  /**
57
154
  * Camera scanner component for capturing verification photos.
58
155
  * Uses expo-camera for the camera view and provides a simple capture UI.
@@ -74,9 +171,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
74
171
  * />
75
172
  * ```
76
173
  */
77
- export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, telemetry: telemetryProp, }) {
174
+ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
78
175
  const contextTelemetry = useTelemetry();
79
176
  const telemetry = telemetryProp ?? contextTelemetry;
177
+ const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
80
178
  const cameraRef = useRef(null);
81
179
  const [status, setStatus] = useState('idle');
82
180
  const [result, setResult] = useState(null);
@@ -87,10 +185,18 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
87
185
  const [terminated, setTerminated] = useState(false);
88
186
  const [cameraReady, setCameraReady] = useState(false);
89
187
  const [cameraKey, setCameraKey] = useState(0);
188
+ const terminalResultRef = useRef(null);
189
+ const terminalResultTimerRef = useRef(null);
190
+ const terminalResultDeliveredRef = useRef(false);
90
191
  const cameraReadyRef = useRef(false);
91
192
  const cameraEverReadyRef = useRef(false);
92
193
  const cameraInitFailedRef = useRef(false);
93
194
  const permissionDeniedTrackedRef = useRef(false);
195
+ const lastCameraReadyAtRef = useRef(null);
196
+ const lastAppStateRemountAtRef = useRef(null);
197
+ const lastOrientationRemountAtRef = useRef(null);
198
+ const lastCaptureRetryAtRef = useRef(null);
199
+ const cameraRemountCountRef = useRef(0);
94
200
  // Track dimensions for orientation detection and responsive layout
95
201
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
96
202
  const isLandscape = windowWidth > windowHeight;
@@ -155,6 +261,40 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
155
261
  return 0;
156
262
  }
157
263
  })();
264
+ const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
265
+ policy,
266
+ status,
267
+ camera_ready: cameraReadyRef.current,
268
+ camera_ever_ready: cameraEverReadyRef.current,
269
+ camera_init_failed: cameraInitFailedRef.current,
270
+ camera_key: cameraKey,
271
+ camera_remount_count: cameraRemountCountRef.current,
272
+ terminated,
273
+ exhausted,
274
+ app_state: AppState.currentState,
275
+ sdk_platform: Platform.OS,
276
+ window_width: windowWidth,
277
+ window_height: windowHeight,
278
+ interface_orientation: isLandscape ? 'landscape' : 'portrait',
279
+ physical_orientation: physicalOrientation,
280
+ overlay_rotation_deg: overlayRotationDeg,
281
+ last_camera_ready_at: lastCameraReadyAtRef.current,
282
+ last_appstate_remount_at: lastAppStateRemountAtRef.current,
283
+ last_orientation_remount_at: lastOrientationRemountAtRef.current,
284
+ last_capture_retry_at: lastCaptureRetryAtRef.current,
285
+ ...extra,
286
+ }), [
287
+ cameraKey,
288
+ exhausted,
289
+ isLandscape,
290
+ overlayRotationDeg,
291
+ physicalOrientation,
292
+ policy,
293
+ status,
294
+ terminated,
295
+ windowHeight,
296
+ windowWidth,
297
+ ]);
158
298
  // Detect orientation changes and remount camera after rotation settles.
159
299
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
160
300
  // animation — the native preview layer initializes with transitional bounds.
@@ -164,9 +304,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
164
304
  const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
165
305
  prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
166
306
  if (orientationChanged && !terminated) {
307
+ const at = new Date().toISOString();
308
+ lastOrientationRemountAtRef.current = at;
309
+ cameraRemountCountRef.current++;
167
310
  telemetry?.track('camera_orientation_remount', {
168
311
  component: 'scanner',
169
312
  metadata: {
313
+ ...buildScannerTelemetryMetadata({
314
+ remount_reason: 'orientation_change',
315
+ remount_requested_at: at,
316
+ }),
170
317
  from: prev.width > prev.height ? 'landscape' : 'portrait',
171
318
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
172
319
  },
@@ -180,17 +327,24 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
180
327
  }, 400);
181
328
  return () => clearTimeout(timer);
182
329
  }
183
- }, [windowWidth, windowHeight, terminated]);
330
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
184
331
  // Resume camera when app returns from background/inactive (e.g. notification bar)
185
332
  useEffect(() => {
186
333
  if (terminated)
187
334
  return;
188
335
  const subscription = AppState.addEventListener('change', (nextState) => {
189
336
  if (nextState === 'active') {
337
+ const at = new Date().toISOString();
338
+ lastAppStateRemountAtRef.current = at;
339
+ cameraRemountCountRef.current++;
190
340
  // Force camera remount — on iOS, AVCaptureSession often fails to resume
191
341
  // its preview layer after returning from the notification bar or control center.
192
342
  telemetry?.track('camera_appstate_remount', {
193
343
  component: 'scanner',
344
+ metadata: buildScannerTelemetryMetadata({
345
+ remount_reason: 'appstate_active',
346
+ remount_requested_at: at,
347
+ }),
194
348
  });
195
349
  setCameraReady(false);
196
350
  cameraReadyRef.current = false;
@@ -198,7 +352,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
198
352
  }
199
353
  });
200
354
  return () => subscription.remove();
201
- }, [terminated]);
355
+ }, [terminated, buildScannerTelemetryMetadata, telemetry]);
202
356
  const pausePreview = useCallback(() => {
203
357
  cameraRef.current?.pausePreview?.().catch(() => { });
204
358
  }, []);
@@ -208,10 +362,51 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
208
362
  pausePreview();
209
363
  }, [pausePreview]);
210
364
  useEffect(() => {
211
- return pausePreview;
365
+ return () => {
366
+ pausePreview();
367
+ if (terminalResultTimerRef.current) {
368
+ clearTimeout(terminalResultTimerRef.current);
369
+ terminalResultTimerRef.current = null;
370
+ }
371
+ };
212
372
  }, [pausePreview]);
373
+ const deliverTerminalResult = useCallback((nextResult) => {
374
+ if (terminalResultDeliveredRef.current)
375
+ return;
376
+ terminalResultDeliveredRef.current = true;
377
+ if (terminalResultTimerRef.current) {
378
+ clearTimeout(terminalResultTimerRef.current);
379
+ terminalResultTimerRef.current = null;
380
+ }
381
+ onResult?.(nextResult);
382
+ }, [onResult]);
383
+ const scheduleTerminalResult = useCallback((nextResult) => {
384
+ terminalResultRef.current = nextResult;
385
+ terminalResultDeliveredRef.current = false;
386
+ if (!onResult)
387
+ return;
388
+ if (terminalResultTimerRef.current) {
389
+ clearTimeout(terminalResultTimerRef.current);
390
+ }
391
+ terminalResultTimerRef.current = setTimeout(() => {
392
+ deliverTerminalResult(nextResult);
393
+ }, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
394
+ }, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
395
+ const deliverVisibleTerminalResult = useCallback(() => {
396
+ const current = terminalResultRef.current ?? result;
397
+ if (current)
398
+ deliverTerminalResult(current);
399
+ }, [deliverTerminalResult, result]);
400
+ const handleClose = useCallback(() => {
401
+ if (terminalResultTimerRef.current) {
402
+ clearTimeout(terminalResultTimerRef.current);
403
+ terminalResultTimerRef.current = null;
404
+ }
405
+ onClose?.(terminalResultRef.current ?? result);
406
+ }, [onClose, result]);
213
407
  // Camera init callbacks
214
408
  const onCameraReady = useCallback(() => {
409
+ lastCameraReadyAtRef.current = new Date().toISOString();
215
410
  setCameraReady(true);
216
411
  cameraReadyRef.current = true;
217
412
  cameraEverReadyRef.current = true;
@@ -229,15 +424,21 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
229
424
  telemetry?.track('camera_init_failure', {
230
425
  component: 'scanner',
231
426
  error,
427
+ metadata: buildScannerTelemetryMetadata({
428
+ mount_error_message: event.message,
429
+ }),
232
430
  });
233
- }, [onError, telemetry]);
234
- // Startup watchdog — if camera hasn't fired onCameraReady within 3s, force remount.
431
+ }, [buildScannerTelemetryMetadata, onError, telemetry]);
432
+ // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
235
433
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
236
434
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
237
435
  // recreate the CameraView, which starts a fresh native session.
238
436
  useEffect(() => {
239
437
  if (!permission?.granted || terminated)
240
438
  return;
439
+ const watchdogMs = Platform.OS === 'android'
440
+ ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
441
+ : IOS_CAMERA_STARTUP_WATCHDOG_MS;
241
442
  const timer = setTimeout(() => {
242
443
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
243
444
  // Only track + remount on first-ever mount. A rotation/app-resume
@@ -246,18 +447,23 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
246
447
  // surfacing new information. If the camera truly stays broken the
247
448
  // user will see capture failures, which is a more meaningful signal.
248
449
  if (!cameraEverReadyRef.current) {
450
+ cameraRemountCountRef.current++;
249
451
  telemetry?.track('camera_preview_timeout', {
250
452
  component: 'scanner',
251
- error: 'Camera did not initialize within 3 seconds — remounting',
453
+ error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
454
+ metadata: buildScannerTelemetryMetadata({
455
+ watchdog_ms: watchdogMs,
456
+ remount_reason: 'startup_watchdog',
457
+ }),
252
458
  });
253
459
  setCameraReady(false);
254
460
  cameraReadyRef.current = false;
255
461
  setCameraKey((k) => k + 1);
256
462
  }
257
463
  }
258
- }, 3000);
464
+ }, watchdogMs);
259
465
  return () => clearTimeout(timer);
260
- }, [permission?.granted, terminated, cameraKey, telemetry]);
466
+ }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
261
467
  // Track permission denied
262
468
  useEffect(() => {
263
469
  if (permission &&
@@ -265,14 +471,17 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
265
471
  permission.canAskAgain === false &&
266
472
  !permissionDeniedTrackedRef.current) {
267
473
  permissionDeniedTrackedRef.current = true;
268
- telemetry?.track('camera_permission_denied', { component: 'scanner' });
474
+ telemetry?.track('camera_permission_denied', {
475
+ component: 'scanner',
476
+ metadata: buildScannerTelemetryMetadata(),
477
+ });
269
478
  }
270
- }, [permission, telemetry]);
479
+ }, [buildScannerTelemetryMetadata, permission, telemetry]);
271
480
  const handleCapture = useCallback(async () => {
272
481
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
273
482
  return;
274
483
  if (!cameraReadyRef.current) {
275
- const error = new Error('Camera is not ready yet. Please wait a moment and try again.');
484
+ const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
276
485
  setResult(null);
277
486
  setLastError(error);
278
487
  setStatus('error');
@@ -280,6 +489,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
280
489
  telemetry?.track('camera_not_ready', {
281
490
  component: 'scanner',
282
491
  error,
492
+ metadata: buildScannerTelemetryMetadata(),
283
493
  });
284
494
  setTimeout(() => setStatus('idle'), 2000);
285
495
  return;
@@ -287,6 +497,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
287
497
  setStatus('capturing');
288
498
  setResult(null);
289
499
  setLastError(null);
500
+ terminalResultRef.current = null;
501
+ terminalResultDeliveredRef.current = false;
502
+ if (terminalResultTimerRef.current) {
503
+ clearTimeout(terminalResultTimerRef.current);
504
+ terminalResultTimerRef.current = null;
505
+ }
506
+ let nativeCaptureAttempts = 0;
507
+ let captureRetryAttempted = false;
508
+ let captureRetryReady = false;
509
+ let lastNativeCaptureErrorMessage = null;
290
510
  try {
291
511
  // --- Capture + best-effort resize ---
292
512
  // Strategy: try to dynamically import expo-image-manipulator.
@@ -306,14 +526,58 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
306
526
  catch {
307
527
  // Not installed — fall back to camera-only base64 below
308
528
  }
529
+ const waitForCameraReady = async (timeoutMs) => {
530
+ const start = Date.now();
531
+ while (Date.now() - start < timeoutMs) {
532
+ if (cameraReadyRef.current && cameraRef.current) {
533
+ return true;
534
+ }
535
+ await sleep(100);
536
+ }
537
+ return cameraReadyRef.current && !!cameraRef.current;
538
+ };
539
+ const takePictureWithRetry = async (options, requiredField) => {
540
+ let lastError = null;
541
+ for (let attempt = 1; attempt <= 2; attempt++) {
542
+ nativeCaptureAttempts = attempt;
543
+ try {
544
+ const photo = await cameraRef.current?.takePictureAsync(options);
545
+ if (!photo?.[requiredField]) {
546
+ throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
547
+ }
548
+ return photo;
549
+ }
550
+ catch (captureErr) {
551
+ const normalized = normalizeScannerError(captureErr);
552
+ lastError = normalized;
553
+ lastNativeCaptureErrorMessage = normalized.message;
554
+ if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
555
+ captureRetryAttempted = true;
556
+ const retryAt = new Date().toISOString();
557
+ lastCaptureRetryAtRef.current = retryAt;
558
+ cameraRemountCountRef.current++;
559
+ setCameraReady(false);
560
+ cameraReadyRef.current = false;
561
+ setCameraKey((key) => key + 1);
562
+ await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
563
+ captureRetryReady = await waitForCameraReady(2500);
564
+ if (captureRetryReady) {
565
+ continue;
566
+ }
567
+ }
568
+ throw normalized;
569
+ }
570
+ }
571
+ throw lastError ?? createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
572
+ };
309
573
  if (ImageManipulator) {
310
574
  // Capture without base64 — ImageManipulator will produce it after resize.
311
- const photo = await cameraRef.current.takePictureAsync({
575
+ const photo = await takePictureWithRetry({
312
576
  quality: 0.8,
313
577
  exif: false,
314
- });
578
+ }, 'uri');
315
579
  if (!photo?.uri) {
316
- throw new Error('Failed to capture photo');
580
+ throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
317
581
  }
318
582
  origWidth = photo.width ?? 0;
319
583
  origHeight = photo.height ?? 0;
@@ -343,13 +607,13 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
343
607
  // Fallback: capture base64 directly from the camera at reduced quality.
344
608
  // No resize is possible without ImageManipulator, but the lower quality
345
609
  // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
346
- const photo = await cameraRef.current.takePictureAsync({
610
+ const photo = await takePictureWithRetry({
347
611
  base64: true,
348
612
  quality: FALLBACK_QUALITY,
349
613
  exif: false,
350
- });
614
+ }, 'base64');
351
615
  if (!photo?.base64) {
352
- throw new Error('Failed to capture photo');
616
+ throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
353
617
  }
354
618
  origWidth = photo.width ?? 0;
355
619
  origHeight = photo.height ?? 0;
@@ -360,14 +624,19 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
360
624
  // Best-effort telemetry — never blocks capture
361
625
  telemetry?.track('image_processed', {
362
626
  component: 'scanner',
363
- metadata: {
627
+ metadata: buildScannerTelemetryMetadata({
364
628
  original_width: origWidth,
365
629
  original_height: origHeight,
366
630
  processed_width: processedWidth,
367
631
  processed_height: processedHeight,
368
632
  resized: didResize ? 1 : 0,
369
633
  has_manipulator: ImageManipulator ? 1 : 0,
370
- },
634
+ native_capture_attempts: nativeCaptureAttempts,
635
+ capture_retry_attempted: captureRetryAttempted,
636
+ capture_retry_ready: captureRetryReady,
637
+ recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
638
+ last_native_capture_error: lastNativeCaptureErrorMessage,
639
+ }),
371
640
  });
372
641
  setStatus('processing');
373
642
  const verificationResult = await onCapture(base64);
@@ -385,12 +654,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
385
654
  const approvedResult = { ...verificationResult, is_compliant: true };
386
655
  setResult(approvedResult);
387
656
  setStatus('success');
388
- onResult?.(approvedResult);
657
+ scheduleTerminalResult(approvedResult);
389
658
  }
390
659
  else {
391
660
  setResult(verificationResult);
392
661
  setStatus('error');
393
- onResult?.(verificationResult);
662
+ scheduleTerminalResult(verificationResult);
394
663
  }
395
664
  return;
396
665
  }
@@ -405,7 +674,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
405
674
  releaseCamera();
406
675
  setResult(verificationResult);
407
676
  setStatus('success');
408
- onResult?.(verificationResult);
677
+ scheduleTerminalResult(verificationResult);
409
678
  }
410
679
  else {
411
680
  // null result means queued for offline
@@ -413,7 +682,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
413
682
  }
414
683
  }
415
684
  catch (err) {
416
- const error = (err instanceof Error ? err : new Error(String(err)));
685
+ const error = normalizeScannerError(err);
686
+ const terminalRequestError = isTerminalRequestError(error);
687
+ if (terminalRequestError) {
688
+ releaseCamera();
689
+ }
690
+ setResult(null);
417
691
  setLastError(error);
418
692
  setStatus('error');
419
693
  onError?.(error);
@@ -424,12 +698,23 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
424
698
  const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
425
699
  telemetry?.track(isCaptureFail ? 'capture_failure'
426
700
  : isImageFail ? 'image_manipulation_failure'
427
- : 'unknown_error', { component: 'scanner', error });
701
+ : 'unknown_error', {
702
+ component: 'scanner',
703
+ error,
704
+ metadata: buildScannerTelemetryMetadata({
705
+ native_capture_attempts: nativeCaptureAttempts,
706
+ capture_retry_attempted: captureRetryAttempted,
707
+ capture_retry_ready: captureRetryReady,
708
+ is_native_camera_capture_error: isNativeCameraCaptureError(error),
709
+ last_native_capture_error: lastNativeCaptureErrorMessage,
710
+ }),
711
+ });
712
+ }
713
+ if (!terminalRequestError) {
714
+ setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
428
715
  }
429
- // Reset after a brief pause
430
- setTimeout(() => setStatus('idle'), 2000);
431
716
  }
432
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
717
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
433
718
  // Expose capture to parent via ref
434
719
  if (captureRef) {
435
720
  captureRef.current = handleCapture;
@@ -473,9 +758,10 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
473
758
  ? (overlay?.exhaustedMessage || 'Submitted for review')
474
759
  : result.is_compliant
475
760
  ? (overlay?.successMessage || 'Verified')
476
- : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
761
+ : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
477
762
  let errorTitle;
478
763
  let errorMessage;
764
+ let showCloseAction = false;
479
765
  if (exhausted) {
480
766
  errorTitle = 'Attempts Exhausted';
481
767
  errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
@@ -497,8 +783,9 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
497
783
  const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
498
784
  errorTitle = display.title;
499
785
  errorMessage = display.message;
786
+ showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
500
787
  }
501
- return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
788
+ return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
502
789
  })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
503
790
  styles.instructionsText,
504
791
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
@@ -507,7 +794,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
507
794
  overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
508
795
  (!cameraReady || status === 'capturing' || status === 'processing') &&
509
796
  styles.captureButtonDisabled,
510
- ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] })] }) }, cameraKey) }));
797
+ ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey) }));
511
798
  }
512
799
  const CORNER_SIZE = 30;
513
800
  const CORNER_THICKNESS = 3;
@@ -715,6 +1002,36 @@ const styles = StyleSheet.create({
715
1002
  textAlign: 'center',
716
1003
  lineHeight: 22,
717
1004
  },
1005
+ cardButton: {
1006
+ alignSelf: 'stretch',
1007
+ backgroundColor: '#111827',
1008
+ borderRadius: 8,
1009
+ paddingVertical: 12,
1010
+ paddingHorizontal: 16,
1011
+ alignItems: 'center',
1012
+ marginTop: 8,
1013
+ },
1014
+ cardButtonText: {
1015
+ color: '#fff',
1016
+ fontSize: 16,
1017
+ fontWeight: '700',
1018
+ },
1019
+ closeButton: {
1020
+ position: 'absolute',
1021
+ top: 52,
1022
+ right: 16,
1023
+ width: 44,
1024
+ height: 44,
1025
+ borderRadius: 22,
1026
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
1027
+ justifyContent: 'center',
1028
+ alignItems: 'center',
1029
+ },
1030
+ closeButtonText: {
1031
+ color: '#fff',
1032
+ fontSize: 18,
1033
+ fontWeight: '700',
1034
+ },
718
1035
  // Permission screen
719
1036
  permissionContainer: {
720
1037
  justifyContent: 'center',
@@ -122,6 +122,12 @@ export interface ScannerOverlayConfig {
122
122
  maxAttempts?: number;
123
123
  autoApproveOnExhaust?: boolean;
124
124
  showTechnicalErrorDetails?: boolean;
125
+ /** Delay before terminal results are reported through onResult. Default: 3000. */
126
+ terminalResultDisplayMs?: number;
127
+ /** Label for the terminal result action button. Default: "Continue". */
128
+ terminalActionLabel?: string;
129
+ /** Whether to show an action button on terminal result cards. Default: true. */
130
+ showTerminalActionButton?: boolean;
125
131
  /** Custom theme for scanner colors. */
126
132
  theme?: ScannerTheme;
127
133
  }
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.11";
1
+ export declare const SDK_VERSION = "2.4.15";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.11';
1
+ export const SDK_VERSION = '2.4.15';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.11",
3
+ "version": "2.4.15",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",