@switchlabs/verify-ai-react-native 2.4.14 → 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.
@@ -16,6 +16,9 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
16
16
  const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
17
17
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
18
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;
19
22
  function getPolicyScannerDefaults(policy) {
20
23
  const id = policy?.toLowerCase() ?? '';
21
24
  const isForest = id.includes('forest') || id.includes('humanforest');
@@ -59,14 +62,34 @@ function createScannerError(message, code, name) {
59
62
  error.code = code;
60
63
  return error;
61
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
+ }
62
76
  function normalizeScannerError(err) {
63
77
  const error = (err instanceof Error ? err : new Error(String(err)));
64
- if (!error.code && error.message === 'Failed to capture photo') {
78
+ if (!error.code && isNativeCameraCaptureError(error)) {
65
79
  error.name = 'CameraCaptureError';
66
80
  error.code = CAMERA_CAPTURE_ERROR_CODE;
67
81
  }
68
82
  return error;
69
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
+ }
70
93
  function getErrorDisplay(error, showTechnicalDetails) {
71
94
  if (!error) {
72
95
  return {
@@ -169,6 +192,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
169
192
  const cameraEverReadyRef = useRef(false);
170
193
  const cameraInitFailedRef = useRef(false);
171
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);
172
200
  // Track dimensions for orientation detection and responsive layout
173
201
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
174
202
  const isLandscape = windowWidth > windowHeight;
@@ -233,6 +261,40 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
233
261
  return 0;
234
262
  }
235
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
+ ]);
236
298
  // Detect orientation changes and remount camera after rotation settles.
237
299
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
238
300
  // animation — the native preview layer initializes with transitional bounds.
@@ -242,9 +304,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
242
304
  const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
243
305
  prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
244
306
  if (orientationChanged && !terminated) {
307
+ const at = new Date().toISOString();
308
+ lastOrientationRemountAtRef.current = at;
309
+ cameraRemountCountRef.current++;
245
310
  telemetry?.track('camera_orientation_remount', {
246
311
  component: 'scanner',
247
312
  metadata: {
313
+ ...buildScannerTelemetryMetadata({
314
+ remount_reason: 'orientation_change',
315
+ remount_requested_at: at,
316
+ }),
248
317
  from: prev.width > prev.height ? 'landscape' : 'portrait',
249
318
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
250
319
  },
@@ -258,17 +327,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
258
327
  }, 400);
259
328
  return () => clearTimeout(timer);
260
329
  }
261
- }, [windowWidth, windowHeight, terminated]);
330
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
262
331
  // Resume camera when app returns from background/inactive (e.g. notification bar)
263
332
  useEffect(() => {
264
333
  if (terminated)
265
334
  return;
266
335
  const subscription = AppState.addEventListener('change', (nextState) => {
267
336
  if (nextState === 'active') {
337
+ const at = new Date().toISOString();
338
+ lastAppStateRemountAtRef.current = at;
339
+ cameraRemountCountRef.current++;
268
340
  // Force camera remount — on iOS, AVCaptureSession often fails to resume
269
341
  // its preview layer after returning from the notification bar or control center.
270
342
  telemetry?.track('camera_appstate_remount', {
271
343
  component: 'scanner',
344
+ metadata: buildScannerTelemetryMetadata({
345
+ remount_reason: 'appstate_active',
346
+ remount_requested_at: at,
347
+ }),
272
348
  });
273
349
  setCameraReady(false);
274
350
  cameraReadyRef.current = false;
@@ -276,7 +352,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
276
352
  }
277
353
  });
278
354
  return () => subscription.remove();
279
- }, [terminated]);
355
+ }, [terminated, buildScannerTelemetryMetadata, telemetry]);
280
356
  const pausePreview = useCallback(() => {
281
357
  cameraRef.current?.pausePreview?.().catch(() => { });
282
358
  }, []);
@@ -330,6 +406,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
330
406
  }, [onClose, result]);
331
407
  // Camera init callbacks
