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

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.
@@ -52,6 +52,49 @@ function stringifyErrorMessage(value, fallback) {
52
52
  }
53
53
  return fallback;
54
54
  }
55
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
56
+ 'rideId',
57
+ 'vehicleType',
58
+ 'verificationAttemptId',
59
+ 'verificationAttempt',
60
+ 'verificationMaxAttempts',
61
+ 'verificationSource',
62
+ 'torchEnabled',
63
+ 'scannerSessionId',
64
+ ];
65
+ function toTelemetryKey(key) {
66
+ return key
67
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
68
+ .replace(/[^a-zA-Z0-9]+/g, '_')
69
+ .replace(/^_+|_+$/g, '')
70
+ .toLowerCase();
71
+ }
72
+ function compactClientTelemetryMetadata(metadata = {}) {
73
+ const compacted = {};
74
+ for (const [key, value] of Object.entries(metadata)) {
75
+ if (value === null || value === undefined)
76
+ continue;
77
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
78
+ }
79
+ return compacted;
80
+ }
81
+ function buildVerifyTelemetryMetadata(requestMetadata, options) {
82
+ const metadata = {
83
+ ...options?.telemetryMetadata,
84
+ };
85
+ if (options?.idempotencyKey) {
86
+ metadata.idempotency_key = options.idempotencyKey;
87
+ }
88
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
89
+ const value = requestMetadata?.[key];
90
+ if (typeof value === 'string' ||
91
+ typeof value === 'number' ||
92
+ typeof value === 'boolean') {
93
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
94
+ }
95
+ }
96
+ return metadata;
97
+ }
55
98
  export class VerifyAIClient {
56
99
  constructor(config) {
57
100
  if (!config.apiKey) {
@@ -101,7 +144,7 @@ export class VerifyAIClient {
101
144
  const message = error instanceof Error ? error.message : stringifyErrorMessage(error, 'VerifyAI request failed');
102
145
  return this.buildRequestError(message, 0, context);
103
146
  }
104
- async executeRequest(path, options = {}) {
147
+ async executeRequest(path, options = {}, telemetryMetadata = {}) {
105
148
  const controller = new AbortController();
106
149
  const timer = setTimeout(() => controller.abort(), this.timeout);
107
150
  const method = (options.method || 'GET').toUpperCase();
@@ -156,6 +199,7 @@ export class VerifyAIClient {
156
199
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
157
200
  : normalized;
158
201
  const metadata = {
202
+ ...compactClientTelemetryMetadata(telemetryMetadata),
159
203
  method: context.method,
160
204
  path: context.path,
161
205
  status: normalized.status,
@@ -178,8 +222,8 @@ export class VerifyAIClient {
178
222
  clearTimeout(timer);
179
223
  }
180
224
  }
181
- async request(path, options = {}) {
182
- return this.executeRequest(path, options);
225
+ async request(path, options = {}, telemetryMetadata = {}) {
226
+ return this.executeRequest(path, options, telemetryMetadata);
183
227
  }
184
228
  /**
185
229
  * Submit a photo for AI verification.
@@ -209,11 +253,12 @@ export class VerifyAIClient {
209
253
  headers['Idempotency-Key'] = options.idempotencyKey;
210
254
  }
211
255
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
256
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
212
257
  return this.request('/verify', {
213
258
  method: 'POST',
214
259
  headers,
215
260
  body: JSON.stringify(enrichedRequest),
216
- });
261
+ }, telemetryMetadata);
217
262
  }
218
263
  /**
219
264
  * Submit a photo for AI verification using multipart/form-data.
@@ -232,7 +277,8 @@ export class VerifyAIClient {
232
277
  name: 'photo.jpg',
233
278
  });
234
279
  formData.append('policy', request.policy);
235
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
280
+ const enrichedMetadata = enrichMetadata(request.metadata);
281
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
236
282
  if (request.provider) {
237
283
  formData.append('provider', request.provider);
238
284
  }
@@ -246,12 +292,13 @@ export class VerifyAIClient {
246
292
  if (options?.idempotencyKey) {
247
293
  headers['Idempotency-Key'] = options.idempotencyKey;
248
294
  }
295
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
249
296
  try {
250
297
  return await this.executeRequest('/verify', {
251
298
  method: 'POST',
252
299
  headers,
253
300
  body: formData,
254
- });
301
+ }, telemetryMetadata);
255
302
  }
256
303
  catch (error) {
257
304
  // 5xx during multipart can mean the server crashed before reservation
@@ -271,7 +318,7 @@ export class VerifyAIClient {
271
318
  metadata: request.metadata,
272
319
  provider: request.provider,
273
320
  include_image_data: request.include_image_data,
274
- }, options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined);
321
+ }, options);
275
322
  }
276
323
  catch {
277
324
  // If the retry itself fails, throw the original error
@@ -20,10 +20,13 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
20
20
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
21
21
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
22
22
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
23
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
23
24
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
25
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
26
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
26
27
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
28
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
29
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
27
30
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
28
31
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
29
32
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -74,6 +77,9 @@ function createScannerError(message, code, name) {
74
77
  function sleep(ms) {
75
78
  return new Promise((resolve) => setTimeout(resolve, ms));
76
79
  }
80
+ function createScannerSessionId() {
81
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
82
+ }
77
83
  function isNativeCameraCaptureError(error) {
78
84
  const message = error.message.toLowerCase();
79
85
  return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
@@ -207,6 +213,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
207
213
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
208
214
  const [cameraReady, setCameraReady] = useState(false);
209
215
  const [cameraKey, setCameraKey] = useState(0);
216
+ const [cameraMounted, setCameraMounted] = useState(true);
210
217
  const terminalResultRef = useRef(null);
211
218
  const terminalResultTimerRef = useRef(null);
212
219
  const terminalResultDeliveredRef = useRef(false);
@@ -219,6 +226,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
219
226
  const lastAppStateRemountAtRef = useRef(null);
220
227
  const lastOrientationRemountAtRef = useRef(null);
221
228
  const lastCaptureRetryAtRef = useRef(null);
229
+ const cameraStartupStartedAtRef = useRef(Date.now());
230
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
231
+ const cameraStartupSlowTrackedRef = useRef(false);
232
+ const cameraStartupReadyTrackedRef = useRef(false);
233
+ const startupRemountTimerRef = useRef(null);
222
234
  const cameraRemountCountRef = useRef(0);
223
235
  const startupWatchdogRemountCountRef = useRef(0);
224
236
  const telemetryRef = useRef(telemetry);
@@ -253,14 +265,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
253
265
  const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
254
266
  physicalOrientationRef.current = physicalOrientation;
255
267
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
268
+ const scannerSessionIdRef = useRef(createScannerSessionId());
269
+ const captureSequenceRef = useRef(0);
270
+ const torchRetrySuppressedRef = useRef(false);
271
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
256
272
  const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
273
+ scanner_session_id: scannerSessionIdRef.current,
257
274
  policy,
258
275
  status,
259
276
  camera_ready: cameraReadyRef.current,
260
277
  camera_ever_ready: cameraEverReadyRef.current,
261
278
  camera_init_failed: cameraInitFailedRef.current,
262
279
  camera_key: cameraKey,
280
+ camera_mounted: cameraMounted ? 1 : 0,
263
281
  camera_remount_count: cameraRemountCountRef.current,
282
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
283
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
264
284
  terminated,
265
285
  exhausted,
266
286
  app_state: appStateRef.current,
@@ -278,6 +298,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
278
298
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
279
299
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
280
300
  last_capture_retry_at: lastCaptureRetryAtRef.current,
301
+ requested_torch_enabled: enableTorch ? 1 : 0,
302
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
281
303
  android_native_orientation_subscription_active: 0,
282
304
  android_native_orientation_event_count: 0,
283
305
  android_native_orientation_change_count: 0,
@@ -314,7 +336,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
314
336
  ...extra,
315
337
  }), [
316
338
  cameraKey,
339
+ cameraMounted,
317
340
  exhausted,
341
+ enableTorch,
318
342
  isLandscape,
319
343
  overlayRotationDeg,
320
344
  physicalOrientation,
@@ -328,6 +352,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
328
352
  ]);
329
353
  telemetryRef.current = telemetry;
330
354
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
355
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed) => {
356
+ torchRetrySuppressedRef.current = nextSuppressed;
357
+ setTorchRetrySuppressed(nextSuppressed);
358
+ }, []);
331
359
  useEffect(() => {
332
360
  const reporter = telemetryRef.current;
333
361
  if (reporter) {
@@ -358,6 +386,38 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
358
386
  void activeTelemetry?.flush();
359
387
  };
360
388
  }, []);
389
+ const requestCameraRemount = useCallback((reason, opts = {}) => {
390
+ if (startupRemountTimerRef.current) {
391
+ clearTimeout(startupRemountTimerRef.current);
392
+ startupRemountTimerRef.current = null;
393
+ }
394
+ setCameraReady(false);
395
+ cameraReadyRef.current = false;
396
+ cameraStartupAttemptStartedAtRef.current = Date.now();
397
+ if (opts.log) {
398
+ console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
399
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
400
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`);
401
+ }
402
+ const remount = () => {
403
+ setCameraKey((k) => k + 1);
404
+ setCameraMounted(true);
405
+ startupRemountTimerRef.current = null;
406
+ };
407
+ if ((opts.backoffMs ?? 0) > 0) {
408
+ setCameraMounted(false);
409
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
410
+ return;
411
+ }
412
+ setCameraMounted(true);
413
+ remount();
414
+ }, [enableTorch]);
415
+ useEffect(() => () => {
416
+ if (startupRemountTimerRef.current) {
417
+ clearTimeout(startupRemountTimerRef.current);
418
+ startupRemountTimerRef.current = null;
419
+ }
420
+ }, []);
361
421
  useEffect(() => {
362
422
  if (Platform.OS !== 'android')
363
423
  return;
@@ -574,16 +634,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
574
634
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
575
635
  },
576
636
  });
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);
637
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
585
638
  }
586
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
639
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
587
640
  // Resume camera when app returns from background/inactive (e.g. notification bar)
588
641
  useEffect(() => {
589
642
  if (terminated)
@@ -611,12 +664,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
611
664
  remount_requested_at: at,
612
665
  }),
613
666
  });
614
- setCameraReady(false);
615
- cameraReadyRef.current = false;
616
- setCameraKey((k) => k + 1);
667
+ requestCameraRemount('appstate_active');
617
668
  });
618
669
  return () => subscription.remove();
619
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
670
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
620
671
  const pausePreview = useCallback(() => {
621
672
  cameraRef.current?.pausePreview?.().catch(() => { });
622
673
  }, []);
@@ -670,13 +721,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
670
721
  }, [onClose, result]);
671
722
  // Camera init callbacks
672
723
  const onCameraReady = useCallback(() => {
673
- lastCameraReadyAtRef.current = new Date().toISOString();
724
+ const readyAt = new Date().toISOString();
725
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
726
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
727
+ lastCameraReadyAtRef.current = readyAt;
674
728
  setCameraReady(true);
675
729
  cameraReadyRef.current = true;
676
730
  cameraEverReadyRef.current = true;
677
731
  cameraInitFailedRef.current = false;
678
732
  startupWatchdogRemountCountRef.current = 0;
679
- }, []);
733
+ if (!cameraStartupReadyTrackedRef.current) {
734
+ cameraStartupReadyTrackedRef.current = true;
735
+ console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
736
+ `startupTotalMs=${startupTotalElapsedMs} ` +
737
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
738
+ `torchRequested=${enableTorch ? 1 : 0}`);
739
+ telemetry?.track('camera_ready', {
740
+ component: 'scanner',
741
+ error: 'camera_ready',
742
+ metadata: buildScannerTelemetryMetadata({
743
+ camera_ready_at: readyAt,
744
+ startup_total_elapsed_ms: startupTotalElapsedMs,
745
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
746
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
747
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
748
+ }),
749
+ });
750
+ }
751
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
680
752
  const onMountError = useCallback((event) => {
681
753
  const error = new Error(event.message || 'Camera mount error');
682
754
  error.code = CAMERA_INIT_ERROR_CODE;
@@ -697,12 +769,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
697
769
  }),
698
770
  });
699
771
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
772
+ useEffect(() => {
773
+ if (!permission?.granted ||
774
+ terminated ||
775
+ currentAppState !== 'active' ||
776
+ !cameraMounted ||
777
+ cameraReadyRef.current ||
778
+ cameraInitFailedRef.current ||
779
+ cameraStartupSlowTrackedRef.current) {
780
+ return;
781
+ }
782
+ const timer = setTimeout(() => {
783
+ if (appStateRef.current !== 'active' ||
784
+ cameraReadyRef.current ||
785
+ cameraInitFailedRef.current ||
786
+ cameraStartupSlowTrackedRef.current) {
787
+ return;
788
+ }
789
+ cameraStartupSlowTrackedRef.current = true;
790
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
791
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
792
+ console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
793
+ `startupTotalMs=${startupTotalElapsedMs} ` +
794
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
795
+ `torchRequested=${enableTorch ? 1 : 0}`);
796
+ telemetry?.track('camera_startup_slow', {
797
+ component: 'scanner',
798
+ error: 'camera_startup_slow',
799
+ metadata: buildScannerTelemetryMetadata({
800
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
801
+ startup_total_elapsed_ms: startupTotalElapsedMs,
802
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
803
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
804
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
805
+ }),
806
+ });
807
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
808
+ return () => clearTimeout(timer);
809
+ }, [
810
+ permission?.granted,
811
+ terminated,
812
+ currentAppState,
813
+ cameraMounted,
814
+ buildScannerTelemetryMetadata,
815
+ enableTorch,
816
+ telemetry,
817
+ ]);
700
818
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
701
819
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
702
820
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
703
821
  // recreate the CameraView, which starts a fresh native session.
704
822
  useEffect(() => {
705
- if (!permission?.granted || terminated || currentAppState !== 'active')
823
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
706
824
  return;
707
825
  const watchdogMs = Platform.OS === 'android'
708
826
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -734,11 +852,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
734
852
  remount_reason: 'startup_watchdog',
735
853
  startup_watchdog_remount_count: startupWatchdogRemountCount,
736
854
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
855
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
856
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
737
857
  }),
738
858
  });
739
- setCameraReady(false);
740
- cameraReadyRef.current = false;
741
- setCameraKey((k) => k + 1);
859
+ requestCameraRemount('startup_watchdog', {
860
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
861
+ log: true,
862
+ });
742
863
  }
743
864
  }
744
865
  }, watchdogMs);
@@ -747,9 +868,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
747
868
  permission?.granted,
748
869
  terminated,
749
870
  currentAppState,
871
+ cameraMounted,
750
872
  cameraKey,
751
873
  buildScannerTelemetryMetadata,
874
+ enableTorch,
752
875
  onMountError,
876
+ requestCameraRemount,
753
877
  telemetry,
754
878
  ]);
755
879
  // Track permission denied
@@ -766,6 +890,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
766
890
  }
767
891
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
768
892
  const handleCapture = useCallback(async () => {
893
+ const captureSequence = captureSequenceRef.current + 1;
894
+ captureSequenceRef.current = captureSequence;
895
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
769
896
  const blockedReason = !cameraRef.current
770
897
  ? 'camera_ref_null'
771
898
  : !cameraReadyRef.current
@@ -783,6 +910,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
783
910
  component: 'scanner',
784
911
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
785
912
  metadata: buildScannerTelemetryMetadata({
913
+ capture_attempt_id: captureAttemptId,
914
+ capture_sequence: captureSequence,
786
915
  capture_blocked_reason: blockedReason,
787
916
  }),
788
917
  });
@@ -803,6 +932,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
803
932
  return;
804
933
  }
805
934
  setStatus('capturing');
935
+ setTorchSuppressedForRetry(false);
806
936
  setResult(null);
807
937
  setLastError(null);
808
938
  terminalResultRef.current = null;
@@ -814,6 +944,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
814
944
  let nativeCaptureAttempts = 0;
815
945
  let captureRetryAttempted = false;
816
946
  let captureRetryReady = false;
947
+ let captureRetryTorchSuppressed = false;
948
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
949
+ let captureRetryRemountBackoffMs = 0;
817
950
  let lastNativeCaptureErrorMessage = null;
818
951
  try {
819
952
  const capturePhysicalOrientation = physicalOrientation;
@@ -822,6 +955,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
822
955
  component: 'scanner',
823
956
  error: 'capture_orientation_context',
824
957
  metadata: buildScannerTelemetryMetadata({
958
+ capture_attempt_id: captureAttemptId,
959
+ capture_sequence: captureSequence,
825
960
  capture_physical_orientation: capturePhysicalOrientation,
826
961
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
827
962
  capture_rotation_applied: 0,
@@ -879,13 +1014,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
879
1014
  lastNativeCaptureErrorMessage = normalized.message;
880
1015
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
881
1016
  captureRetryAttempted = true;
1017
+ captureRetryTorchSuppressed = !!enableTorch;
1018
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1019
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1020
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1021
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1022
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1023
+ : 0;
882
1024
  const retryAt = new Date().toISOString();
883
1025
  lastCaptureRetryAtRef.current = retryAt;
884
1026
  cameraRemountCountRef.current++;
885
- setCameraReady(false);
886
- cameraReadyRef.current = false;
887
- setCameraKey((key) => key + 1);
888
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1027
+ if (captureRetryTorchSuppressed) {
1028
+ setTorchSuppressedForRetry(true);
1029
+ }
1030
+ requestCameraRemount('capture_retry', captureRetryRemountBackoffMs > 0
1031
+ ? { backoffMs: captureRetryRemountBackoffMs }
1032
+ : undefined);
1033
+ await sleep(captureRetrySettleDelayMs);
889
1034
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
890
1035
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
891
1036
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -895,11 +1040,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
895
1040
  error: normalized,
896
1041
  errorCode: normalized.code,
897
1042
  metadata: buildScannerTelemetryMetadata({
1043
+ capture_attempt_id: captureAttemptId,
1044
+ capture_sequence: captureSequence,
898
1045
  native_capture_attempts: nativeCaptureAttempts,
899
1046
  capture_retry_attempted: captureRetryAttempted,
900
1047
  capture_retry_ready: captureRetryReady,
901
1048
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1049
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1050
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
902
1051
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1052
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
903
1053
  last_native_capture_error: lastNativeCaptureErrorMessage,
904
1054
  }),
905
1055
  });
@@ -967,6 +1117,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
967
1117
  telemetry?.track('image_processed', {
968
1118
  component: 'scanner',
969
1119
  metadata: buildScannerTelemetryMetadata({
1120
+ capture_attempt_id: captureAttemptId,
1121
+ capture_sequence: captureSequence,
970
1122
  original_width: origWidth,
971
1123
  original_height: origHeight,
972
1124
  processed_width: processedWidth,
@@ -976,6 +1128,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
976
1128
  native_capture_attempts: nativeCaptureAttempts,
977
1129
  capture_retry_attempted: captureRetryAttempted,
978
1130
  capture_retry_ready: captureRetryReady,
1131
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1132
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1133
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
979
1134
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
980
1135
  last_native_capture_error: lastNativeCaptureErrorMessage,
981
1136
  }),
@@ -1044,9 +1199,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1044
1199
  component: 'scanner',
1045
1200
  error,
1046
1201
  metadata: buildScannerTelemetryMetadata({
1202
+ capture_attempt_id: captureAttemptId,
1203
+ capture_sequence: captureSequence,
1047
1204
  native_capture_attempts: nativeCaptureAttempts,
1048
1205
  capture_retry_attempted: captureRetryAttempted,
1049
1206
  capture_retry_ready: captureRetryReady,
1207
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1208
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1209
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1050
1210
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1051
1211
  last_native_capture_error: lastNativeCaptureErrorMessage,
1052
1212
  }),
@@ -1056,7 +1216,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1056
1216
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1057
1217
  }
1058
1218
  }
1059
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1219
+ finally {
1220
+ setTorchSuppressedForRetry(false);
1221
+ }
1222
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1060
1223
  // Expose capture to parent via ref
1061
1224
  if (captureRef) {
1062
1225
  captureRef.current = handleCapture;
@@ -1068,7 +1231,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1068
1231
  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
1232
  }
1070
1233
  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) => {
1234
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1235
+ 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
1236
  setPhysicalOrientation(event.orientation);
1073
1237
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
1074
1238
  styles.titleText,
@@ -1139,7 +1303,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1139
1303
  overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
1140
1304
  (!cameraReady || status === 'capturing' || status === 'processing') &&
1141
1305
  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) }));
1306
+ ], 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
1307
  }
1144
1308
  const CORNER_SIZE = 30;
1145
1309
  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',
@@ -57,6 +57,8 @@ export interface VerificationListParams {
57
57
  export interface VerifyOptions {
58
58
  /** Idempotency key to prevent duplicate verifications on retry. */
59
59
  idempotencyKey?: string;
60
+ /** Optional primitive metadata attached to client-side request telemetry. */
61
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
60
62
  }
61
63
  export interface QueueItem {
62
64
  id: string;
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.2";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.0';
1
+ export const SDK_VERSION = '2.5.2';
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.2",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,6 +78,65 @@ interface RequestContext {
78
78
  method: string;
79
79
  }
80
80
 
81
+ type ClientTelemetryValue = string | number | boolean | null | undefined;
82
+ type ClientTelemetryMetadata = Record<string, ClientTelemetryValue>;
83
+
84
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
85
+ 'rideId',
86
+ 'vehicleType',
87
+ 'verificationAttemptId',
88
+ 'verificationAttempt',
89
+ 'verificationMaxAttempts',
90
+ 'verificationSource',
91
+ 'torchEnabled',
92
+ 'scannerSessionId',
93
+ ];
94
+
95
+ function toTelemetryKey(key: string): string {
96
+ return key
97
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
98
+ .replace(/[^a-zA-Z0-9]+/g, '_')
99
+ .replace(/^_+|_+$/g, '')
100
+ .toLowerCase();
101
+ }
102
+
103
+ function compactClientTelemetryMetadata(
104
+ metadata: ClientTelemetryMetadata = {},
105
+ ): Record<string, string | number> {
106
+ const compacted: Record<string, string | number> = {};
107
+ for (const [key, value] of Object.entries(metadata)) {
108
+ if (value === null || value === undefined) continue;
109
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
110
+ }
111
+ return compacted;
112
+ }
113
+
114
+ function buildVerifyTelemetryMetadata(
115
+ requestMetadata: Record<string, unknown> | undefined,
116
+ options?: VerifyOptions,
117
+ ): ClientTelemetryMetadata {
118
+ const metadata: ClientTelemetryMetadata = {
119
+ ...options?.telemetryMetadata,
120
+ };
121
+
122
+ if (options?.idempotencyKey) {
123
+ metadata.idempotency_key = options.idempotencyKey;
124
+ }
125
+
126
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
127
+ const value = requestMetadata?.[key];
128
+ if (
129
+ typeof value === 'string' ||
130
+ typeof value === 'number' ||
131
+ typeof value === 'boolean'
132
+ ) {
133
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
134
+ }
135
+ }
136
+
137
+ return metadata;
138
+ }
139
+
81
140
  export class VerifyAIClient {
82
141
  private apiKey: string;
83
142
  private baseUrl: string;
@@ -147,7 +206,8 @@ export class VerifyAIClient {
147
206
 
148
207
  private async executeRequest<T>(
149
208
  path: string,
150
- options: RequestInit = {}
209
+ options: RequestInit = {},
210
+ telemetryMetadata: ClientTelemetryMetadata = {},
151
211
  ): Promise<T> {
152
212
  const controller = new AbortController();
153
213
  const timer = setTimeout(() => controller.abort(), this.timeout);
@@ -218,6 +278,7 @@ export class VerifyAIClient {
218
278
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
219
279
  : normalized;
220
280
  const metadata: Record<string, string | number> = {
281
+ ...compactClientTelemetryMetadata(telemetryMetadata),
221
282
  method: context.method,
222
283
  path: context.path,
223
284
  status: normalized.status,
@@ -243,9 +304,10 @@ export class VerifyAIClient {
243
304
 
244
305
  private async request<T>(
245
306
  path: string,
246
- options: RequestInit = {}
307
+ options: RequestInit = {},
308
+ telemetryMetadata: ClientTelemetryMetadata = {},
247
309
  ): Promise<T> {
248
- return this.executeRequest<T>(path, options);
310
+ return this.executeRequest<T>(path, options, telemetryMetadata);
249
311
  }
250
312
 
251
313
  /**
@@ -276,11 +338,12 @@ export class VerifyAIClient {
276
338
  headers['Idempotency-Key'] = options.idempotencyKey;
277
339
  }
278
340
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
341
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
279
342
  return this.request<VerificationResult>('/verify', {
280
343
  method: 'POST',
281
344
  headers,
282
345
  body: JSON.stringify(enrichedRequest),
283
- });
346
+ }, telemetryMetadata);
284
347
  }
285
348
 
286
349
  /**
@@ -300,7 +363,8 @@ export class VerifyAIClient {
300
363
  name: 'photo.jpg',
301
364
  } as unknown as Blob);
302
365
  formData.append('policy', request.policy);
303
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
366
+ const enrichedMetadata = enrichMetadata(request.metadata);
367
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
304
368
  if (request.provider) {
305
369
  formData.append('provider', request.provider);
306
370
  }
@@ -316,12 +380,14 @@ export class VerifyAIClient {
316
380
  headers['Idempotency-Key'] = options.idempotencyKey;
317
381
  }
318
382
 
383
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
384
+
319
385
  try {
320
386
  return await this.executeRequest<VerificationResult>('/verify', {
321
387
  method: 'POST',
322
388
  headers,
323
389
  body: formData,
324
- });
390
+ }, telemetryMetadata);
325
391
  } catch (error) {
326
392
  // 5xx during multipart can mean the server crashed before reservation
327
393
  // (e.g. Vercel multipart-parse failure) OR after the verification was
@@ -342,7 +408,7 @@ export class VerifyAIClient {
342
408
  provider: request.provider,
343
409
  include_image_data: request.include_image_data,
344
410
  },
345
- options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined,
411
+ options,
346
412
  );
347
413
  } catch {
348
414
  // If the retry itself fails, throw the original error
@@ -45,10 +45,13 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
45
45
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
46
46
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
47
47
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
48
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
48
49
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
50
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
51
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
51
52
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
53
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
54
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
52
55
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
53
56
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
54
57
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -151,6 +154,10 @@ function sleep(ms: number): Promise<void> {
151
154
  return new Promise((resolve) => setTimeout(resolve, ms));
152
155
  }
153
156
 
157
+ function createScannerSessionId(): string {
158
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
159
+ }
160
+
154
161
  function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
155
162
  const message = error.message.toLowerCase();
156
163
  return (
@@ -309,6 +316,7 @@ export function VerifyAIScanner({
309
316
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
310
317
  const [cameraReady, setCameraReady] = useState(false);
311
318
  const [cameraKey, setCameraKey] = useState(0);
319
+ const [cameraMounted, setCameraMounted] = useState(true);
312
320
  const terminalResultRef = useRef<VerificationResult | null>(null);
313
321
  const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
314
322
  const terminalResultDeliveredRef = useRef(false);
@@ -321,6 +329,11 @@ export function VerifyAIScanner({
321
329
  const lastAppStateRemountAtRef = useRef<string | null>(null);
322
330
  const lastOrientationRemountAtRef = useRef<string | null>(null);
323
331
  const lastCaptureRetryAtRef = useRef<string | null>(null);
332
+ const cameraStartupStartedAtRef = useRef(Date.now());
333
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
334
+ const cameraStartupSlowTrackedRef = useRef(false);
335
+ const cameraStartupReadyTrackedRef = useRef(false);
336
+ const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
324
337
  const cameraRemountCountRef = useRef(0);
325
338
  const startupWatchdogRemountCountRef = useRef(0);
326
339
  const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
@@ -361,17 +374,25 @@ export function VerifyAIScanner({
361
374
 
362
375
  physicalOrientationRef.current = physicalOrientation;
363
376
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
377
+ const scannerSessionIdRef = useRef(createScannerSessionId());
378
+ const captureSequenceRef = useRef(0);
379
+ const torchRetrySuppressedRef = useRef(false);
380
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
364
381
 
365
382
  const buildScannerTelemetryMetadata = useCallback((
366
383
  extra: Record<string, string | number | boolean | null | undefined> = {},
367
384
  ): ScannerTelemetryMetadata => compactTelemetryMetadata({
385
+ scanner_session_id: scannerSessionIdRef.current,
368
386
  policy,
369
387
  status,
370
388
  camera_ready: cameraReadyRef.current,
371
389
  camera_ever_ready: cameraEverReadyRef.current,
372
390
  camera_init_failed: cameraInitFailedRef.current,
373
391
  camera_key: cameraKey,
392
+ camera_mounted: cameraMounted ? 1 : 0,
374
393
  camera_remount_count: cameraRemountCountRef.current,
394
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
395
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
375
396
  terminated,
376
397
  exhausted,
377
398
  app_state: appStateRef.current,
@@ -389,6 +410,8 @@ export function VerifyAIScanner({
389
410
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
390
411
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
391
412
  last_capture_retry_at: lastCaptureRetryAtRef.current,
413
+ requested_torch_enabled: enableTorch ? 1 : 0,
414
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
392
415
  android_native_orientation_subscription_active: 0,
393
416
  android_native_orientation_event_count: 0,
394
417
  android_native_orientation_change_count: 0,
@@ -425,7 +448,9 @@ export function VerifyAIScanner({
425
448
  ...extra,
426
449
  }), [
427
450
  cameraKey,
451
+ cameraMounted,
428
452
  exhausted,
453
+ enableTorch,
429
454
  isLandscape,
430
455
  overlayRotationDeg,
431
456
  physicalOrientation,
@@ -441,6 +466,11 @@ export function VerifyAIScanner({
441
466
  telemetryRef.current = telemetry;
442
467
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
443
468
 
469
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed: boolean) => {
470
+ torchRetrySuppressedRef.current = nextSuppressed;
471
+ setTorchRetrySuppressed(nextSuppressed);
472
+ }, []);
473
+
444
474
  useEffect(() => {
445
475
  const reporter = telemetryRef.current;
446
476
  if (reporter) {
@@ -476,6 +506,53 @@ export function VerifyAIScanner({
476
506
  };
477
507
  }, []);
478
508
 
509
+ const requestCameraRemount = useCallback((
510
+ reason: string,
511
+ opts: {
512
+ backoffMs?: number;
513
+ log?: boolean;
514
+ } = {},
515
+ ) => {
516
+ if (startupRemountTimerRef.current) {
517
+ clearTimeout(startupRemountTimerRef.current);
518
+ startupRemountTimerRef.current = null;
519
+ }
520
+
521
+ setCameraReady(false);
522
+ cameraReadyRef.current = false;
523
+ cameraStartupAttemptStartedAtRef.current = Date.now();
524
+
525
+ if (opts.log) {
526
+ console.warn(
527
+ `VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
528
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
529
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`,
530
+ );
531
+ }
532
+
533
+ const remount = () => {
534
+ setCameraKey((k) => k + 1);
535
+ setCameraMounted(true);
536
+ startupRemountTimerRef.current = null;
537
+ };
538
+
539
+ if ((opts.backoffMs ?? 0) > 0) {
540
+ setCameraMounted(false);
541
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
542
+ return;
543
+ }
544
+
545
+ setCameraMounted(true);
546
+ remount();
547
+ }, [enableTorch]);
548
+
549
+ useEffect(() => () => {
550
+ if (startupRemountTimerRef.current) {
551
+ clearTimeout(startupRemountTimerRef.current);
552
+ startupRemountTimerRef.current = null;
553
+ }
554
+ }, []);
555
+
479
556
  useEffect(() => {
480
557
  if (Platform.OS !== 'android') return;
481
558
 
@@ -728,17 +805,9 @@ export function VerifyAIScanner({
728
805
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
729
806
  },
730
807
  });
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);
808
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
740
809
  }
741
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
810
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
742
811
 
743
812
  // Resume camera when app returns from background/inactive (e.g. notification bar)
744
813
  useEffect(() => {
@@ -769,13 +838,11 @@ export function VerifyAIScanner({
769
838
  remount_requested_at: at,
770
839
  }),
771
840
  });
772
- setCameraReady(false);
773
- cameraReadyRef.current = false;
774
- setCameraKey((k) => k + 1);
841
+ requestCameraRemount('appstate_active');
775
842
  });
776
843
 
777
844
  return () => subscription.remove();
778
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
845
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
779
846
 
780
847
  const pausePreview = useCallback(() => {
781
848
  cameraRef.current?.pausePreview?.().catch(() => {});
@@ -836,13 +903,36 @@ export function VerifyAIScanner({
836
903
 
837
904
  // Camera init callbacks
838
905
  const onCameraReady = useCallback(() => {
839
- lastCameraReadyAtRef.current = new Date().toISOString();
906
+ const readyAt = new Date().toISOString();
907
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
908
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
909
+ lastCameraReadyAtRef.current = readyAt;
840
910
  setCameraReady(true);
841
911
  cameraReadyRef.current = true;
842
912
  cameraEverReadyRef.current = true;
843
913
  cameraInitFailedRef.current = false;
844
914
  startupWatchdogRemountCountRef.current = 0;
845
- }, []);
915
+ if (!cameraStartupReadyTrackedRef.current) {
916
+ cameraStartupReadyTrackedRef.current = true;
917
+ console.log(
918
+ `VerifyAI[${SDK_VERSION}]: camera ready ` +
919
+ `startupTotalMs=${startupTotalElapsedMs} ` +
920
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
921
+ `torchRequested=${enableTorch ? 1 : 0}`,
922
+ );
923
+ telemetry?.track('camera_ready', {
924
+ component: 'scanner',
925
+ error: 'camera_ready',
926
+ metadata: buildScannerTelemetryMetadata({
927
+ camera_ready_at: readyAt,
928
+ startup_total_elapsed_ms: startupTotalElapsedMs,
929
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
930
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
931
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
932
+ }),
933
+ });
934
+ }
935
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
846
936
 
847
937
  const onMountError = useCallback((event: { message?: string }) => {
848
938
  const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
@@ -865,12 +955,68 @@ export function VerifyAIScanner({
865
955
  });
866
956
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
867
957
 
958
+ useEffect(() => {
959
+ if (
960
+ !permission?.granted ||
961
+ terminated ||
962
+ currentAppState !== 'active' ||
963
+ !cameraMounted ||
964
+ cameraReadyRef.current ||
965
+ cameraInitFailedRef.current ||
966
+ cameraStartupSlowTrackedRef.current
967
+ ) {
968
+ return;
969
+ }
970
+
971
+ const timer = setTimeout(() => {
972
+ if (
973
+ appStateRef.current !== 'active' ||
974
+ cameraReadyRef.current ||
975
+ cameraInitFailedRef.current ||
976
+ cameraStartupSlowTrackedRef.current
977
+ ) {
978
+ return;
979
+ }
980
+
981
+ cameraStartupSlowTrackedRef.current = true;
982
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
983
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
984
+ console.warn(
985
+ `VerifyAI[${SDK_VERSION}]: camera startup slow ` +
986
+ `startupTotalMs=${startupTotalElapsedMs} ` +
987
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
988
+ `torchRequested=${enableTorch ? 1 : 0}`,
989
+ );
990
+ telemetry?.track('camera_startup_slow', {
991
+ component: 'scanner',
992
+ error: 'camera_startup_slow',
993
+ metadata: buildScannerTelemetryMetadata({
994
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
995
+ startup_total_elapsed_ms: startupTotalElapsedMs,
996
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
997
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
998
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
999
+ }),
1000
+ });
1001
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
1002
+
1003
+ return () => clearTimeout(timer);
1004
+ }, [
1005
+ permission?.granted,
1006
+ terminated,
1007
+ currentAppState,
1008
+ cameraMounted,
1009
+ buildScannerTelemetryMetadata,
1010
+ enableTorch,
1011
+ telemetry,
1012
+ ]);
1013
+
868
1014
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
869
1015
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
870
1016
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
871
1017
  // recreate the CameraView, which starts a fresh native session.
872
1018
  useEffect(() => {
873
- if (!permission?.granted || terminated || currentAppState !== 'active') return;
1019
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
874
1020
 
875
1021
  const watchdogMs = Platform.OS === 'android'
876
1022
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -904,11 +1050,14 @@ export function VerifyAIScanner({
904
1050
  remount_reason: 'startup_watchdog',
905
1051
  startup_watchdog_remount_count: startupWatchdogRemountCount,
906
1052
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
1053
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1054
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
907
1055
  }),
908
1056
  });
909
- setCameraReady(false);
910
- cameraReadyRef.current = false;
911
- setCameraKey((k) => k + 1);
1057
+ requestCameraRemount('startup_watchdog', {
1058
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1059
+ log: true,
1060
+ });
912
1061
  }
913
1062
  }
914
1063
  }, watchdogMs);
@@ -918,9 +1067,12 @@ export function VerifyAIScanner({
918
1067
  permission?.granted,
919
1068
  terminated,
920
1069
  currentAppState,
1070
+ cameraMounted,
921
1071
  cameraKey,
922
1072
  buildScannerTelemetryMetadata,
1073
+ enableTorch,
923
1074
  onMountError,
1075
+ requestCameraRemount,
924
1076
  telemetry,
925
1077
  ]);
926
1078
 
@@ -941,6 +1093,9 @@ export function VerifyAIScanner({
941
1093
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
942
1094
 
943
1095
  const handleCapture = useCallback(async () => {
1096
+ const captureSequence = captureSequenceRef.current + 1;
1097
+ captureSequenceRef.current = captureSequence;
1098
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
944
1099
  const blockedReason =
945
1100
  !cameraRef.current
946
1101
  ? 'camera_ref_null'
@@ -960,6 +1115,8 @@ export function VerifyAIScanner({
960
1115
  component: 'scanner',
961
1116
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
962
1117
  metadata: buildScannerTelemetryMetadata({
1118
+ capture_attempt_id: captureAttemptId,
1119
+ capture_sequence: captureSequence,
963
1120
  capture_blocked_reason: blockedReason,
964
1121
  }),
965
1122
  });
@@ -985,6 +1142,7 @@ export function VerifyAIScanner({
985
1142
  }
986
1143
 
987
1144
  setStatus('capturing');
1145
+ setTorchSuppressedForRetry(false);
988
1146
  setResult(null);
989
1147
  setLastError(null);
990
1148
  terminalResultRef.current = null;
@@ -997,6 +1155,9 @@ export function VerifyAIScanner({
997
1155
  let nativeCaptureAttempts = 0;
998
1156
  let captureRetryAttempted = false;
999
1157
  let captureRetryReady = false;
1158
+ let captureRetryTorchSuppressed = false;
1159
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
1160
+ let captureRetryRemountBackoffMs = 0;
1000
1161
  let lastNativeCaptureErrorMessage: string | null = null;
1001
1162
 
1002
1163
  try {
@@ -1006,6 +1167,8 @@ export function VerifyAIScanner({
1006
1167
  component: 'scanner',
1007
1168
  error: 'capture_orientation_context',
1008
1169
  metadata: buildScannerTelemetryMetadata({
1170
+ capture_attempt_id: captureAttemptId,
1171
+ capture_sequence: captureSequence,
1009
1172
  capture_physical_orientation: capturePhysicalOrientation,
1010
1173
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
1011
1174
  capture_rotation_applied: 0,
@@ -1074,13 +1237,26 @@ export function VerifyAIScanner({
1074
1237
 
1075
1238
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
1076
1239
  captureRetryAttempted = true;
1240
+ captureRetryTorchSuppressed = !!enableTorch;
1241
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1242
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1243
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1244
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1245
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1246
+ : 0;
1077
1247
  const retryAt = new Date().toISOString();
1078
1248
  lastCaptureRetryAtRef.current = retryAt;
1079
1249
  cameraRemountCountRef.current++;
1080
- setCameraReady(false);
1081
- cameraReadyRef.current = false;
1082
- setCameraKey((key) => key + 1);
1083
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1250
+ if (captureRetryTorchSuppressed) {
1251
+ setTorchSuppressedForRetry(true);
1252
+ }
1253
+ requestCameraRemount(
1254
+ 'capture_retry',
1255
+ captureRetryRemountBackoffMs > 0
1256
+ ? { backoffMs: captureRetryRemountBackoffMs }
1257
+ : undefined,
1258
+ );
1259
+ await sleep(captureRetrySettleDelayMs);
1084
1260
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1085
1261
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1086
1262
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -1090,11 +1266,16 @@ export function VerifyAIScanner({
1090
1266
  error: normalized,
1091
1267
  errorCode: normalized.code,
1092
1268
  metadata: buildScannerTelemetryMetadata({
1269
+ capture_attempt_id: captureAttemptId,
1270
+ capture_sequence: captureSequence,
1093
1271
  native_capture_attempts: nativeCaptureAttempts,
1094
1272
  capture_retry_attempted: captureRetryAttempted,
1095
1273
  capture_retry_ready: captureRetryReady,
1096
1274
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1275
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1276
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1097
1277
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1278
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1098
1279
  last_native_capture_error: lastNativeCaptureErrorMessage,
1099
1280
  }),
1100
1281
  });
@@ -1188,6 +1369,8 @@ export function VerifyAIScanner({
1188
1369
  telemetry?.track('image_processed', {
1189
1370
  component: 'scanner',
1190
1371
  metadata: buildScannerTelemetryMetadata({
1372
+ capture_attempt_id: captureAttemptId,
1373
+ capture_sequence: captureSequence,
1191
1374
  original_width: origWidth,
1192
1375
  original_height: origHeight,
1193
1376
  processed_width: processedWidth,
@@ -1197,6 +1380,9 @@ export function VerifyAIScanner({
1197
1380
  native_capture_attempts: nativeCaptureAttempts,
1198
1381
  capture_retry_attempted: captureRetryAttempted,
1199
1382
  capture_retry_ready: captureRetryReady,
1383
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1384
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1385
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1200
1386
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1201
1387
  last_native_capture_error: lastNativeCaptureErrorMessage,
1202
1388
  }),
@@ -1271,9 +1457,14 @@ export function VerifyAIScanner({
1271
1457
  component: 'scanner',
1272
1458
  error,
1273
1459
  metadata: buildScannerTelemetryMetadata({
1460
+ capture_attempt_id: captureAttemptId,
1461
+ capture_sequence: captureSequence,
1274
1462
  native_capture_attempts: nativeCaptureAttempts,
1275
1463
  capture_retry_attempted: captureRetryAttempted,
1276
1464
  capture_retry_ready: captureRetryReady,
1465
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1466
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1467
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1277
1468
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1278
1469
  last_native_capture_error: lastNativeCaptureErrorMessage,
1279
1470
  }),
@@ -1284,8 +1475,10 @@ export function VerifyAIScanner({
1284
1475
  if (!terminalRequestError) {
1285
1476
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1286
1477
  }
1478
+ } finally {
1479
+ setTorchSuppressedForRetry(false);
1287
1480
  }
1288
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1481
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1289
1482
 
1290
1483
  // Expose capture to parent via ref
1291
1484
  if (captureRef) {
@@ -1308,22 +1501,24 @@ export function VerifyAIScanner({
1308
1501
  }
1309
1502
 
1310
1503
  const showBottomCard = status === 'success' || status === 'error';
1504
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1311
1505
 
1312
1506
  return (
1313
1507
  <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
- >
1508
+ {cameraMounted && (
1509
+ <CameraView
1510
+ key={cameraKey}
1511
+ ref={cameraRef}
1512
+ style={styles.camera}
1513
+ facing="back"
1514
+ enableTorch={shouldEnableTorch}
1515
+ onCameraReady={onCameraReady}
1516
+ onMountError={onMountError}
1517
+ responsiveOrientationWhenOrientationLocked
1518
+ onResponsiveOrientationChanged={(event) => {
1519
+ setPhysicalOrientation(event.orientation);
1520
+ }}
1521
+ >
1327
1522
  {/* Overlay */}
1328
1523
  <View style={styles.overlay}>
1329
1524
  {overlay?.title && (
@@ -1527,7 +1722,8 @@ export function VerifyAIScanner({
1527
1722
  </TouchableOpacity>
1528
1723
  )}
1529
1724
  </View>
1530
- </CameraView>
1725
+ </CameraView>
1726
+ )}
1531
1727
  </View>
1532
1728
  );
1533
1729
  }
@@ -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',
@@ -64,6 +64,8 @@ export interface VerificationListParams {
64
64
  export interface VerifyOptions {
65
65
  /** Idempotency key to prevent duplicate verifications on retry. */
66
66
  idempotencyKey?: string;
67
+ /** Optional primitive metadata attached to client-side request telemetry. */
68
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
67
69
  }
68
70
 
69
71
  export interface QueueItem {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.0';
1
+ export const SDK_VERSION = '2.5.2';