@switchlabs/verify-ai-react-native 2.5.0 → 2.5.1

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.
@@ -24,6 +24,8 @@ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
24
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
25
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
26
26
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
27
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
28
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
27
29
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
28
30
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
29
31
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -207,6 +209,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
207
209
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
208
210
  const [cameraReady, setCameraReady] = useState(false);
209
211
  const [cameraKey, setCameraKey] = useState(0);
212
+ const [cameraMounted, setCameraMounted] = useState(true);
210
213
  const terminalResultRef = useRef(null);
211
214
  const terminalResultTimerRef = useRef(null);
212
215
  const terminalResultDeliveredRef = useRef(false);
@@ -219,6 +222,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
219
222
  const lastAppStateRemountAtRef = useRef(null);
220
223
  const lastOrientationRemountAtRef = useRef(null);
221
224
  const lastCaptureRetryAtRef = useRef(null);
225
+ const cameraStartupStartedAtRef = useRef(Date.now());
226
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
227
+ const cameraStartupSlowTrackedRef = useRef(false);
228
+ const cameraStartupReadyTrackedRef = useRef(false);
229
+ const startupRemountTimerRef = useRef(null);
222
230
  const cameraRemountCountRef = useRef(0);
223
231
  const startupWatchdogRemountCountRef = useRef(0);
224
232
  const telemetryRef = useRef(telemetry);
@@ -260,7 +268,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
260
268
  camera_ever_ready: cameraEverReadyRef.current,
261
269
  camera_init_failed: cameraInitFailedRef.current,
262
270
  camera_key: cameraKey,
271
+ camera_mounted: cameraMounted ? 1 : 0,
263
272
  camera_remount_count: cameraRemountCountRef.current,
273
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
274
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
264
275
  terminated,
265
276
  exhausted,
266
277
  app_state: appStateRef.current,
@@ -278,6 +289,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
278
289
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
279
290
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
280
291
  last_capture_retry_at: lastCaptureRetryAtRef.current,
292
+ requested_torch_enabled: enableTorch ? 1 : 0,
281
293
  android_native_orientation_subscription_active: 0,
282
294
  android_native_orientation_event_count: 0,
283
295
  android_native_orientation_change_count: 0,
@@ -314,7 +326,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
314
326
  ...extra,
315
327
  }), [
316
328
  cameraKey,
329
+ cameraMounted,
317
330
  exhausted,
331
+ enableTorch,
318
332
  isLandscape,
319
333
  overlayRotationDeg,
320
334
  physicalOrientation,
@@ -358,6 +372,37 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
358
372
  void activeTelemetry?.flush();
359
373
  };
360
374
  }, []);
375
+ const requestCameraRemount = useCallback((reason, opts = {}) => {
376
+ if (startupRemountTimerRef.current) {
377
+ clearTimeout(startupRemountTimerRef.current);
378
+ startupRemountTimerRef.current = null;
379
+ }
380
+ setCameraReady(false);
381
+ cameraReadyRef.current = false;
382
+ cameraStartupAttemptStartedAtRef.current = Date.now();
383
+ if (opts.log) {
384
+ console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
385
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
386
+ }
387
+ const remount = () => {
388
+ setCameraKey((k) => k + 1);
389
+ setCameraMounted(true);
390
+ startupRemountTimerRef.current = null;
391
+ };
392
+ if ((opts.backoffMs ?? 0) > 0) {
393
+ setCameraMounted(false);
394
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
395
+ return;
396
+ }
397
+ setCameraMounted(true);
398
+ remount();
399
+ }, [enableTorch]);
400
+ useEffect(() => () => {
401
+ if (startupRemountTimerRef.current) {
402
+ clearTimeout(startupRemountTimerRef.current);
403
+ startupRemountTimerRef.current = null;
404
+ }
405
+ }, []);
361
406
  useEffect(() => {
362
407
  if (Platform.OS !== 'android')
363
408
  return;
@@ -574,16 +619,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
574
619
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
575
620
  },
576
621
  });