332
408
  const onCameraReady = useCallback(() => {
409
+ lastCameraReadyAtRef.current = new Date().toISOString();
333
410
  setCameraReady(true);
334
411
  cameraReadyRef.current = true;
335
412
  cameraEverReadyRef.current = true;
@@ -347,15 +424,21 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
347
424
  telemetry?.track('camera_init_failure', {
348
425
  component: 'scanner',
349
426
  error,
427
+ metadata: buildScannerTelemetryMetadata({
428
+ mount_error_message: event.message,
429
+ }),
350
430
  });
351
- }, [onError, telemetry]);
352
- // 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.
353
433
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
354
434
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
355
435
  // recreate the CameraView, which starts a fresh native session.
356
436
  useEffect(() => {
357
437
  if (!permission?.granted || terminated)
358
438
  return;
439
+ const watchdogMs = Platform.OS === 'android'
440
+ ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
441
+ : IOS_CAMERA_STARTUP_WATCHDOG_MS;
359
442
  const timer = setTimeout(() => {
360
443
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
361
444
  // Only track + remount on first-ever mount. A rotation/app-resume
@@ -364,18 +447,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
364
447
  // surfacing new information. If the camera truly stays broken the
365
448
  // user will see capture failures, which is a more meaningful signal.
366
449
  if (!cameraEverReadyRef.current) {
450
+ cameraRemountCountRef.current++;
367
451
  telemetry?.track('camera_preview_timeout', {
368
452
  component: 'scanner',
369
- 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
+ }),
370
458
  });
371
459
  setCameraReady(false);
372
460
  cameraReadyRef.current = false;
373
461
  setCameraKey((k) => k + 1);
374
462
  }
375
463
  }
376
- }, 3000);
464
+ }, watchdogMs);
377
465
  return () => clearTimeout(timer);
378
- }, [permission?.granted, terminated, cameraKey, telemetry]);
466
+ }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
379
467
  // Track permission denied
380
468
  useEffect(() => {
381
469
  if (permission &&
@@ -383,9 +471,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
383
471
  permission.canAskAgain === false &&
384
472
  !permissionDeniedTrackedRef.current) {
385
473
  permissionDeniedTrackedRef.current = true;
386
- telemetry?.track('camera_permission_denied', { component: 'scanner' });
474
+ telemetry?.track('camera_permission_denied', {
475
+ component: 'scanner',
476
+ metadata: buildScannerTelemetryMetadata(),
477
+ });
387
478
  }
388
- }, [permission, telemetry]);
479
+ }, [buildScannerTelemetryMetadata, permission, telemetry]);
389
480
  const handleCapture = useCallback(async () => {
390
481
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
391
482
  return;
@@ -398,6 +489,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
398
489
  telemetry?.track('camera_not_ready', {
399
490
  component: 'scanner',
400
491
  error,
492
+ metadata: buildScannerTelemetryMetadata(),
401
493
  });
402
494
  setTimeout(() => setStatus('idle'), 2000);
403
495
  return;
@@ -411,6 +503,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
411
503
  clearTimeout(terminalResultTimerRef.current);
412
504
  terminalResultTimerRef.current = null;
413
505
  }
506
+ let nativeCaptureAttempts = 0;
507
+ let captureRetryAttempted = false;
508
+ let captureRetryReady = false;
509
+ let lastNativeCaptureErrorMessage = null;
414
510
  try {
415
511
  // --- Capture + best-effort resize ---
416
512
  // Strategy: try to dynamically import expo-image-manipulator.
@@ -430,12 +526,56 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
430
526
  catch {
431
527
  // Not installed — fall back to camera-only base64 below
432
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
+ };
433
573
  if (ImageManipulator) {
434
574
  // Capture without base64 — ImageManipulator will produce it after resize.
435
- const photo = await cameraRef.current.takePictureAsync({
575
+ const photo = await takePictureWithRetry({
436
576
  quality: 0.8,
437
577
  exif: false,
438
- });
578
+ }, 'uri');
439
579
  if (!photo?.uri) {
440
580
  throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
441
581
  }
@@ -467,11 +607,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
467
607
  // Fallback: capture base64 directly from the camera at reduced quality.
468
608
  // No resize is possible without ImageManipulator, but the lower quality
469
609
  // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
470
- const photo = await cameraRef.current.takePictureAsync({
610
+ const photo = await takePictureWithRetry({
471
611
  base64: true,
472
612
  quality: FALLBACK_QUALITY,
473
613
  exif: false,
474
- });
614
+ }, 'base64');
475
615
  if (!photo?.base64) {
476
616
  throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
477
617
  }
@@ -484,14 +624,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
484
624
  // Best-effort telemetry — never blocks capture
485
625
  telemetry?.track('image_processed', {
486
626
  component: 'scanner',
487
- metadata: {
627
+ metadata: buildScannerTelemetryMetadata({
488
628
  original_width: origWidth,
489
629
  original_height: origHeight,
490
630
  processed_width: processedWidth,
491
631
  processed_height: processedHeight,
492
632
  resized: didResize ? 1 : 0,
493
633
  has_manipulator: ImageManipulator ? 1 : 0,
494
- },
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
+ }),
495
640
  });
496
641
  setStatus('processing');
497
642
  const verificationResult = await onCapture(base64);
@@ -553,13 +698,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
553
698
  const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
554
699
  telemetry?.track(isCaptureFail ? 'capture_failure'
555
700
  : isImageFail ? 'image_manipulation_failure'
556
- : '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
+ });
557
712
  }
558
713
  if (!terminalRequestError) {
559
714
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
560
715
  }
561
716
  }
