@switchlabs/verify-ai-react-native 2.4.18 → 2.4.21

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.
@@ -48,7 +48,7 @@ export class VerifyAIClient {
48
48
 
49
49
  constructor(config: VerifyAIConfig) {
50
50
  if (!config.apiKey) {
51
- throw new Error('VerifyAI: apiKey is required');
51
+ throw new Error(`VerifyAI[v${SDK_VERSION}]: apiKey is required`);
52
52
  }
53
53
  this.apiKey = config.apiKey;
54
54
  this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
@@ -147,7 +147,7 @@ export class VerifyAIClient {
147
147
 
148
148
  if (!body || typeof body !== 'object') {
149
149
  throw this.buildRequestError(
150
- 'VerifyAI: Invalid response payload',
150
+ `VerifyAI[v${SDK_VERSION}]: Invalid response payload`,
151
151
  0,
152
152
  context,
153
153
  {
@@ -25,6 +25,12 @@ import { useTelemetry } from '../telemetry/TelemetryContext';
25
25
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
26
26
  import { BikeOverlay } from './BikeOverlay';
27
27
  import { ScooterOverlay } from './ScooterOverlay';
28
+ import {
29
+ ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
30
+ classifyAndroidAccelerometerOrientation,
31
+ getOverlayRotationDeg,
32
+ type ScannerPhysicalOrientation,
33
+ } from './scannerOrientation';
28
34
 
29
35
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
30
36
  const FALLBACK_QUALITY = 0.65;
@@ -34,13 +40,16 @@ const MANIPULATOR_QUALITY = 0.8;
34
40
  const MAX_DIMENSION = 1600;
35
41
  const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
36
42
  const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
43
+ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
37
44
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
38
45
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
39
46
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
40
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
47
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
41
48
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
49
+ const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
42
50
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
43
51
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
52
+ const ANDROID_ORIENTATION_SETTLE_MS = 250;
44
53
 
45
54
  export interface VerifyAIScannerProps {
46
55
  /** Called with base64 image data when the user captures a photo. */
@@ -199,6 +208,8 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
199
208
  message = 'Network request failed. Check your connection and try again.';
200
209
  } else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
201
210
  message = 'The camera had trouble taking a photo. Please try again.';
211
+ } else if (code === CAMERA_INIT_ERROR_CODE) {
212
+ message = 'The camera could not be started. Close any other app using the camera and try again.';
202
213
  } else if (code === CAMERA_NOT_READY_ERROR_CODE) {
203
214
  message = 'Camera is not ready yet. Please wait a moment and try again.';
204
215
  } else if (status === 401) {
@@ -292,11 +303,13 @@ export function VerifyAIScanner({
292
303
  const attemptCountRef = useRef(0);
293
304
  const [exhausted, setExhausted] = useState(false);
294
305
  const [terminated, setTerminated] = useState(false);
306
+ const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
295
307
  const [cameraReady, setCameraReady] = useState(false);
296
308
  const [cameraKey, setCameraKey] = useState(0);
297
309
  const terminalResultRef = useRef<VerificationResult | null>(null);
298
310
  const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
299
311
  const terminalResultDeliveredRef = useRef(false);
312
+ const appStateRef = useRef(AppState.currentState);
300
313
  const cameraReadyRef = useRef(false);
301
314
  const cameraEverReadyRef = useRef(false);
302
315
  const cameraInitFailedRef = useRef(false);
@@ -306,6 +319,11 @@ export function VerifyAIScanner({
306
319
  const lastOrientationRemountAtRef = useRef<string | null>(null);
307
320
  const lastCaptureRetryAtRef = useRef<string | null>(null);
308
321
  const cameraRemountCountRef = useRef(0);
322
+ const startupWatchdogRemountCountRef = useRef(0);
323
+ const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
324
+ const buildScannerTelemetryMetadataRef = useRef<
325
+ ((extra?: Record<string, string | number | boolean | null | undefined>) => ScannerTelemetryMetadata) | null
326
+ >(null);
309
327
  const androidOrientationSubscriptionActiveRef = useRef(false);
310
328
  const androidOrientationEventCountRef = useRef(0);
311
329
  const androidOrientationChangeCountRef = useRef(0);
@@ -317,8 +335,13 @@ export function VerifyAIScanner({
317
335
  const lastAndroidOrientationZRef = useRef<number | null>(null);
318
336
  const lastAndroidOrientationDerivedRef = useRef<string | null>(null);
319
337
  const lastAndroidOrientationIgnoredReasonRef = useRef<string | null>(null);
338
+ const lastAndroidOrientationDominantAxisRef = useRef<string | null>(null);
320
339
  const lastAndroidOrientationErrorAtRef = useRef<string | null>(null);
321
340
  const lastAndroidOrientationErrorRef = useRef<string | null>(null);
341
+ const pendingAndroidPhysicalOrientationRef = useRef<ScannerPhysicalOrientation | null>(null);
342
+ const pendingAndroidPhysicalOrientationStartedAtRef = useRef<string | null>(null);
343
+ const androidOrientationSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
344
+ const physicalOrientationRef = useRef<ScannerPhysicalOrientation>('portrait');
322
345
 
323
346
  // Track dimensions for orientation detection and responsive layout
324
347
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -330,23 +353,11 @@ export function VerifyAIScanner({
330
353
  // rotation. iOS uses expo-camera's responsive-orientation callback; Android
331
354
  // uses the accelerometer via expo-sensors (the callback is iOS-only). Either
332
355
  // way we rotate the overlay UI to stay readable from the user's viewpoint.
333
- const [physicalOrientation, setPhysicalOrientation] = useState<
334
- 'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight'
335
- >('portrait');
336
-
337
- const overlayRotationDeg = (() => {
338
- switch (physicalOrientation) {
339
- case 'landscapeLeft':
340
- return 90;
341
- case 'landscapeRight':
342
- return -90;
343
- case 'portraitUpsideDown':
344
- return 180;
345
- case 'portrait':
346
- default:
347
- return 0;
348
- }
349
- })();
356
+ const [physicalOrientation, setPhysicalOrientation] =
357
+ useState<ScannerPhysicalOrientation>('portrait');
358
+
359
+ physicalOrientationRef.current = physicalOrientation;
360
+ const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
350
361
 
351
362
  const buildScannerTelemetryMetadata = useCallback((
352
363
  extra: Record<string, string | number | boolean | null | undefined> = {},
@@ -360,7 +371,7 @@ export function VerifyAIScanner({
360
371
  camera_remount_count: cameraRemountCountRef.current,
361
372
  terminated,
362
373
  exhausted,
363
- app_state: AppState.currentState,
374
+ app_state: appStateRef.current,
364
375
  sdk_platform: Platform.OS,
365
376
  device_model: getPlatformConstantString('Model', 'model'),
366
377
  device_os_version: String(Platform.Version),
@@ -375,10 +386,22 @@ export function VerifyAIScanner({
375
386
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
376
387
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
377
388
  last_capture_retry_at: lastCaptureRetryAtRef.current,
389
+ android_native_orientation_subscription_active: 0,
390
+ android_native_orientation_event_count: 0,
391
+ android_native_orientation_change_count: 0,
392
+ android_native_orientation_source: 'expo_camera_internal_capture_orientation',
378
393
  android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
379
394
  android_orientation_started_at: androidOrientationStartedAtRef.current,
380
395
  android_orientation_event_count: androidOrientationEventCountRef.current,
381
396
  android_orientation_change_count: androidOrientationChangeCountRef.current,
397
+ android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
398
+ android_accelerometer_started_at: androidOrientationStartedAtRef.current,
399
+ android_accelerometer_event_count: androidOrientationEventCountRef.current,
400
+ android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
401
+ android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
402
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
403
+ pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
404
+ pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
382
405
  last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
383
406
  last_android_orientation_x: lastAndroidOrientationXRef.current,
384
407
  last_android_orientation_y: lastAndroidOrientationYRef.current,
@@ -387,6 +410,15 @@ export function VerifyAIScanner({
387
410
  last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
388
411
  last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
389
412
  last_android_orientation_error: lastAndroidOrientationErrorRef.current,
413
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
414
+ last_accelerometer_x: lastAndroidOrientationXRef.current,
415
+ last_accelerometer_y: lastAndroidOrientationYRef.current,
416
+ last_accelerometer_z: lastAndroidOrientationZRef.current,
417
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
418
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
419
+ last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
420
+ last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
421
+ last_accelerometer_error: lastAndroidOrientationErrorRef.current,
390
422
  ...extra,
391
423
  }), [
392
424
  cameraKey,
@@ -403,13 +435,36 @@ export function VerifyAIScanner({
403
435
  windowWidth,
404
436
  ]);
405
437
 
438
+ telemetryRef.current = telemetry;
439
+ buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
440
+
441
+ useEffect(() => {
442
+ telemetryRef.current?.track('camera_scanner_mounted', {
443
+ component: 'scanner',
444
+ error: 'scanner_mounted',
445
+ metadata: buildScannerTelemetryMetadataRef.current?.({
446
+ scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
447
+ }),
448
+ });
449
+
450
+ return () => {
451
+ const activeTelemetry = telemetryRef.current;
452
+ activeTelemetry?.track('camera_scanner_disposed', {
453
+ component: 'scanner',
454
+ error: 'scanner_disposed',
455
+ metadata: buildScannerTelemetryMetadataRef.current?.(),
456
+ });
457
+ void activeTelemetry?.flush();
458
+ };
459
+ }, []);
460
+
406
461
  useEffect(() => {
407
462
  if (Platform.OS !== 'android') return;
408
463
 
409
464
  let subscription: { remove: () => void } | null = null;
410
465
  let cancelled = false;
411
466
  let noEventTimer: ReturnType<typeof setTimeout> | null = null;
412
- let lastOrientation: typeof physicalOrientation = 'portrait';
467
+ let lastOrientation: ScannerPhysicalOrientation = physicalOrientationRef.current;
413
468
 
414
469
  const androidMetadata = (
415
470
  extra: Record<string, string | number | boolean | null | undefined> = {},
@@ -420,10 +475,22 @@ export function VerifyAIScanner({
420
475
  device_os_version: String(Platform.Version),
421
476
  route_name: telemetryContext?.routeName,
422
477
  is_portrait_locked: telemetryContext?.isPortraitLocked,
478
+ android_native_orientation_subscription_active: 0,
479
+ android_native_orientation_event_count: 0,
480
+ android_native_orientation_change_count: 0,
481
+ android_native_orientation_source: 'expo_camera_internal_capture_orientation',
423
482
  android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
424
483
  android_orientation_started_at: androidOrientationStartedAtRef.current,
425
484
  android_orientation_event_count: androidOrientationEventCountRef.current,
426
485
  android_orientation_change_count: androidOrientationChangeCountRef.current,
486
+ android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
487
+ android_accelerometer_started_at: androidOrientationStartedAtRef.current,
488
+ android_accelerometer_event_count: androidOrientationEventCountRef.current,
489
+ android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
490
+ android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
491
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
492
+ pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
493
+ pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
427
494
  last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
428
495
  last_android_orientation_x: lastAndroidOrientationXRef.current,
429
496
  last_android_orientation_y: lastAndroidOrientationYRef.current,
@@ -432,6 +499,15 @@ export function VerifyAIScanner({
432
499
  last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
433
500
  last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
434
501
  last_android_orientation_error: lastAndroidOrientationErrorRef.current,
502
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
503
+ last_accelerometer_x: lastAndroidOrientationXRef.current,
504
+ last_accelerometer_y: lastAndroidOrientationYRef.current,
505
+ last_accelerometer_z: lastAndroidOrientationZRef.current,
506
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
507
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
508
+ last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
509
+ last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
510
+ last_accelerometer_error: lastAndroidOrientationErrorRef.current,
435
511
  ...extra,
436
512
  });
437
513
 
@@ -489,8 +565,10 @@ export function VerifyAIScanner({
489
565
  androidOrientationChangeCountRef.current = 0;
490
566
  lastAndroidOrientationTelemetryAtRef.current = 0;
491
567
  trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
568
+ accelerometer_start_reason: 'android_overlay_orientation_primary',
492
569
  accelerometer_update_interval_ms: 500,
493
570
  accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
571
+ accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
494
572
  });
495
573
 
496
574
  noEventTimer = setTimeout(() => {
@@ -505,42 +583,68 @@ export function VerifyAIScanner({
505
583
  subscription = Accelerometer.addListener(({ x, y, z }) => {
506
584
  const at = new Date();
507
585
  const atIso = at.toISOString();
586
+ const sample = classifyAndroidAccelerometerOrientation({ x, y, z });
508
587
  androidOrientationEventCountRef.current++;
509
588
  lastAndroidOrientationEventAtRef.current = atIso;
510
- lastAndroidOrientationXRef.current = Number(x.toFixed(4));
511
- lastAndroidOrientationYRef.current = Number(y.toFixed(4));
512
- lastAndroidOrientationZRef.current = Number(z.toFixed(4));
589
+ lastAndroidOrientationXRef.current = Number(sample.x.toFixed(4));
590
+ lastAndroidOrientationYRef.current = Number(sample.y.toFixed(4));
591
+ lastAndroidOrientationZRef.current = Number(sample.z.toFixed(4));
592
+ lastAndroidOrientationDerivedRef.current = sample.orientation;
593
+ lastAndroidOrientationIgnoredReasonRef.current = sample.ignoredReason;
594
+ lastAndroidOrientationDominantAxisRef.current = sample.dominantAxis;
513
595
  if (noEventTimer) {
514
596
  clearTimeout(noEventTimer);
515
597
  noEventTimer = null;
516
598
  }
517
599
 
518
- let next: typeof physicalOrientation | null = null;
519
- let ignoredReason: string | null = null;
520
- if (Math.abs(x) > Math.abs(y) + 0.2) {
521
- next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
522
- } else if (Math.abs(y) > Math.abs(x) + 0.2) {
523
- next = y > 0 ? 'portraitUpsideDown' : 'portrait';
524
- } else {
525
- ignoredReason = 'ambiguous_tilt';
526
- }
527
-
528
- lastAndroidOrientationDerivedRef.current = next;
529
- lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
530
-
600
+ const next = sample.orientation;
531
601
  if (next && next !== lastOrientation) {
532
602
  const previous = lastOrientation;
533
- lastOrientation = next;
534
- androidOrientationChangeCountRef.current++;
535
- setPhysicalOrientation(next);
536
- trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
537
- previous_physical_orientation: previous,
538
- next_physical_orientation: next,
539
- accelerometer_sampled_at: atIso,
540
- });
603
+ if (
604
+ pendingAndroidPhysicalOrientationRef.current === next &&
605
+ androidOrientationSettleTimerRef.current
606
+ ) {
607
+ return;
608
+ }
609
+
610
+ pendingAndroidPhysicalOrientationRef.current = next;
611
+ pendingAndroidPhysicalOrientationStartedAtRef.current = atIso;
612
+ if (androidOrientationSettleTimerRef.current) {
613
+ clearTimeout(androidOrientationSettleTimerRef.current);
614
+ }
615
+
616
+ androidOrientationSettleTimerRef.current = setTimeout(() => {
617
+ if (cancelled || pendingAndroidPhysicalOrientationRef.current !== next) return;
618
+ const pendingStartedAt = pendingAndroidPhysicalOrientationStartedAtRef.current;
619
+ pendingAndroidPhysicalOrientationRef.current = null;
620
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
621
+ androidOrientationSettleTimerRef.current = null;
622
+ if (next === lastOrientation) return;
623
+
624
+ lastOrientation = next;
625
+ androidOrientationChangeCountRef.current++;
626
+ setPhysicalOrientation(next);
627
+ trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
628
+ android_physical_orientation_source: 'accelerometer_fallback',
629
+ previous_physical_orientation: previous,
630
+ next_physical_orientation: next,
631
+ accelerometer_sampled_at: atIso,
632
+ accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
633
+ accelerometer_dominant_axis: sample.dominantAxis,
634
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
635
+ android_physical_orientation_pending_started_at: pendingStartedAt,
636
+ });
637
+ }, ANDROID_ORIENTATION_SETTLE_MS);
541
638
  return;
542
639
  }
543
640
 
641
+ if (!next && androidOrientationSettleTimerRef.current) {
642
+ clearTimeout(androidOrientationSettleTimerRef.current);
643
+ androidOrientationSettleTimerRef.current = null;
644
+ pendingAndroidPhysicalOrientationRef.current = null;
645
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
646
+ }
647
+
544
648
  const shouldTrackSample =
545
649
  androidOrientationEventCountRef.current === 1 ||
546
650
  at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
@@ -549,7 +653,11 @@ export function VerifyAIScanner({
549
653
  trackAndroidOrientationEvent(
550
654
  'camera_android_accelerometer_sample',
551
655
  `accelerometer_sample_${androidOrientationEventCountRef.current}`,
552
- { accelerometer_sampled_at: atIso },
656
+ {
657
+ accelerometer_sampled_at: atIso,
658
+ accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
659
+ accelerometer_dominant_axis: sample.dominantAxis,
660
+ },
553
661
  );
554
662
  }
555
663
  });
@@ -557,9 +665,23 @@ export function VerifyAIScanner({
557
665
 
558
666
  return () => {
559
667
  cancelled = true;
668
+ const wasActive = subscription != null || androidOrientationSubscriptionActiveRef.current;
669
+ if (androidOrientationSettleTimerRef.current) {
670
+ clearTimeout(androidOrientationSettleTimerRef.current);
671
+ androidOrientationSettleTimerRef.current = null;
672
+ }
673
+ pendingAndroidPhysicalOrientationRef.current = null;
674
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
560
675
  androidOrientationSubscriptionActiveRef.current = false;
561
676
  if (noEventTimer) clearTimeout(noEventTimer);
562
677
  subscription?.remove();
678
+ if (wasActive) {
679
+ trackAndroidOrientationEvent(
680
+ 'camera_android_accelerometer_stopped',
681
+ 'android_accelerometer_tracking_stopped',
682
+ { accelerometer_stop_reason: 'orientation_tracking_stopped' },
683
+ );
684
+ }
563
685
  };
564
686
  }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
565
687
 
@@ -605,23 +727,33 @@ export function VerifyAIScanner({
605
727
  if (terminated) return;
606
728
 
607
729
  const subscription = AppState.addEventListener('change', (nextState) => {
608
- if (nextState === 'active') {
609
- const at = new Date().toISOString();
610
- lastAppStateRemountAtRef.current = at;
611
- cameraRemountCountRef.current++;
612
- // Force camera remount — on iOS, AVCaptureSession often fails to resume
613
- // its preview layer after returning from the notification bar or control center.
614
- telemetry?.track('camera_appstate_remount', {
615
- component: 'scanner',
616
- metadata: buildScannerTelemetryMetadata({
617
- remount_reason: 'appstate_active',
618
- remount_requested_at: at,
619
- }),
620
- });
730
+ const previousState = appStateRef.current;
731
+ appStateRef.current = nextState;
732
+ setCurrentAppState(nextState);
733
+
734
+ if (nextState !== 'active') {
621
735
  setCameraReady(false);
622
736
  cameraReadyRef.current = false;
623
- setCameraKey((k) => k + 1);
737
+ return;
624
738
  }
739
+
740
+ if (previousState === 'active') return;
741
+
742
+ const at = new Date().toISOString();
743
+ lastAppStateRemountAtRef.current = at;
744
+ cameraRemountCountRef.current++;
745
+ // Force camera remount — on iOS, AVCaptureSession often fails to resume
746
+ // its preview layer after returning from the notification bar or control center.
747
+ telemetry?.track('camera_appstate_remount', {
748
+ component: 'scanner',
749
+ metadata: buildScannerTelemetryMetadata({
750
+ remount_reason: 'appstate_active',
751
+ remount_requested_at: at,
752
+ }),
753
+ });
754
+ setCameraReady(false);
755
+ cameraReadyRef.current = false;
756
+ setCameraKey((k) => k + 1);
625
757
  });
626
758
 
627
759
  return () => subscription.remove();
@@ -691,10 +823,12 @@ export function VerifyAIScanner({
691
823
  cameraReadyRef.current = true;
692
824
  cameraEverReadyRef.current = true;
693
825
  cameraInitFailedRef.current = false;
826
+ startupWatchdogRemountCountRef.current = 0;
694
827
  }, []);
695
828
 
696
829
  const onMountError = useCallback((event: { message?: string }) => {
697
830
  const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
831
+ error.code = CAMERA_INIT_ERROR_CODE;
698
832
  setResult(null);
699
833
  setLastError(error);
700
834
  setStatus('error');
@@ -707,6 +841,8 @@ export function VerifyAIScanner({
707
841
  error,
708
842
  metadata: buildScannerTelemetryMetadata({
709
843
  mount_error_message: event.message,
844
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
845
+ startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
710
846
  }),
711
847
  });
712
848
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
@@ -716,20 +852,31 @@ export function VerifyAIScanner({
716
852
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
717
853
  // recreate the CameraView, which starts a fresh native session.
718
854
  useEffect(() => {
719
- if (!permission?.granted || terminated) return;
855
+ if (!permission?.granted || terminated || currentAppState !== 'active') return;
720
856
 
721
857
  const watchdogMs = Platform.OS === 'android'
722
858
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
723
859
  : IOS_CAMERA_STARTUP_WATCHDOG_MS;
724
860
 
725
861
  const timer = setTimeout(() => {
862
+ if (appStateRef.current !== 'active') return;
863
+
726
864
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
727
865
  // Only track + remount on first-ever mount. A rotation/app-resume
728
- // triggered remount can legitimately take >3s without indicating a
866
+ // triggered remount can legitimately take several seconds without indicating a
729
867
  // real failure, and firing the alert each time is noisy without
730
- // surfacing new information. If the camera truly stays broken the
731
- // user will see capture failures, which is a more meaningful signal.
868
+ // surfacing new information. First startup still gets a capped retry loop
869
+ // so a persistent native camera failure becomes visible to the user.
732
870
  if (!cameraEverReadyRef.current) {
871
+ if (startupWatchdogRemountCountRef.current >= CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS) {
872
+ onMountError({
873
+ message: `Camera did not initialize after ${CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS} startup remount attempts`,
874
+ });
875
+ return;
876
+ }
877
+
878
+ const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
879
+ startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
733
880
  cameraRemountCountRef.current++;
734
881
  telemetry?.track('camera_preview_timeout', {
735
882
  component: 'scanner',
@@ -737,6 +884,8 @@ export function VerifyAIScanner({
737
884
  metadata: buildScannerTelemetryMetadata({
738
885
  watchdog_ms: watchdogMs,
739
886
  remount_reason: 'startup_watchdog',
887
+ startup_watchdog_remount_count: startupWatchdogRemountCount,
888
+ startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
740
889
  }),
741
890
  });
742
891
  setCameraReady(false);
@@ -747,7 +896,15 @@ export function VerifyAIScanner({
747
896
  }, watchdogMs);
748
897
 
749
898
  return () => clearTimeout(timer);
750
- }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
899
+ }, [
900
+ permission?.granted,
901
+ terminated,
902
+ currentAppState,
903
+ cameraKey,
904
+ buildScannerTelemetryMetadata,
905
+ onMountError,
906
+ telemetry,
907
+ ]);
751
908
 
752
909
  // Track permission denied
753
910
  useEffect(() => {
@@ -834,6 +991,13 @@ export function VerifyAIScanner({
834
991
  capture_physical_orientation: capturePhysicalOrientation,
835
992
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
836
993
  capture_rotation_applied: 0,
994
+ capture_rotation_source: Platform.OS === 'android'
995
+ ? 'expo_camera_native_orientation_event_listener'
996
+ : 'expo_camera_responsive_orientation',
997
+ accelerometer_event_count: androidOrientationEventCountRef.current,
998
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
999
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
1000
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
837
1001
  }),
838
1002
  });
839
1003
 
@@ -0,0 +1,66 @@
1
+ export type ScannerPhysicalOrientation =
2
+ | 'portrait'
3
+ | 'portraitUpsideDown'
4
+ | 'landscapeLeft'
5
+ | 'landscapeRight';
6
+
7
+ export type AndroidAccelerometerSample = {
8
+ x: number;
9
+ y: number;
10
+ z: number;
11
+ };
12
+
13
+ export type AndroidAccelerometerOrientationSample = AndroidAccelerometerSample & {
14
+ orientation: ScannerPhysicalOrientation | null;
15
+ ignoredReason: string | null;
16
+ dominantAxis: 'x' | 'y' | 'ambiguous';
17
+ xyDominance: number;
18
+ };
19
+
20
+ export const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
21
+
22
+ export function classifyAndroidAccelerometerOrientation(
23
+ sample: AndroidAccelerometerSample,
24
+ axisDominanceThreshold = ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
25
+ ): AndroidAccelerometerOrientationSample {
26
+ const { x, y, z } = sample;
27
+ const xAbs = Math.abs(x);
28
+ const yAbs = Math.abs(y);
29
+ let orientation: ScannerPhysicalOrientation | null = null;
30
+ let ignoredReason: string | null = null;
31
+ let dominantAxis: AndroidAccelerometerOrientationSample['dominantAxis'] = 'ambiguous';
32
+
33
+ if (xAbs > yAbs + axisDominanceThreshold) {
34
+ dominantAxis = 'x';
35
+ orientation = x > 0 ? 'landscapeRight' : 'landscapeLeft';
36
+ } else if (yAbs > xAbs + axisDominanceThreshold) {
37
+ dominantAxis = 'y';
38
+ orientation = y > 0 ? 'portraitUpsideDown' : 'portrait';
39
+ } else {
40
+ ignoredReason = 'ambiguous_xy';
41
+ }
42
+
43
+ return {
44
+ x,
45
+ y,
46
+ z,
47
+ orientation,
48
+ ignoredReason,
49
+ dominantAxis,
50
+ xyDominance: Math.abs(xAbs - yAbs),
51
+ };
52
+ }
53
+
54
+ export function getOverlayRotationDeg(orientation: ScannerPhysicalOrientation): number {
55
+ switch (orientation) {
56
+ case 'landscapeLeft':
57
+ return 90;
58
+ case 'landscapeRight':
59
+ return -90;
60
+ case 'portraitUpsideDown':
61
+ return 180;
62
+ case 'portrait':
63
+ default:
64
+ return 0;
65
+ }
66
+ }
@@ -4,6 +4,7 @@ import { VerifyAIClient, VerifyAIRequestError } from '../client';
4
4
  import { OfflineQueue } from '../storage/offlineQueue';
5
5
  import { TelemetryContext } from '../telemetry/TelemetryContext';
6
6
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
7
+ import { SDK_VERSION } from '../version';
7
8
  import type {
8
9
  VerifyAIConfig,
9
10
  VerificationRequest,
@@ -161,7 +162,7 @@ export function useVerifyAI(config: UseVerifyAIConfig): UseVerifyAIReturn {
161
162
  });
162
163
  inferenceEngineRef.current = new InferenceEngine();
163
164
  } catch (err) {
164
- console.warn('[VerifyAI] Failed to load ML modules:', err);
165
+ console.warn(`[VerifyAI v${SDK_VERSION}] Failed to load ML modules:`, err);
165
166
  }
166
167
  })();
167
168
 
@@ -6,6 +6,7 @@
6
6
  import { Buffer } from 'buffer';
7
7
  import { sha256 } from '@noble/hashes/sha2.js';
8
8
  import type { BundleManifest, ModelArtifact } from './types';
9
+ import { SDK_VERSION } from '../version';
9
10
 
10
11
  export type { BundleManifest, ModelArtifact };
11
12
 
@@ -113,7 +114,7 @@ export class ModelManager {
113
114
  private async downloadArtifacts(manifest: BundleManifest, policyId: string): Promise<void> {
114
115
  const fileSystem = await loadFileSystem();
115
116
  if (!fileSystem || !fileSystem.documentDirectory) {
116
- console.warn('[VerifyAI] expo-file-system not available, skipping download');
117
+ console.warn(`[VerifyAI v${SDK_VERSION}] expo-file-system not available, skipping download`);
117
118
  return;
118
119
  }
119
120
 
@@ -39,6 +39,17 @@ const CRITICAL_EVENTS = new Set([
39
39
  'camera_init_failure',
40
40
  'camera_preview_timeout',
41
41
  'camera_permission_denied',
42
+ 'camera_scanner_mounted',
43
+ 'camera_scanner_disposed',
44
+ 'camera_android_native_orientation_started',
45
+ 'camera_android_native_orientation_no_events',
46
+ 'camera_android_native_orientation_error',
47
+ 'camera_android_native_orientation_start_failure',
48
+ 'camera_android_native_orientation_done',
49
+ 'camera_android_accelerometer_started',
50
+ 'camera_android_accelerometer_no_events',
51
+ 'camera_android_accelerometer_error',
52
+ 'camera_android_accelerometer_start_failure',
42
53
  ]);
43
54
 
44
55
  export class TelemetryReporter {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.18';
1
+ export const SDK_VERSION = '2.4.21';