577
- setCameraReady(false);
578
- cameraReadyRef.current = false;
579
- // Delay remount so iOS rotation animation completes before the new
580
- // AVCaptureVideoPreviewLayer initializes with the final frame bounds.
581
- const timer = setTimeout(() => {
582
- setCameraKey((k) => k + 1);
583
- }, 400);
584
- return () => clearTimeout(timer);
622
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
585
623
  }
586
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
624
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
587
625
  // Resume camera when app returns from background/inactive (e.g. notification bar)
588
626
  useEffect(() => {
589
627
  if (terminated)
@@ -611,12 +649,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
611
649
  remount_requested_at: at,
612
650
  }),
613
651
  });
614
- setCameraReady(false);
615
- cameraReadyRef.current = false;
616
- setCameraKey((k) => k + 1);
652
+ requestCameraRemount('appstate_active');
617
653
  });
618
654
  return () => subscription.remove();
619
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
655
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
620
656
  const pausePreview = useCallback(() => {
621
657
  cameraRef.current?.pausePreview?.().catch(() => { });
622
658
  }, []);
@@ -670,13 +706,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
670
706
  }, [onClose, result]);
671
707
  // Camera init callbacks
672
708
  const onCameraReady = useCallback(() => {
673
- lastCameraReadyAtRef.current = new Date().toISOString();
709
+ const readyAt = new Date().toISOString();
710
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
711
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
712
+ lastCameraReadyAtRef.current = readyAt;
674
713
  setCameraReady(true);
675
714
  cameraReadyRef.current = true;
676
715
  cameraEverReadyRef.current = true;
677
716
  cameraInitFailedRef.current = false;
678
717
  startupWatchdogRemountCountRef.current = 0;
679
- }, []);
718
+ if (!cameraStartupReadyTrackedRef.current) {
719
+ cameraStartupReadyTrackedRef.current = true;
720
+ console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
721
+ `startupTotalMs=${startupTotalElapsedMs} ` +
722
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
723
+ `torchRequested=${enableTorch ? 1 : 0}`);
724
+ telemetry?.track('camera_ready', {
725
+ component: 'scanner',
726
+ error: 'camera_ready',
727
+ metadata: buildScannerTelemetryMetadata({
728
+ camera_ready_at: readyAt,
729
+ startup_total_elapsed_ms: startupTotalElapsedMs,
730
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
731
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
732
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
733
+ }),
734
+ });
735
+ }
736
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
680
737
  const onMountError = useCallback((event) => {
681
738
  const error = new Error(event.message || 'Camera mount error');
682
739
  error.code = CAMERA_INIT_ERROR_CODE;
@@ -697,12 +754,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
697
754
  }),
698
755
  });
699
756
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
757
+ useEffect(() => {
758
+ if (!permission?.granted ||
759
+ terminated ||
760
+ currentAppState !== 'active' ||
761
+ !cameraMounted ||
762
+ cameraReadyRef.current ||
763
+ cameraInitFailedRef.current ||
764
+ cameraStartupSlowTrackedRef.current) {
765
+ return;
766
+ }
767
+ const timer = setTimeout(() => {
768
+ if (appStateRef.current !== 'active' ||
769
+ cameraReadyRef.current ||
770
+ cameraInitFailedRef.current ||
771
+ cameraStartupSlowTrackedRef.current) {
772
+ return;
773
+ }
774
+ cameraStartupSlowTrackedRef.current = true;
775
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
776
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
777
+ console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
778
+ `startupTotalMs=${startupTotalElapsedMs} ` +
779
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
780
+ `torchRequested=${enableTorch ? 1 : 0}`);
781
+ telemetry?.track('camera_startup_slow', {
782
+ component: 'scanner',
783
+ error: 'camera_startup_slow',
784
+ metadata: buildScannerTelemetryMetadata({
785
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
786
+ startup_total_elapsed_ms: startupTotalElapsedMs,
787
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
788
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
789
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
790
+ }),
791
+ });
792
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
793
+ return () => clearTimeout(timer);
794
+ }, [
795
+ permission?.granted,
796
+ terminated,
797
+ currentAppState,
798
+ cameraMounted,
799
+ buildScannerTelemetryMetadata,
800
+ enableTorch,
801
+ telemetry,
802
+ ]);
700
803
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
701
804
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
702
805
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
703
806
  // recreate the CameraView, which starts a fresh native session.
704
807
  useEffect(() => {
705
- if (!permission?.granted || terminated || currentAppState !== 'active')
808
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
706
809
  return;
707
810
  const watchdogMs = Platform.OS === 'android'
708
811
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -734,11 +837,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
734
837
  remount_reason: 'startup_watchdog',
735
838
  startup_watchdog_remount_count: startupWatchdogRemountCount,
736
839
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
840
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
841
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
737
842
  }),
738
843
  });