562
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
717
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
563
718
  // Expose capture to parent via ref
564
719
  if (captureRef) {
565
720
  captureRef.current = handleCapture;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.14";
1
+ export declare const SDK_VERSION = "2.4.15";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.14';
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.14",
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",
@@ -35,6 +35,9 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
35
35
  const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
36
36
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
37
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;
38
41
 
39
42
  export interface VerifyAIScannerProps {
40
43
  /** Called with base64 image data when the user captures a photo. */
@@ -74,6 +77,8 @@ type ErrorWithDetails = Error & {
74
77
  };
75
78
  };
76
79
 
80
+ type ScannerTelemetryMetadata = Record<string, string | number>;
81
+
77
82
  function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
78
83
  const id = policy?.toLowerCase() ?? '';
79
84
  const isForest = id.includes('forest') || id.includes('humanforest');
@@ -125,15 +130,41 @@ function createScannerError(message: string, code: string, name: string): ErrorW
125
130
  return error;
126
131
  }
127
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
+
128
148
  function normalizeScannerError(err: unknown): ErrorWithDetails {
129
149
  const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
130
- if (!error.code && error.message === 'Failed to capture photo') {
150
+ if (!error.code && isNativeCameraCaptureError(error)) {
131
151
  error.name = 'CameraCaptureError';
132
152
  error.code = CAMERA_CAPTURE_ERROR_CODE;
133
153
  }
134
154
  return error;
135
155
  }
136
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
+
137
168
  function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
138
169
  if (!error) {
139
170
  return {
@@ -255,6 +286,11 @@ export function VerifyAIScanner({
255
286
  const cameraEverReadyRef = useRef(false);
256
287
  const cameraInitFailedRef = useRef(false);
257
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);
258
294
 
259
295
  // Track dimensions for orientation detection and responsive layout
260
296
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -329,6 +365,43 @@ export function VerifyAIScanner({
329
365
  }
330
366
  })();
331
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
+
332
405
  // Detect orientation changes and remount camera after rotation settles.
333
406
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
334
407
  // animation — the native preview layer initializes with transitional bounds.
@@ -340,9 +413,16 @@ export function VerifyAIScanner({
340
413
  prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
341
414
 
342
415
  if (orientationChanged && !terminated) {
416
+ const at = new Date().toISOString();
417
+ lastOrientationRemountAtRef.current = at;
418
+ cameraRemountCountRef.current++;
343
419
  telemetry?.track('camera_orientation_remount', {
344
420
  component: 'scanner',
345
421
  metadata: {
422
+ ...buildScannerTelemetryMetadata({
423
+ remount_reason: 'orientation_change',
424
+ remount_requested_at: at,
425
+ }),
346
426
  from: prev.width > prev.height ? 'landscape' : 'portrait',
347
427
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
348
428
  },
@@ -357,7 +437,7 @@ export function VerifyAIScanner({
357
437
  }, 400);
358
438
  return () => clearTimeout(timer);
359
439
  }
360
- }, [windowWidth, windowHeight, terminated]);
440
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
361
441
 
362
442
  // Resume camera when app returns from background/inactive (e.g. notification bar)
363
443
  useEffect(() => {
@@ -365,10 +445,17 @@ export function VerifyAIScanner({
365
445
 
366
446
  const subscription = AppState.addEventListener('change', (nextState) => {
367
447
  if (nextState === 'active') {
448
+ const at = new Date().toISOString();
449
+ lastAppStateRemountAtRef.current = at;
450
+ cameraRemountCountRef.current++;
368
451
  // Force camera remount — on iOS, AVCaptureSession often fails to resume
369
452
  // its preview layer after returning from the notification bar or control center.
370
453
  telemetry?.track('camera_appstate_remount', {
371
454
  component: 'scanner',
455
+ metadata: buildScannerTelemetryMetadata({
456
+ remount_reason: 'appstate_active',
457
+ remount_requested_at: at,
458
+ }),
372
459
  });
373
460
  setCameraReady(false);
374
461
  cameraReadyRef.current = false;
@@ -377,7 +464,7 @@ export function VerifyAIScanner({
377
464
  });
378
465
 
379
466
  return () => subscription.remove();
380
- }, [terminated]);
467
+ }, [terminated, buildScannerTelemetryMetadata, telemetry]);
381
468
 
382
469
  const pausePreview = useCallback(() => {
383
470
  cameraRef.current?.pausePreview?.().catch(() => {});
@@ -438,6 +525,7 @@ export function VerifyAIScanner({
438
525
 
439
526
  // Camera init callbacks
440
527
  const onCameraReady = useCallback(() => {
528
+ lastCameraReadyAtRef.current = new Date().toISOString();
441
529
  setCameraReady(true);
442
530
  cameraReadyRef.current = true;
443
531
  cameraEverReadyRef.current = true;
@@ -456,16 +544,23 @@ export function VerifyAIScanner({
456
544
  telemetry?.track('camera_init_failure', {
457
545
  component: 'scanner',
458
546
  error,
547
+ metadata: buildScannerTelemetryMetadata({
548
+ mount_error_message: event.message,
549
+ }),
459
550
  });
460
- }, [onError, telemetry]);
551
+ }, [buildScannerTelemetryMetadata, onError, telemetry]);
461
552
 
462
- // 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.
463
554
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
464
555
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
465
556
  // recreate the CameraView, which starts a fresh native session.
466
557
  useEffect(() => {
467
558
  if (!permission?.granted || terminated) return;
468
559
 
560
+ const watchdogMs = Platform.OS === 'android'
561
+ ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
562
+ : IOS_CAMERA_STARTUP_WATCHDOG_MS;
563
+
469
564
  const timer = setTimeout(() => {
470
565
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
471
566
  // Only track + remount on first-ever mount. A rotation/app-resume
@@ -474,19 +569,24 @@ export function VerifyAIScanner({
474
569
  // surfacing new information. If the camera truly stays broken the
475
570
  // user will see capture failures, which is a more meaningful signal.
476
571
  if (!cameraEverReadyRef.current) {
572
+ cameraRemountCountRef.current++;
477
573
  telemetry?.track('camera_preview_timeout', {
478
574
  component: 'scanner',
479
- 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
+ }),
480
580
  });
481
581
  setCameraReady(false);
482
582
  cameraReadyRef.current = false;
483
583
  setCameraKey((k) => k + 1);
484
584
  }
485
585
  }
486
- }, 3000);
586
+ }, watchdogMs);
487
587
 
488
588
  return () => clearTimeout(timer);
489
- }, [permission?.granted, terminated, cameraKey, telemetry]);
589
+ }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
490
590
 
491
591
  // Track permission denied
492
592
  useEffect(() => {
@@ -497,9 +597,12 @@ export function VerifyAIScanner({
497
597
  !permissionDeniedTrackedRef.current
498
598
  ) {
499
599
  permissionDeniedTrackedRef.current = true;
500
- telemetry?.track('camera_permission_denied', { component: 'scanner' });
600
+ telemetry?.track('camera_permission_denied', {
601
+ component: 'scanner',
602
+ metadata: buildScannerTelemetryMetadata(),
603
+ });
501
604
  }
502
- }, [permission, telemetry]);
605
+ }, [buildScannerTelemetryMetadata, permission, telemetry]);
503
606
 
504
607
  const handleCapture = useCallback(async () => {
505
608
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
@@ -516,6 +619,7 @@ export function VerifyAIScanner({
516
619
  telemetry?.track('camera_not_ready', {
517
620
  component: 'scanner',
518
621
  error,
622
+ metadata: buildScannerTelemetryMetadata(),
519
623
  });
520
624
  setTimeout(() => setStatus('idle'), 2000);
521
625
  return;
@@ -531,6 +635,11 @@ export function VerifyAIScanner({
531
635
  terminalResultTimerRef.current = null;
532
636
  }
533
637
 
638
+ let nativeCaptureAttempts = 0;
639
+ let captureRetryAttempted = false;
640
+ let captureRetryReady = false;
641
+ let lastNativeCaptureErrorMessage: string | null = null;
642
+
534
643
  try {
535
644
  // --- Capture + best-effort resize ---
536
645
  // Strategy: try to dynamically import expo-image-manipulator.
@@ -551,12 +660,72 @@ export function VerifyAIScanner({
551
660
  // Not installed — fall back to camera-only base64 below
552
661
  }
553
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
+
554
723
  if (ImageManipulator) {
555
724
  // Capture without base64 — ImageManipulator will produce it after resize.
556
- const photo = await cameraRef.current.takePictureAsync({
725
+ const photo = await takePictureWithRetry({
557
726
  quality: 0.8,
558
727
  exif: false,
559
- });
728
+ }, 'uri');
560
729
 
561
730
  if (!photo?.uri) {
562
731
  throw createScannerError(
@@ -600,11 +769,11 @@ export function VerifyAIScanner({
600
769
  // Fallback: capture base64 directly from the camera at reduced quality.
601
770
  // No resize is possible without ImageManipulator, but the lower quality
602
771
  // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
603
- const photo = await cameraRef.current.takePictureAsync({
772
+ const photo = await takePictureWithRetry({
604
773
  base64: true,
605
774
  quality: FALLBACK_QUALITY,
606
775
  exif: false,
607
- });
776
+ }, 'base64');
608
777
 
609
778
  if (!photo?.base64) {
610
779
  throw createScannerError(
@@ -624,14 +793,19 @@ export function VerifyAIScanner({
624
793
  // Best-effort telemetry — never blocks capture
625
794
  telemetry?.track('image_processed', {
626
795
  component: 'scanner',
627
- metadata: {
796
+ metadata: buildScannerTelemetryMetadata({
628
797
  original_width: origWidth,
629
798
  original_height: origHeight,
630
799
  processed_width: processedWidth,
631
800
  processed_height: processedHeight,
632
801
  resized: didResize ? 1 : 0,
633
802
  has_manipulator: ImageManipulator ? 1 : 0,
634
- },
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
+ }),
635
809
  });
636
810
 
637
811
  setStatus('processing');
@@ -699,7 +873,17 @@ export function VerifyAIScanner({
699
873
  isCaptureFail ? 'capture_failure'
700
874
  : isImageFail ? 'image_manipulation_failure'
701
875
  : 'unknown_error',
702
- { 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
+ },
703
887
  );
704
888
  }
705
889
 
@@ -707,7 +891,7 @@ export function VerifyAIScanner({
707
891
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
708
892
  }
709
893
  }
710
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
894
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
711
895
 
712
896
  // Expose capture to parent via ref
713
897
  if (captureRef) {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.14';
1
+ export const SDK_VERSION = '2.4.15';