@switchlabs/verify-ai-react-native 2.4.14 → 2.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,11 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
16
16
  const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
17
17
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
18
18
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
19
+ const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
20
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
21
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
22
+ const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
23
+ const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
19
24
  function getPolicyScannerDefaults(policy) {
20
25
  const id = policy?.toLowerCase() ?? '';
21
26
  const isForest = id.includes('forest') || id.includes('humanforest');
@@ -59,14 +64,43 @@ function createScannerError(message, code, name) {
59
64
  error.code = code;
60
65
  return error;
61
66
  }
67
+ function sleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+ function isNativeCameraCaptureError(error) {
71
+ const message = error.message.toLowerCase();
72
+ return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
73
+ error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
74
+ message === 'failed to capture photo' ||
75
+ message === 'failed to capture image' ||
76
+ message === 'image could not be captured');
77
+ }
62
78
  function normalizeScannerError(err) {
63
79
  const error = (err instanceof Error ? err : new Error(String(err)));
64
- if (!error.code && error.message === 'Failed to capture photo') {
80
+ if (!error.code && isNativeCameraCaptureError(error)) {
65
81
  error.name = 'CameraCaptureError';
66
82
  error.code = CAMERA_CAPTURE_ERROR_CODE;
67
83
  }
68
84
  return error;
69
85
  }
86
+ function compactTelemetryMetadata(metadata) {
87
+ const compacted = {};
88
+ for (const [key, value] of Object.entries(metadata)) {
89
+ if (value === null || value === undefined)
90
+ continue;
91
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
92
+ }
93
+ return compacted;
94
+ }
95
+ function getPlatformConstantString(...keys) {
96
+ const constants = Platform.constants;
97
+ for (const key of keys) {
98
+ const value = constants[key];
99
+ if (typeof value === 'string' && value.trim())
100
+ return value;
101
+ }
102
+ return null;
103
+ }
70
104
  function getErrorDisplay(error, showTechnicalDetails) {
71
105
  if (!error) {
72
106
  return {
@@ -148,7 +182,7 @@ function isTerminalRequestError(error) {
148
182
  * />
149
183
  * ```
150
184
  */
151
- export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
185
+ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, telemetryContext, }) {
152
186
  const contextTelemetry = useTelemetry();
153
187
  const telemetry = telemetryProp ?? contextTelemetry;
154
188
  const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
@@ -169,6 +203,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
169
203
  const cameraEverReadyRef = useRef(false);
170
204
  const cameraInitFailedRef = useRef(false);
171
205
  const permissionDeniedTrackedRef = useRef(false);
206
+ const lastCameraReadyAtRef = useRef(null);
207
+ const lastAppStateRemountAtRef = useRef(null);
208
+ const lastOrientationRemountAtRef = useRef(null);
209
+ const lastCaptureRetryAtRef = useRef(null);
210
+ const cameraRemountCountRef = useRef(0);
211
+ const androidOrientationSubscriptionActiveRef = useRef(false);
212
+ const androidOrientationEventCountRef = useRef(0);
213
+ const androidOrientationChangeCountRef = useRef(0);
214
+ const androidOrientationStartedAtRef = useRef(null);
215
+ const lastAndroidOrientationEventAtRef = useRef(null);
216
+ const lastAndroidOrientationTelemetryAtRef = useRef(0);
217
+ const lastAndroidOrientationXRef = useRef(null);
218
+ const lastAndroidOrientationYRef = useRef(null);
219
+ const lastAndroidOrientationZRef = useRef(null);
220
+ const lastAndroidOrientationDerivedRef = useRef(null);
221
+ const lastAndroidOrientationIgnoredReasonRef = useRef(null);
222
+ const lastAndroidOrientationErrorAtRef = useRef(null);
223
+ const lastAndroidOrientationErrorRef = useRef(null);
172
224
  // Track dimensions for orientation detection and responsive layout
173
225
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
174
226
  const isLandscape = windowWidth > windowHeight;
@@ -179,12 +231,106 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
179
231
  // uses the accelerometer via expo-sensors (the callback is iOS-only). Either
180
232
  // way we rotate the overlay UI to stay readable from the user's viewpoint.
181
233
  const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
234
+ const overlayRotationDeg = (() => {
235
+ switch (physicalOrientation) {
236
+ case 'landscapeLeft':
237
+ return 90;
238
+ case 'landscapeRight':
239
+ return -90;
240
+ case 'portraitUpsideDown':
241
+ return 180;
242
+ case 'portrait':
243
+ default:
244
+ return 0;
245
+ }
246
+ })();
247
+ const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
248
+ policy,
249
+ status,
250
+ camera_ready: cameraReadyRef.current,
251
+ camera_ever_ready: cameraEverReadyRef.current,
252
+ camera_init_failed: cameraInitFailedRef.current,
253
+ camera_key: cameraKey,
254
+ camera_remount_count: cameraRemountCountRef.current,
255
+ terminated,
256
+ exhausted,
257
+ app_state: AppState.currentState,
258
+ sdk_platform: Platform.OS,
259
+ device_model: getPlatformConstantString('Model', 'model'),
260
+ device_os_version: String(Platform.Version),
261
+ route_name: telemetryContext?.routeName,
262
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
263
+ window_width: windowWidth,
264
+ window_height: windowHeight,
265
+ interface_orientation: isLandscape ? 'landscape' : 'portrait',
266
+ physical_orientation: physicalOrientation,
267
+ overlay_rotation_deg: overlayRotationDeg,
268
+ last_camera_ready_at: lastCameraReadyAtRef.current,
269
+ last_appstate_remount_at: lastAppStateRemountAtRef.current,
270
+ last_orientation_remount_at: lastOrientationRemountAtRef.current,
271
+ last_capture_retry_at: lastCaptureRetryAtRef.current,
272
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
273
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
274
+ android_orientation_event_count: androidOrientationEventCountRef.current,
275
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
276
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
277
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
278
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
279
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
280
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
281
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
282
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
283
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
284
+ ...extra,
285
+ }), [
286
+ cameraKey,
287
+ exhausted,
288
+ isLandscape,
289
+ overlayRotationDeg,
290
+ physicalOrientation,
291
+ policy,
292
+ status,
293
+ telemetryContext?.isPortraitLocked,
294
+ telemetryContext?.routeName,
295
+ terminated,
296
+ windowHeight,
297
+ windowWidth,
298
+ ]);
182
299
  useEffect(() => {
183
300
  if (Platform.OS !== 'android')
184
301
  return;
185
302
  let subscription = null;
186
303
  let cancelled = false;
304
+ let noEventTimer = null;
187
305
  let lastOrientation = 'portrait';
306
+ const androidMetadata = (extra = {}) => compactTelemetryMetadata({
307
+ policy,
308
+ sdk_platform: Platform.OS,
309
+ device_model: getPlatformConstantString('Model', 'model'),
310
+ device_os_version: String(Platform.Version),
311
+ route_name: telemetryContext?.routeName,
312
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
313
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
314
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
315
+ android_orientation_event_count: androidOrientationEventCountRef.current,
316
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
317
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
318
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
319
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
320
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
321
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
322
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
323
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
324
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
325
+ ...extra,
326
+ });
327
+ const trackAndroidOrientationEvent = (eventType, error, metadata = {}) => {
328
+ telemetry?.track(eventType, {
329
+ component: 'scanner',
330
+ error,
331
+ metadata: androidMetadata(metadata),
332
+ });
333
+ };
188
334
  (async () => {
189
335
  let Accelerometer = null;
190
336
  try {
@@ -192,14 +338,49 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
192
338
  const mod = require('expo-sensors');
193
339
  Accelerometer = mod?.Accelerometer ?? null;
194
340
  }
195
- catch {
341
+ catch (err) {
342
+ const error = normalizeScannerError(err);
343
+ lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
344
+ lastAndroidOrientationErrorRef.current = error.message;
345
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
196
346
  return;
197
347
  }
198
- if (cancelled || !Accelerometer)
348
+ if (cancelled)
349
+ return;
350
+ if (!Accelerometer) {
351
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', 'expo-sensors Accelerometer is unavailable');
199
352
  return;
353
+ }
354
+ const startedAt = new Date().toISOString();
355
+ androidOrientationStartedAtRef.current = startedAt;
356
+ androidOrientationSubscriptionActiveRef.current = true;
357
+ androidOrientationEventCountRef.current = 0;
358
+ androidOrientationChangeCountRef.current = 0;
359
+ lastAndroidOrientationTelemetryAtRef.current = 0;
360
+ trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
361
+ accelerometer_update_interval_ms: 500,
362
+ accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
363
+ });
364
+ noEventTimer = setTimeout(() => {
365
+ if (cancelled || androidOrientationEventCountRef.current > 0)
366
+ return;
367
+ trackAndroidOrientationEvent('camera_android_accelerometer_no_events', `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`);
368
+ }, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
200
369
  Accelerometer.setUpdateInterval(500);
201
- subscription = Accelerometer.addListener(({ x, y }) => {
202
- let next;
370
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
371
+ const at = new Date();
372
+ const atIso = at.toISOString();
373
+ androidOrientationEventCountRef.current++;
374
+ lastAndroidOrientationEventAtRef.current = atIso;
375
+ lastAndroidOrientationXRef.current = Number(x.toFixed(4));
376
+ lastAndroidOrientationYRef.current = Number(y.toFixed(4));
377
+ lastAndroidOrientationZRef.current = Number(z.toFixed(4));
378
+ if (noEventTimer) {
379
+ clearTimeout(noEventTimer);
380
+ noEventTimer = null;
381
+ }
382
+ let next = null;
383
+ let ignoredReason = null;
203
384
  if (Math.abs(x) > Math.abs(y) + 0.2) {
204
385
  next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
205
386
  }
@@ -207,32 +388,38 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
207
388
  next = y > 0 ? 'portraitUpsideDown' : 'portrait';
208
389
  }
209
390
  else {
210
- return; // ambiguous tilt — ignore
391
+ ignoredReason = 'ambiguous_tilt';
211
392
  }
212
- if (next !== lastOrientation) {
393
+ lastAndroidOrientationDerivedRef.current = next;
394
+ lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
395
+ if (next && next !== lastOrientation) {
396
+ const previous = lastOrientation;
213
397
  lastOrientation = next;
398
+ androidOrientationChangeCountRef.current++;
214
399
  setPhysicalOrientation(next);
400
+ trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
401
+ previous_physical_orientation: previous,
402
+ next_physical_orientation: next,
403
+ accelerometer_sampled_at: atIso,
404
+ });
405
+ return;
406
+ }
407
+ const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
408
+ at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
409
+ if (shouldTrackSample) {
410
+ lastAndroidOrientationTelemetryAtRef.current = at.getTime();
411
+ trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
215
412
  }
216
413
  });
217
414
  })();
218
415
  return () => {
219
416
  cancelled = true;
417
+ androidOrientationSubscriptionActiveRef.current = false;
418
+ if (noEventTimer)
419
+ clearTimeout(noEventTimer);
220
420
  subscription?.remove();
221
421
  };
222
- }, []);
223
- const overlayRotationDeg = (() => {
224
- switch (physicalOrientation) {
225
- case 'landscapeLeft':
226
- return 90;
227
- case 'landscapeRight':
228
- return -90;
229
- case 'portraitUpsideDown':
230
- return 180;
231
- case 'portrait':
232
- default:
233
- return 0;
234
- }
235
- })();
422
+ }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
236
423
  // Detect orientation changes and remount camera after rotation settles.
237
424
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
238
425
  // animation — the native preview layer initializes with transitional bounds.
@@ -242,9 +429,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
242
429
  const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
243
430
  prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
244
431
  if (orientationChanged && !terminated) {
432
+ const at = new Date().toISOString();
433
+ lastOrientationRemountAtRef.current = at;
434
+ cameraRemountCountRef.current++;
245
435
  telemetry?.track('camera_orientation_remount', {
246
436
  component: 'scanner',
247
437
  metadata: {
438
+ ...buildScannerTelemetryMetadata({
439
+ remount_reason: 'orientation_change',
440
+ remount_requested_at: at,
441
+ }),
248
442
  from: prev.width > prev.height ? 'landscape' : 'portrait',
249
443
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
250
444
  },
@@ -258,17 +452,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
258
452
  }, 400);
259
453
  return () => clearTimeout(timer);
260
454
  }
261
- }, [windowWidth, windowHeight, terminated]);
455
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
262
456
  // Resume camera when app returns from background/inactive (e.g. notification bar)
263
457
  useEffect(() => {
264
458
  if (terminated)
265
459
  return;
266
460
  const subscription = AppState.addEventListener('change', (nextState) => {
267
461
  if (nextState === 'active') {
462
+ const at = new Date().toISOString();
463
+ lastAppStateRemountAtRef.current = at;
464
+ cameraRemountCountRef.current++;
268
465
  // Force camera remount — on iOS, AVCaptureSession often fails to resume
269
466
  // its preview layer after returning from the notification bar or control center.
270
467
  telemetry?.track('camera_appstate_remount', {
271
468
  component: 'scanner',
469
+ metadata: buildScannerTelemetryMetadata({
470
+ remount_reason: 'appstate_active',
471
+ remount_requested_at: at,
472
+ }),
272
473
  });
273
474
  setCameraReady(false);
274
475
  cameraReadyRef.current = false;
@@ -276,7 +477,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
276
477
  }
277
478
  });
278
479
  return () => subscription.remove();
279
- }, [terminated]);
480
+ }, [terminated, buildScannerTelemetryMetadata, telemetry]);
280
481
  const pausePreview = useCallback(() => {
281
482
  cameraRef.current?.pausePreview?.().catch(() => { });
282
483
  }, []);
@@ -330,6 +531,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
330
531
  }, [onClose, result]);
331
532
  // Camera init callbacks
332
533
  const onCameraReady = useCallback(() => {
534
+ lastCameraReadyAtRef.current = new Date().toISOString();
333
535
  setCameraReady(true);
334
536
  cameraReadyRef.current = true;
335
537
  cameraEverReadyRef.current = true;
@@ -347,15 +549,21 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
347
549
  telemetry?.track('camera_init_failure', {
348
550
  component: 'scanner',
349
551
  error,
552
+ metadata: buildScannerTelemetryMetadata({
553
+ mount_error_message: event.message,
554
+ }),
350
555
  });
351
- }, [onError, telemetry]);
352
- // Startup watchdog — if camera hasn't fired onCameraReady within 3s, force remount.
556
+ }, [buildScannerTelemetryMetadata, onError, telemetry]);
557
+ // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
353
558
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
354
559
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
355
560
  // recreate the CameraView, which starts a fresh native session.
356
561
  useEffect(() => {
357
562
  if (!permission?.granted || terminated)
358
563
  return;
564
+ const watchdogMs = Platform.OS === 'android'
565
+ ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
566
+ : IOS_CAMERA_STARTUP_WATCHDOG_MS;
359
567
  const timer = setTimeout(() => {
360
568
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
361
569
  // Only track + remount on first-ever mount. A rotation/app-resume
@@ -364,18 +572,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
364
572
  // surfacing new information. If the camera truly stays broken the
365
573
  // user will see capture failures, which is a more meaningful signal.
366
574
  if (!cameraEverReadyRef.current) {
575
+ cameraRemountCountRef.current++;
367
576
  telemetry?.track('camera_preview_timeout', {
368
577
  component: 'scanner',
369
- error: 'Camera did not initialize within 3 seconds — remounting',
578
+ error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
579
+ metadata: buildScannerTelemetryMetadata({
580
+ watchdog_ms: watchdogMs,
581
+ remount_reason: 'startup_watchdog',
582
+ }),
370
583
  });
371
584
  setCameraReady(false);
372
585
  cameraReadyRef.current = false;
373
586
  setCameraKey((k) => k + 1);
374
587
  }
375
588
  }
376
- }, 3000);
589
+ }, watchdogMs);
377
590
  return () => clearTimeout(timer);
378
- }, [permission?.granted, terminated, cameraKey, telemetry]);
591
+ }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
379
592
  // Track permission denied
380
593
  useEffect(() => {
381
594
  if (permission &&
@@ -383,13 +596,36 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
383
596
  permission.canAskAgain === false &&
384
597
  !permissionDeniedTrackedRef.current) {
385
598
  permissionDeniedTrackedRef.current = true;
386
- telemetry?.track('camera_permission_denied', { component: 'scanner' });
599
+ telemetry?.track('camera_permission_denied', {
600
+ component: 'scanner',
601
+ metadata: buildScannerTelemetryMetadata(),
602
+ });
387
603
  }
388
- }, [permission, telemetry]);
604
+ }, [buildScannerTelemetryMetadata, permission, telemetry]);
389
605
  const handleCapture = useCallback(async () => {
390
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
606
+ const blockedReason = !cameraRef.current
607
+ ? 'camera_ref_null'
608
+ : !cameraReadyRef.current
609
+ ? 'camera_not_ready'
610
+ : status === 'capturing'
611
+ ? 'already_capturing'
612
+ : status === 'processing'
613
+ ? 'already_processing'
614
+ : terminated
615
+ ? 'terminated'
616
+ : exhausted
617
+ ? 'exhausted'
618
+ : null;
619
+ telemetry?.track('camera_capture_request', {
620
+ component: 'scanner',
621
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
622
+ metadata: buildScannerTelemetryMetadata({
623
+ capture_blocked_reason: blockedReason,
624
+ }),
625
+ });
626
+ if (blockedReason && blockedReason !== 'camera_not_ready')
391
627
  return;
392
- if (!cameraReadyRef.current) {
628
+ if (blockedReason === 'camera_not_ready') {
393
629
  const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
394
630
  setResult(null);
395
631
  setLastError(error);
@@ -398,6 +634,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
398
634
  telemetry?.track('camera_not_ready', {
399
635
  component: 'scanner',
400
636
  error,
637
+ metadata: buildScannerTelemetryMetadata(),
401
638
  });
402
639
  setTimeout(() => setStatus('idle'), 2000);
403
640
  return;
@@ -411,7 +648,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
411
648
  clearTimeout(terminalResultTimerRef.current);
412
649
  terminalResultTimerRef.current = null;
413
650
  }
651
+ let nativeCaptureAttempts = 0;
652
+ let captureRetryAttempted = false;
653
+ let captureRetryReady = false;
654
+ let lastNativeCaptureErrorMessage = null;
414
655
  try {
656
+ const capturePhysicalOrientation = physicalOrientation;
657
+ const captureOverlayRotationDeg = overlayRotationDeg;
658
+ telemetry?.track('camera_capture_orientation_context', {
659
+ component: 'scanner',
660
+ error: 'capture_orientation_context',
661
+ metadata: buildScannerTelemetryMetadata({
662
+ capture_physical_orientation: capturePhysicalOrientation,
663
+ capture_overlay_rotation_deg: captureOverlayRotationDeg,
664
+ capture_rotation_applied: 0,
665
+ }),
666
+ });
415
667
  // --- Capture + best-effort resize ---
416
668
  // Strategy: try to dynamically import expo-image-manipulator.
417
669
  // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
@@ -430,12 +682,69 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
430
682
  catch {
431
683
  // Not installed — fall back to camera-only base64 below
432
684
  }
685
+ const waitForCameraReady = async (timeoutMs) => {
686
+ const start = Date.now();
687
+ while (Date.now() - start < timeoutMs) {
688
+ if (cameraReadyRef.current && cameraRef.current) {
689
+ return true;
690
+ }
691
+ await sleep(100);
692
+ }
693
+ return cameraReadyRef.current && !!cameraRef.current;
694
+ };
695
+ const takePictureWithRetry = async (options, requiredField) => {
696
+ let lastError = null;
697
+ for (let attempt = 1; attempt <= 2; attempt++) {
698
+ nativeCaptureAttempts = attempt;
699
+ try {
700
+ const photo = await cameraRef.current?.takePictureAsync(options);
701
+ if (!photo?.[requiredField]) {
702
+ throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
703
+ }
704
+ return photo;
705
+ }
706
+ catch (captureErr) {
707
+ const normalized = normalizeScannerError(captureErr);
708
+ lastError = normalized;
709
+ lastNativeCaptureErrorMessage = normalized.message;
710
+ if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
711
+ captureRetryAttempted = true;
712
+ const retryAt = new Date().toISOString();
713
+ lastCaptureRetryAtRef.current = retryAt;
714
+ cameraRemountCountRef.current++;
715
+ setCameraReady(false);
716
+ cameraReadyRef.current = false;
717
+ setCameraKey((key) => key + 1);
718
+ await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
719
+ captureRetryReady = await waitForCameraReady(2500);
720
+ telemetry?.track('camera_capture_retry', {
721
+ component: 'scanner',
722
+ error: normalized,
723
+ errorCode: normalized.code,
724
+ metadata: buildScannerTelemetryMetadata({
725
+ native_capture_attempts: nativeCaptureAttempts,
726
+ capture_retry_attempted: captureRetryAttempted,
727
+ capture_retry_ready: captureRetryReady,
728
+ capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
729
+ capture_retry_ready_timeout_ms: 2500,
730
+ last_native_capture_error: lastNativeCaptureErrorMessage,
731
+ }),
732
+ });
733
+ if (captureRetryReady) {
734
+ continue;
735
+ }
736
+ }
737
+ throw normalized;
738
+ }
739
+ }
740
+ throw lastError ?? createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
741
+ };
433
742
  if (ImageManipulator) {
434
743
  // Capture without base64 — ImageManipulator will produce it after resize.
435
- const photo = await cameraRef.current.takePictureAsync({
744
+ const photo = await takePictureWithRetry({
436
745
  quality: 0.8,
437
746
  exif: false,
438
- });
747
+ }, 'uri');
439
748
  if (!photo?.uri) {
440
749
  throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
441
750
  }
@@ -467,11 +776,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
467
776
  // Fallback: capture base64 directly from the camera at reduced quality.
468
777
  // No resize is possible without ImageManipulator, but the lower quality
469
778
  // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
470
- const photo = await cameraRef.current.takePictureAsync({
779
+ const photo = await takePictureWithRetry({
471
780
  base64: true,
472
781
  quality: FALLBACK_QUALITY,
473
782
  exif: false,
474
- });
783
+ }, 'base64');
475
784
  if (!photo?.base64) {
476
785
  throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
477
786
  }
@@ -484,14 +793,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
484
793
  // Best-effort telemetry — never blocks capture
485
794
  telemetry?.track('image_processed', {
486
795
  component: 'scanner',
487
- metadata: {
796
+ metadata: buildScannerTelemetryMetadata({
488
797
  original_width: origWidth,
489
798
  original_height: origHeight,
490
799
  processed_width: processedWidth,
491
800
  processed_height: processedHeight,
492
801
  resized: didResize ? 1 : 0,
493
802
  has_manipulator: ImageManipulator ? 1 : 0,
494
- },
803
+ native_capture_attempts: nativeCaptureAttempts,
804
+ capture_retry_attempted: captureRetryAttempted,
805
+ capture_retry_ready: captureRetryReady,
806
+ recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
807
+ last_native_capture_error: lastNativeCaptureErrorMessage,
808
+ }),
495
809
  });
496
810
  setStatus('processing');
497
811
  const verificationResult = await onCapture(base64);
@@ -553,13 +867,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
553
867
  const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
554
868
  telemetry?.track(isCaptureFail ? 'capture_failure'
555
869
  : isImageFail ? 'image_manipulation_failure'
556
- : 'unknown_error', { component: 'scanner', error });
870
+ : 'unknown_error', {
871
+ component: 'scanner',
872
+ error,
873
+ metadata: buildScannerTelemetryMetadata({
874
+ native_capture_attempts: nativeCaptureAttempts,
875
+ capture_retry_attempted: captureRetryAttempted,
876
+ capture_retry_ready: captureRetryReady,
877
+ is_native_camera_capture_error: isNativeCameraCaptureError(error),
878
+ last_native_capture_error: lastNativeCaptureErrorMessage,
879
+ }),
880
+ });
557
881
  }
558
882
  if (!terminalRequestError) {
559
883
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
560
884
  }
561
885
  }
562
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
886
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
563
887
  // Expose capture to parent via ref
564
888
  if (captureRef) {
565
889
  captureRef.current = handleCapture;
@@ -575,6 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
575
899
  setPhysicalOrientation(event.orientation);
576
900
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
577
901
  styles.titleText,
902
+ overlay.theme?.titleStyle,
578
903
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
579
904
  ], children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: [styles.guideContainer, isLandscape && styles.guideContainerLandscape], children: [_jsxs(View, { style: [
580
905
  styles.guideFrame,
@@ -584,8 +909,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
584
909
  : undefined,
585
910
  ], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] })] }), overlay.guideCaption && (_jsx(Text, { style: [
586
911
  styles.guideCaptionText,
912
+ overlay.theme?.feedbackStyle,
587
913
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
588
- ], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
914
+ ], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: [styles.statusText, overlay?.theme?.feedbackStyle], children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
589
915
  styles.resultIconCircle,
590
916
  exhausted
591
917
  ? styles.resultIconExhausted
@@ -603,7 +929,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
603
929
  ? (overlay?.exhaustedMessage || 'Submitted for review')
604
930
  : result.is_compliant
605
931
  ? (overlay?.successMessage || 'Verified')
606
- : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
932
+ : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
607
933
  let errorTitle;
608
934
  let errorMessage;
609
935
  let showCloseAction = false;
@@ -630,9 +956,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
630
956
  errorMessage = display.message;
631
957
  showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
632
958
  }
633
- return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
959
+ return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
634
960
  })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
635
961
  styles.instructionsText,
962
+ overlay.theme?.feedbackStyle,
636
963
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
637
964
  ], children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
638
965
  styles.captureButton,