739
- setCameraReady(false);
740
- cameraReadyRef.current = false;
741
- setCameraKey((k) => k + 1);
844
+ requestCameraRemount('startup_watchdog', {
845
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
846
+ log: true,
847
+ });
742
848
  }
743
849
  }
744
850
  }, watchdogMs);
@@ -747,9 +853,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
747
853
  permission?.granted,
748
854
  terminated,
749
855
  currentAppState,
856
+ cameraMounted,
750
857
  cameraKey,
751
858
  buildScannerTelemetryMetadata,
859
+ enableTorch,
752
860
  onMountError,
861
+ requestCameraRemount,
753
862
  telemetry,
754
863
  ]);
755
864
  // Track permission denied
@@ -882,9 +991,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
882
991
  const retryAt = new Date().toISOString();
883
992
  lastCaptureRetryAtRef.current = retryAt;
884
993
  cameraRemountCountRef.current++;
885
- setCameraReady(false);
886
- cameraReadyRef.current = false;
887
- setCameraKey((key) => key + 1);
994
+ requestCameraRemount('capture_retry');
888
995
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
889
996
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
890
997
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
@@ -1056,7 +1163,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1056
1163
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1057
1164
  }
1058
1165
  }
1059
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1166
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1060
1167
  // Expose capture to parent via ref
1061
1168
  if (captureRef) {
1062
1169
  captureRef.current = handleCapture;
@@ -1068,7 +1175,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1068
1175
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
1069
1176
  }
1070
1177
  const showBottomCard = status === 'success' || status === 'error';
1071
- return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: !terminated && enableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1178
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1179
+ return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1072
1180
  setPhysicalOrientation(event.orientation);
1073
1181
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
1074
1182
  styles.titleText,
@@ -1139,7 +1247,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1139
1247
  overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
1140
1248
  (!cameraReady || status === 'capturing' || status === 'processing') &&
1141
1249
  styles.captureButtonDisabled,
1142
- ], 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) }));
1250
+ ], 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)) }));
1143
1251
  }
1144
1252
  const CORNER_SIZE = 30;
1145
1253
  const CORNER_THICKNESS = 3;
@@ -13,6 +13,7 @@ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
13
13
  const CRITICAL_EVENTS = new Set([
14
14
  'camera_init_failure',
15
15
  'camera_preview_timeout',
16
+ 'camera_startup_slow',
16
17
  'camera_permission_denied',
17
18
  'camera_scanner_mounted',
18
19
  'camera_scanner_disposed',
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.5.0";
1
+ export declare const SDK_VERSION = "2.5.1";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.0';
1
+ export const SDK_VERSION = '2.5.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,8 @@ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
49
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
50
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
51
51
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
52
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
53
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
52
54
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
53
55
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
54
56
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -309,6 +311,7 @@ export function VerifyAIScanner({
309
311
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
310
312
  const [cameraReady, setCameraReady] = useState(false);
311
313
  const [cameraKey, setCameraKey] = useState(0);
314
+ const [cameraMounted, setCameraMounted] = useState(true);
312
315
  const terminalResultRef = useRef<VerificationResult | null>(null);
313
316
  const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
314
317
  const terminalResultDeliveredRef = useRef(false);
@@ -321,6 +324,11 @@ export function VerifyAIScanner({
321
324
  const lastAppStateRemountAtRef = useRef<string | null>(null);
322
325
  const lastOrientationRemountAtRef = useRef<string | null>(null);
323
326
  const lastCaptureRetryAtRef = useRef<string | null>(null);
327
+ const cameraStartupStartedAtRef = useRef(Date.now());
328
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
329
+ const cameraStartupSlowTrackedRef = useRef(false);
330
+ const cameraStartupReadyTrackedRef = useRef(false);
331
+ const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
324
332
  const cameraRemountCountRef = useRef(0);
325
333
  const startupWatchdogRemountCountRef = useRef(0);
326
334
  const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
@@ -371,7 +379,10 @@ export function VerifyAIScanner({
371
379
  camera_ever_ready: cameraEverReadyRef.current,
372
380
  camera_init_failed: cameraInitFailedRef.current,
373
381
  camera_key: cameraKey,
382
+ camera_mounted: cameraMounted ? 1 : 0,
374
383
  camera_remount_count: cameraRemountCountRef.current,
384
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
385
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
375
386
  terminated,
376
387
  exhausted,
377
388
  app_state: appStateRef.current,
@@ -389,6 +400,7 @@ export function VerifyAIScanner({
389
400
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
390
401
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
391
402
  last_capture_retry_at: lastCaptureRetryAtRef.current,
403
+ requested_torch_enabled: enableTorch ? 1 : 0,
392
404
  android_native_orientation_subscription_active: 0,
393
405
  android_native_orientation_event_count: 0,
394
406
  android_native_orientation_change_count: 0,
@@ -425,7 +437,9 @@ export function VerifyAIScanner({
425
437
  ...extra,
426
438
  }), [
427
439
  cameraKey,
440
+ cameraMounted,
428
441
  exhausted,
442
+ enableTorch,
429
443
  isLandscape,
430
444
  overlayRotationDeg,
431
445
  physicalOrientation,
@@ -476,6 +490,52 @@ export function VerifyAIScanner({
476
490
  };
477
491
  }, []);
478
492
 
493
+ const requestCameraRemount = useCallback((
494
+ reason: string,
495
+ opts: {
496
+ backoffMs?: number;
497
+ log?: boolean;
498
+ } = {},
499
+ ) => {
500
+ if (startupRemountTimerRef.current) {
501
+ clearTimeout(startupRemountTimerRef.current);
502
+ startupRemountTimerRef.current = null;
503
+ }
504
+
505
+ setCameraReady(false);
506
+ cameraReadyRef.current = false;
507
+ cameraStartupAttemptStartedAtRef.current = Date.now();
508
+
509
+ if (opts.log) {
510
+ console.warn(
511
+ `VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
512
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
513
+ );
514
+ }
515
+
516
+ const remount = () => {
517
+ setCameraKey((k) => k + 1);
518
+ setCameraMounted(true);
519
+ startupRemountTimerRef.current = null;
520
+ };
521
+
522
+ if ((opts.backoffMs ?? 0) > 0) {
523
+ setCameraMounted(false);
524
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
525
+ return;
526
+ }
527
+
528
+ setCameraMounted(true);
529
+ remount();
530
+ }, [enableTorch]);
531
+
532
+ useEffect(() => () => {
533
+ if (startupRemountTimerRef.current) {
534
+ clearTimeout(startupRemountTimerRef.current);
535
+ startupRemountTimerRef.current = null;
536
+ }
537
+ }, []);
538
+
479
539
  useEffect(() => {
480
540
  if (Platform.OS !== 'android') return;
481
541
 
@@ -728,17 +788,9 @@ export function VerifyAIScanner({
728
788
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
729
789
  },
730
790
  });
731
- setCameraReady(false);
732
- cameraReadyRef.current = false;
733
-
734
- // Delay remount so iOS rotation animation completes before the new
735
- // AVCaptureVideoPreviewLayer initializes with the final frame bounds.
736
- const timer = setTimeout(() => {
737
- setCameraKey((k) => k + 1);
738
- }, 400);
739
- return () => clearTimeout(timer);
791
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
740
792
  }
741
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
793
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
742
794
 
743
795
  // Resume camera when app returns from background/inactive (e.g. notification bar)
744
796
  useEffect(() => {
@@ -769,13 +821,11 @@ export function VerifyAIScanner({
769
821
  remount_requested_at: at,
770
822
  }),
771
823
  });
772
- setCameraReady(false);
773
- cameraReadyRef.current = false;
774
- setCameraKey((k) => k + 1);
824
+ requestCameraRemount('appstate_active');
775
825
  });
776
826
 
777
827
  return () => subscription.remove();
778
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
828
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
779
829
 
780
830
  const pausePreview = useCallback(() => {
781
831
  cameraRef.current?.pausePreview?.().catch(() => {});
@@ -836,13 +886,36 @@ export function VerifyAIScanner({
836
886
 
837
887
  // Camera init callbacks
838
888
  const onCameraReady = useCallback(() => {
839
- lastCameraReadyAtRef.current = new Date().toISOString();
889
+ const readyAt = new Date().toISOString();
890
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
891
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
892
+ lastCameraReadyAtRef.current = readyAt;
840
893
  setCameraReady(true);
841
894
  cameraReadyRef.current = true;
842
895
  cameraEverReadyRef.current = true;
843
896
  cameraInitFailedRef.current = false;
844
897
  startupWatchdogRemountCountRef.current = 0;
845
- }, []);
898
+ if (!cameraStartupReadyTrackedRef.current) {
899
+ cameraStartupReadyTrackedRef.current = true;
900
+ console.log(
901
+ `VerifyAI[${SDK_VERSION}]: camera ready ` +
902
+ `startupTotalMs=${startupTotalElapsedMs} ` +
903
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
904
+ `torchRequested=${enableTorch ? 1 : 0}`,
905
+ );
906
+ telemetry?.track('camera_ready', {
907
+ component: 'scanner',
908
+ error: 'camera_ready',
909
+ metadata: buildScannerTelemetryMetadata({
910
+ camera_ready_at: readyAt,
911
+ startup_total_elapsed_ms: startupTotalElapsedMs,
912
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
913
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
914
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
915
+ }),
916
+ });
917
+ }
918
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
846
919
 
847
920
  const onMountError = useCallback((event: { message?: string }) => {
848
921
  const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
@@ -865,12 +938,68 @@ export function VerifyAIScanner({
865
938
  });
866
939
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
867
940
 
941
+ useEffect(() => {
942
+ if (
943
+ !permission?.granted ||
944
+ terminated ||
945
+ currentAppState !== 'active' ||
946
+ !cameraMounted ||
947
+ cameraReadyRef.current ||
948
+ cameraInitFailedRef.current ||
949
+ cameraStartupSlowTrackedRef.current
950
+ ) {
951
+ return;
952
+ }
953
+
954
+ const timer = setTimeout(() => {
955
+ if (
956
+ appStateRef.current !== 'active' ||
957
+ cameraReadyRef.current ||
958
+ cameraInitFailedRef.current ||
959
+ cameraStartupSlowTrackedRef.current
960
+ ) {
961
+ return;
962
+ }
963
+
964
+ cameraStartupSlowTrackedRef.current = true;
965
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
966
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
967
+ console.warn(
968
+ `VerifyAI[${SDK_VERSION}]: camera startup slow ` +
969
+ `startupTotalMs=${startupTotalElapsedMs} ` +
970
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
971
+ `torchRequested=${enableTorch ? 1 : 0}`,
972
+ );
973
+ telemetry?.track('camera_startup_slow', {
974
+ component: 'scanner',
975
+ error: 'camera_startup_slow',
976
+ metadata: buildScannerTelemetryMetadata({
977
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
978
+ startup_total_elapsed_ms: startupTotalElapsedMs,
979
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
980
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
981
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
982
+ }),
983
+ });
984
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
985
+
986
+ return () => clearTimeout(timer);
987
+ }, [
988
+ permission?.granted,
989
+ terminated,
990
+ currentAppState,
991
+ cameraMounted,
992
+ buildScannerTelemetryMetadata,
993
+ enableTorch,
994
+ telemetry,
995
+ ]);
996
+
868
997
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
869
998
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
870
999
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
871
1000
  // recreate the CameraView, which starts a fresh native session.
872
1001
  useEffect(() => {
873
- if (!permission?.granted || terminated || currentAppState !== 'active') return;
1002
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
874
1003
 
875
1004
  const watchdogMs = Platform.OS === 'android'
876
1005
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -904,11 +1033,14 @@ export function VerifyAIScanner({
904
1033
  remount_reason: 'startup_watchdog',
905
1034
  startup_watchdog_remount_count: startupWatchdogRemountCount,
906
1035
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
1036
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1037
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
907
1038
  }),
908
1039
  });
909
- setCameraReady(false);
910
- cameraReadyRef.current = false;
911
- setCameraKey((k) => k + 1);
1040
+ requestCameraRemount('startup_watchdog', {
1041
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1042
+ log: true,
1043
+ });
912
1044
  }
913
1045
  }
914
1046
  }, watchdogMs);
@@ -918,9 +1050,12 @@ export function VerifyAIScanner({
918
1050
  permission?.granted,
919
1051
  terminated,
920
1052
  currentAppState,
1053
+ cameraMounted,
921
1054
  cameraKey,
922
1055
  buildScannerTelemetryMetadata,
1056
+ enableTorch,
923
1057
  onMountError,
1058
+ requestCameraRemount,
924
1059
  telemetry,
925
1060
  ]);
926
1061
 
@@ -1077,9 +1212,7 @@ export function VerifyAIScanner({
1077
1212
  const retryAt = new Date().toISOString();
1078
1213
  lastCaptureRetryAtRef.current = retryAt;
1079
1214
  cameraRemountCountRef.current++;
1080
- setCameraReady(false);
1081
- cameraReadyRef.current = false;
1082
- setCameraKey((key) => key + 1);
1215
+ requestCameraRemount('capture_retry');
1083
1216
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1084
1217
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1085
1218
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
@@ -1285,7 +1418,7 @@ export function VerifyAIScanner({
1285
1418
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1286
1419
  }
1287
1420
  }
1288
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1421
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1289
1422
 
1290
1423
  // Expose capture to parent via ref
1291
1424
  if (captureRef) {
@@ -1308,22 +1441,24 @@ export function VerifyAIScanner({
1308
1441
  }
1309
1442
 
1310
1443
  const showBottomCard = status === 'success' || status === 'error';
1444
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1311
1445
 
1312
1446
  return (
1313
1447
  <View style={[styles.container, style]}>
1314
- <CameraView
1315
- key={cameraKey}
1316
- ref={cameraRef}
1317
- style={styles.camera}
1318
- facing="back"
1319
- enableTorch={!terminated && enableTorch}
1320
- onCameraReady={onCameraReady}
1321
- onMountError={onMountError}
1322
- responsiveOrientationWhenOrientationLocked
1323
- onResponsiveOrientationChanged={(event) => {
1324
- setPhysicalOrientation(event.orientation);
1325
- }}
1326
- >
1448
+ {cameraMounted && (
1449
+ <CameraView
1450
+ key={cameraKey}
1451
+ ref={cameraRef}
1452
+ style={styles.camera}
1453
+ facing="back"
1454
+ enableTorch={shouldEnableTorch}
1455
+ onCameraReady={onCameraReady}
1456
+ onMountError={onMountError}
1457
+ responsiveOrientationWhenOrientationLocked
1458
+ onResponsiveOrientationChanged={(event) => {
1459
+ setPhysicalOrientation(event.orientation);
1460
+ }}
1461
+ >
1327
1462
  {/* Overlay */}
1328
1463
  <View style={styles.overlay}>
1329
1464
  {overlay?.title && (
@@ -1527,7 +1662,8 @@ export function VerifyAIScanner({
1527
1662
  </TouchableOpacity>
1528
1663
  )}
1529
1664
  </View>
1530
- </CameraView>
1665
+ </CameraView>
1666
+ )}
1531
1667
  </View>
1532
1668
  );
1533
1669
  }
@@ -38,6 +38,7 @@ interface BufferedEvent {
38
38
  const CRITICAL_EVENTS = new Set([
39
39
  'camera_init_failure',
40
40
  'camera_preview_timeout',
41
+ 'camera_startup_slow',
41
42
  'camera_permission_denied',
42
43
  'camera_scanner_mounted',
43
44
  'camera_scanner_disposed',
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.0';
1
+ export const SDK_VERSION = '2.5.1';