@switchlabs/verify-ai-react-native 2.4.17 → 2.4.20

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.
package/README.md CHANGED
@@ -13,11 +13,14 @@ npm install @switchlabs/verify-ai-react-native
13
13
  With built-in camera scanner:
14
14
 
15
15
  ```bash
16
- npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator
16
+ npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator expo-sensors
17
17
  ```
18
18
 
19
19
  For offline queue support, also install `@react-native-async-storage/async-storage`.
20
20
 
21
+ `expo-sensors` powers Android physical-orientation tracking for scanner overlays
22
+ when the host app is portrait-locked.
23
+
21
24
  If you want on-device inference, also install `expo-file-system`,
22
25
  `react-native-fast-tflite`, and configure `react-native-fast-tflite` for the
23
26
  delegates you plan to use.
@@ -121,10 +121,21 @@ export class VerifyAIClient {
121
121
  const telemetryError = eventType === 'request_timeout'
122
122
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
123
123
  : normalized;
124
+ const metadata = {
125
+ method: context.method,
126
+ path: context.path,
127
+ status: normalized.status,
128
+ timeout_ms: this.timeout,
129
+ };
130
+ if (normalized.code)
131
+ metadata.code = normalized.code;
132
+ if (normalized.requestId)
133
+ metadata.request_id = normalized.requestId;
124
134
  this.telemetry.track(eventType, {
125
135
  component: 'client',
126
136
  error: telemetryError,
127
137
  errorCode,
138
+ metadata,
128
139
  });
129
140
  }
130
141
  throw normalized;
@@ -6,6 +6,7 @@ import { VerifyAIRequestError } from '../client';
6
6
  import { useTelemetry } from '../telemetry/TelemetryContext';
7
7
  import { BikeOverlay } from './BikeOverlay';
8
8
  import { ScooterOverlay } from './ScooterOverlay';
9
+ import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
9
10
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
10
11
  const FALLBACK_QUALITY = 0.65;
11
12
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
@@ -14,13 +15,16 @@ const MANIPULATOR_QUALITY = 0.8;
14
15
  const MAX_DIMENSION = 1600;
15
16
  const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
16
17
  const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
18
+ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
17
19
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
18
20
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
19
21
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
20
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
22
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
21
23
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
24
+ const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
22
25
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
23
26
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
27
+ const ANDROID_ORIENTATION_SETTLE_MS = 250;
24
28
  function getPolicyScannerDefaults(policy) {
25
29
  const id = policy?.toLowerCase() ?? '';
26
30
  const isForest = id.includes('forest') || id.includes('humanforest');
@@ -121,6 +125,9 @@ function getErrorDisplay(error, showTechnicalDetails) {
121
125
  else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
122
126
  message = 'The camera had trouble taking a photo. Please try again.';
123
127
  }
128
+ else if (code === CAMERA_INIT_ERROR_CODE) {
129
+ message = 'The camera could not be started. Close any other app using the camera and try again.';
130
+ }
124
131
  else if (code === CAMERA_NOT_READY_ERROR_CODE) {
125
132
  message = 'Camera is not ready yet. Please wait a moment and try again.';
126
133
  }
@@ -194,11 +201,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
194
201
  const attemptCountRef = useRef(0);
195
202
  const [exhausted, setExhausted] = useState(false);
196
203
  const [terminated, setTerminated] = useState(false);
204
+ const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
197
205
  const [cameraReady, setCameraReady] = useState(false);
198
206
  const [cameraKey, setCameraKey] = useState(0);
199
207
  const terminalResultRef = useRef(null);
200
208
  const terminalResultTimerRef = useRef(null);
201
209
  const terminalResultDeliveredRef = useRef(false);
210
+ const appStateRef = useRef(AppState.currentState);
202
211
  const cameraReadyRef = useRef(false);
203
212
  const cameraEverReadyRef = useRef(false);
204
213
  const cameraInitFailedRef = useRef(false);
@@ -208,6 +217,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
208
217
  const lastOrientationRemountAtRef = useRef(null);
209
218
  const lastCaptureRetryAtRef = useRef(null);
210
219
  const cameraRemountCountRef = useRef(0);
220
+ const startupWatchdogRemountCountRef = useRef(0);
221
+ const telemetryRef = useRef(telemetry);
222
+ const buildScannerTelemetryMetadataRef = useRef(null);
211
223
  const androidOrientationSubscriptionActiveRef = useRef(false);
212
224
  const androidOrientationEventCountRef = useRef(0);
213
225
  const androidOrientationChangeCountRef = useRef(0);
@@ -219,8 +231,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
219
231
  const lastAndroidOrientationZRef = useRef(null);
220
232
  const lastAndroidOrientationDerivedRef = useRef(null);
221
233
  const lastAndroidOrientationIgnoredReasonRef = useRef(null);
234
+ const lastAndroidOrientationDominantAxisRef = useRef(null);
222
235
  const lastAndroidOrientationErrorAtRef = useRef(null);
223
236
  const lastAndroidOrientationErrorRef = useRef(null);
237
+ const pendingAndroidPhysicalOrientationRef = useRef(null);
238
+ const pendingAndroidPhysicalOrientationStartedAtRef = useRef(null);
239
+ const androidOrientationSettleTimerRef = useRef(null);
240
+ const physicalOrientationRef = useRef('portrait');
224
241
  // Track dimensions for orientation detection and responsive layout
225
242
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
226
243
  const isLandscape = windowWidth > windowHeight;
@@ -231,19 +248,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
231
248
  // uses the accelerometer via expo-sensors (the callback is iOS-only). Either
232
249
  // way we rotate the overlay UI to stay readable from the user's viewpoint.
233
250
  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
- })();
251
+ physicalOrientationRef.current = physicalOrientation;
252
+ const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
247
253
  const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
248
254
  policy,
249
255
  status,
@@ -254,7 +260,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
254
260
  camera_remount_count: cameraRemountCountRef.current,
255
261
  terminated,
256
262
  exhausted,
257
- app_state: AppState.currentState,
263
+ app_state: appStateRef.current,
258
264
  sdk_platform: Platform.OS,
259
265
  device_model: getPlatformConstantString('Model', 'model'),
260
266
  device_os_version: String(Platform.Version),
@@ -269,10 +275,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
269
275
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
270
276
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
271
277
  last_capture_retry_at: lastCaptureRetryAtRef.current,
278
+ android_native_orientation_subscription_active: 0,
279
+ android_native_orientation_event_count: 0,
280
+ android_native_orientation_change_count: 0,
281
+ android_native_orientation_source: 'expo_camera_internal_capture_orientation',
272
282
  android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
273
283
  android_orientation_started_at: androidOrientationStartedAtRef.current,
274
284
  android_orientation_event_count: androidOrientationEventCountRef.current,
275
285
  android_orientation_change_count: androidOrientationChangeCountRef.current,
286
+ android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
287
+ android_accelerometer_started_at: androidOrientationStartedAtRef.current,
288
+ android_accelerometer_event_count: androidOrientationEventCountRef.current,
289
+ android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
290
+ android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
291
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
292
+ pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
293
+ pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
276
294
  last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
277
295
  last_android_orientation_x: lastAndroidOrientationXRef.current,
278
296
  last_android_orientation_y: lastAndroidOrientationYRef.current,
@@ -281,6 +299,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
281
299
  last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
282
300
  last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
283
301
  last_android_orientation_error: lastAndroidOrientationErrorRef.current,
302
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
303
+ last_accelerometer_x: lastAndroidOrientationXRef.current,
304
+ last_accelerometer_y: lastAndroidOrientationYRef.current,
305
+ last_accelerometer_z: lastAndroidOrientationZRef.current,
306
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
307
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
308
+ last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
309
+ last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
310
+ last_accelerometer_error: lastAndroidOrientationErrorRef.current,
284
311
  ...extra,
285
312
  }), [
286
313
  cameraKey,
@@ -296,13 +323,33 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
296
323
  windowHeight,
297
324
  windowWidth,
298
325
  ]);
326
+ telemetryRef.current = telemetry;
327
+ buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
328
+ useEffect(() => {
329
+ telemetryRef.current?.track('camera_scanner_mounted', {
330
+ component: 'scanner',
331
+ error: 'scanner_mounted',
332
+ metadata: buildScannerTelemetryMetadataRef.current?.({
333
+ scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
334
+ }),
335
+ });
336
+ return () => {
337
+ const activeTelemetry = telemetryRef.current;
338
+ activeTelemetry?.track('camera_scanner_disposed', {
339
+ component: 'scanner',
340
+ error: 'scanner_disposed',
341
+ metadata: buildScannerTelemetryMetadataRef.current?.(),
342
+ });
343
+ void activeTelemetry?.flush();
344
+ };
345
+ }, []);
299
346
  useEffect(() => {
300
347
  if (Platform.OS !== 'android')
301
348
  return;
302
349
  let subscription = null;
303
350
  let cancelled = false;
304
351
  let noEventTimer = null;
305
- let lastOrientation = 'portrait';
352
+ let lastOrientation = physicalOrientationRef.current;
306
353
  const androidMetadata = (extra = {}) => compactTelemetryMetadata({
307
354
  policy,
308
355
  sdk_platform: Platform.OS,
@@ -310,10 +357,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
310
357
  device_os_version: String(Platform.Version),
311
358
  route_name: telemetryContext?.routeName,
312
359
  is_portrait_locked: telemetryContext?.isPortraitLocked,
360
+ android_native_orientation_subscription_active: 0,
361
+ android_native_orientation_event_count: 0,
362
+ android_native_orientation_change_count: 0,
363
+ android_native_orientation_source: 'expo_camera_internal_capture_orientation',
313
364
  android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
314
365
  android_orientation_started_at: androidOrientationStartedAtRef.current,
315
366
  android_orientation_event_count: androidOrientationEventCountRef.current,
316
367
  android_orientation_change_count: androidOrientationChangeCountRef.current,
368
+ android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
369
+ android_accelerometer_started_at: androidOrientationStartedAtRef.current,
370
+ android_accelerometer_event_count: androidOrientationEventCountRef.current,
371
+ android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
372
+ android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
373
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
374
+ pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
375
+ pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
317
376
  last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
318
377
  last_android_orientation_x: lastAndroidOrientationXRef.current,
319
378
  last_android_orientation_y: lastAndroidOrientationYRef.current,
@@ -322,6 +381,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
322
381
  last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
323
382
  last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
324
383
  last_android_orientation_error: lastAndroidOrientationErrorRef.current,
384
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
385
+ last_accelerometer_x: lastAndroidOrientationXRef.current,
386
+ last_accelerometer_y: lastAndroidOrientationYRef.current,
387
+ last_accelerometer_z: lastAndroidOrientationZRef.current,
388
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
389
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
390
+ last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
391
+ last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
392
+ last_accelerometer_error: lastAndroidOrientationErrorRef.current,
325
393
  ...extra,
326
394
  });
327
395
  const trackAndroidOrientationEvent = (eventType, error, metadata = {}) => {
@@ -368,8 +436,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
368
436
  androidOrientationChangeCountRef.current = 0;
369
437
  lastAndroidOrientationTelemetryAtRef.current = 0;
370
438
  trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
439
+ accelerometer_start_reason: 'android_overlay_orientation_primary',
371
440
  accelerometer_update_interval_ms: 500,
372
441
  accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
442
+ accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
373
443
  });
374
444
  noEventTimer = setTimeout(() => {
375
445
  if (cancelled || androidOrientationEventCountRef.current > 0)
@@ -380,54 +450,90 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
380
450
  subscription = Accelerometer.addListener(({ x, y, z }) => {
381
451
  const at = new Date();
382
452
  const atIso = at.toISOString();
453
+ const sample = classifyAndroidAccelerometerOrientation({ x, y, z });
383
454
  androidOrientationEventCountRef.current++;
384
455
  lastAndroidOrientationEventAtRef.current = atIso;
385
- lastAndroidOrientationXRef.current = Number(x.toFixed(4));
386
- lastAndroidOrientationYRef.current = Number(y.toFixed(4));
387
- lastAndroidOrientationZRef.current = Number(z.toFixed(4));
456
+ lastAndroidOrientationXRef.current = Number(sample.x.toFixed(4));
457
+ lastAndroidOrientationYRef.current = Number(sample.y.toFixed(4));
458
+ lastAndroidOrientationZRef.current = Number(sample.z.toFixed(4));
459
+ lastAndroidOrientationDerivedRef.current = sample.orientation;
460
+ lastAndroidOrientationIgnoredReasonRef.current = sample.ignoredReason;
461
+ lastAndroidOrientationDominantAxisRef.current = sample.dominantAxis;
388
462
  if (noEventTimer) {
389
463
  clearTimeout(noEventTimer);
390
464
  noEventTimer = null;
391
465
  }
392
- let next = null;
393
- let ignoredReason = null;
394
- if (Math.abs(x) > Math.abs(y) + 0.2) {
395
- next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
396
- }
397
- else if (Math.abs(y) > Math.abs(x) + 0.2) {
398
- next = y > 0 ? 'portraitUpsideDown' : 'portrait';
399
- }
400
- else {
401
- ignoredReason = 'ambiguous_tilt';
402
- }
403
- lastAndroidOrientationDerivedRef.current = next;
404
- lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
466
+ const next = sample.orientation;
405
467
  if (next && next !== lastOrientation) {
406
468
  const previous = lastOrientation;
407
- lastOrientation = next;
408
- androidOrientationChangeCountRef.current++;
409
- setPhysicalOrientation(next);
410
- trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
411
- previous_physical_orientation: previous,
412
- next_physical_orientation: next,
413
- accelerometer_sampled_at: atIso,
414
- });
469
+ if (pendingAndroidPhysicalOrientationRef.current === next &&
470
+ androidOrientationSettleTimerRef.current) {
471
+ return;
472
+ }
473
+ pendingAndroidPhysicalOrientationRef.current = next;
474
+ pendingAndroidPhysicalOrientationStartedAtRef.current = atIso;
475
+ if (androidOrientationSettleTimerRef.current) {
476
+ clearTimeout(androidOrientationSettleTimerRef.current);
477
+ }
478
+ androidOrientationSettleTimerRef.current = setTimeout(() => {
479
+ if (cancelled || pendingAndroidPhysicalOrientationRef.current !== next)
480
+ return;
481
+ const pendingStartedAt = pendingAndroidPhysicalOrientationStartedAtRef.current;
482
+ pendingAndroidPhysicalOrientationRef.current = null;
483
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
484
+ androidOrientationSettleTimerRef.current = null;
485
+ if (next === lastOrientation)
486
+ return;
487
+ lastOrientation = next;
488
+ androidOrientationChangeCountRef.current++;
489
+ setPhysicalOrientation(next);
490
+ trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
491
+ android_physical_orientation_source: 'accelerometer_fallback',
492
+ previous_physical_orientation: previous,
493
+ next_physical_orientation: next,
494
+ accelerometer_sampled_at: atIso,
495
+ accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
496
+ accelerometer_dominant_axis: sample.dominantAxis,
497
+ android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
498
+ android_physical_orientation_pending_started_at: pendingStartedAt,
499
+ });
500
+ }, ANDROID_ORIENTATION_SETTLE_MS);
415
501
  return;
416
502
  }
503
+ if (!next && androidOrientationSettleTimerRef.current) {
504
+ clearTimeout(androidOrientationSettleTimerRef.current);
505
+ androidOrientationSettleTimerRef.current = null;
506
+ pendingAndroidPhysicalOrientationRef.current = null;
507
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
508
+ }
417
509
  const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
418
510
  at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
419
511
  if (shouldTrackSample) {
420
512
  lastAndroidOrientationTelemetryAtRef.current = at.getTime();
421
- trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
513
+ trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, {
514
+ accelerometer_sampled_at: atIso,
515
+ accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
516
+ accelerometer_dominant_axis: sample.dominantAxis,
517
+ });
422
518
  }
423
519
  });
424
520
  })();
425
521
  return () => {
426
522
  cancelled = true;
523
+ const wasActive = subscription != null || androidOrientationSubscriptionActiveRef.current;
524
+ if (androidOrientationSettleTimerRef.current) {
525
+ clearTimeout(androidOrientationSettleTimerRef.current);
526
+ androidOrientationSettleTimerRef.current = null;
527
+ }
528
+ pendingAndroidPhysicalOrientationRef.current = null;
529
+ pendingAndroidPhysicalOrientationStartedAtRef.current = null;
427
530
  androidOrientationSubscriptionActiveRef.current = false;
428
531
  if (noEventTimer)
429
532
  clearTimeout(noEventTimer);
430
533
  subscription?.remove();
534
+ if (wasActive) {
535
+ trackAndroidOrientationEvent('camera_android_accelerometer_stopped', 'android_accelerometer_tracking_stopped', { accelerometer_stop_reason: 'orientation_tracking_stopped' });
536
+ }
431
537
  };
432
538
  }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
433
539
  // Detect orientation changes and remount camera after rotation settles.
@@ -468,23 +574,31 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
468
574
  if (terminated)
469
575
  return;
470
576
  const subscription = AppState.addEventListener('change', (nextState) => {
471
- if (nextState === 'active') {
472
- const at = new Date().toISOString();
473
- lastAppStateRemountAtRef.current = at;
474
- cameraRemountCountRef.current++;
475
- // Force camera remount — on iOS, AVCaptureSession often fails to resume
476
- // its preview layer after returning from the notification bar or control center.
477
- telemetry?.track('camera_appstate_remount', {
478
- component: 'scanner',
479
- metadata: buildScannerTelemetryMetadata({
480
- remount_reason: 'appstate_active',
481
- remount_requested_at: at,
482
- }),
483
- });
577
+ const previousState = appStateRef.current;
578
+ appStateRef.current = nextState;
579
+ setCurrentAppState(nextState);
580
+ if (nextState !== 'active') {
484
581
  setCameraReady(false);
485
582
  cameraReadyRef.current = false;
486
- setCameraKey((k) => k + 1);
583
+ return;
487
584
  }
585
+ if (previousState === 'active')
586
+ return;
587
+ const at = new Date().toISOString();
588
+ lastAppStateRemountAtRef.current = at;
589
+ cameraRemountCountRef.current++;
590
+ // Force camera remount — on iOS, AVCaptureSession often fails to resume
591
+ // its preview layer after returning from the notification bar or control center.
592
+ telemetry?.track('camera_appstate_remount', {
593
+ component: 'scanner',
594
+ metadata: buildScannerTelemetryMetadata({
595
+ remount_reason: 'appstate_active',
596
+ remount_requested_at: at,
597
+ }),
598
+ });
599
+ setCameraReady(false);
600
+ cameraReadyRef.current = false;
601
+ setCameraKey((k) => k + 1);
488
602
  });
489
603
  return () => subscription.remove();
490
604
  }, [terminated, buildScannerTelemetryMetadata, telemetry]);
@@ -546,9 +660,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
546
660
  cameraReadyRef.current = true;
547
661
  cameraEverReadyRef.current = true;
548
662
  cameraInitFailedRef.current = false;
663
+ startupWatchdogRemountCountRef.current = 0;
549
664
  }, []);
550
665
  const onMountError = useCallback((event) => {
551
666
  const error = new Error(event.message || 'Camera mount error');
667
+ error.code = CAMERA_INIT_ERROR_CODE;
552
668
  setResult(null);
553
669
  setLastError(error);
554
670
  setStatus('error');
@@ -561,6 +677,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
561
677
  error,
562
678
  metadata: buildScannerTelemetryMetadata({
563
679
  mount_error_message: event.message,
680
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
681
+ startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
564
682
  }),
565
683
  });
566
684
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
@@ -569,19 +687,29 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
569
687
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
570
688
  // recreate the CameraView, which starts a fresh native session.
571
689
  useEffect(() => {
572
- if (!permission?.granted || terminated)
690
+ if (!permission?.granted || terminated || currentAppState !== 'active')
573
691
  return;
574
692
  const watchdogMs = Platform.OS === 'android'
575
693
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
576
694
  : IOS_CAMERA_STARTUP_WATCHDOG_MS;
577
695
  const timer = setTimeout(() => {
696
+ if (appStateRef.current !== 'active')
697
+ return;
578
698
  if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
579
699
  // Only track + remount on first-ever mount. A rotation/app-resume
580
- // triggered remount can legitimately take >3s without indicating a
700
+ // triggered remount can legitimately take several seconds without indicating a
581
701
  // real failure, and firing the alert each time is noisy without
582
- // surfacing new information. If the camera truly stays broken the
583
- // user will see capture failures, which is a more meaningful signal.
702
+ // surfacing new information. First startup still gets a capped retry loop
703
+ // so a persistent native camera failure becomes visible to the user.
584
704
  if (!cameraEverReadyRef.current) {
705
+ if (startupWatchdogRemountCountRef.current >= CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS) {
706
+ onMountError({
707
+ message: `Camera did not initialize after ${CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS} startup remount attempts`,
708
+ });
709
+ return;
710
+ }
711
+ const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
712
+ startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
585
713
  cameraRemountCountRef.current++;
586
714
  telemetry?.track('camera_preview_timeout', {
587
715
  component: 'scanner',
@@ -589,6 +717,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
589
717
  metadata: buildScannerTelemetryMetadata({
590
718
  watchdog_ms: watchdogMs,
591
719
  remount_reason: 'startup_watchdog',
720
+ startup_watchdog_remount_count: startupWatchdogRemountCount,
721
+ startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
592
722
  }),
593
723
  });
594
724
  setCameraReady(false);
@@ -598,7 +728,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
598
728
  }
599
729
  }, watchdogMs);
600
730
  return () => clearTimeout(timer);
601
- }, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
731
+ }, [
732
+ permission?.granted,
733
+ terminated,
734
+ currentAppState,
735
+ cameraKey,
736
+ buildScannerTelemetryMetadata,
737
+ onMountError,
738
+ telemetry,
739
+ ]);
602
740
  // Track permission denied
603
741
  useEffect(() => {
604
742
  if (permission &&
@@ -672,6 +810,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
672
810
  capture_physical_orientation: capturePhysicalOrientation,
673
811
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
674
812
  capture_rotation_applied: 0,
813
+ capture_rotation_source: Platform.OS === 'android'
814
+ ? 'expo_camera_native_orientation_event_listener'
815
+ : 'expo_camera_responsive_orientation',
816
+ accelerometer_event_count: androidOrientationEventCountRef.current,
817
+ last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
818
+ last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
819
+ last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
675
820
  }),
676
821
  });
677
822
  // --- Capture + best-effort resize ---
@@ -0,0 +1,15 @@
1
+ export type ScannerPhysicalOrientation = 'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight';
2
+ export type AndroidAccelerometerSample = {
3
+ x: number;
4
+ y: number;
5
+ z: number;
6
+ };
7
+ export type AndroidAccelerometerOrientationSample = AndroidAccelerometerSample & {
8
+ orientation: ScannerPhysicalOrientation | null;
9
+ ignoredReason: string | null;
10
+ dominantAxis: 'x' | 'y' | 'ambiguous';
11
+ xyDominance: number;
12
+ };
13
+ export declare const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
14
+ export declare function classifyAndroidAccelerometerOrientation(sample: AndroidAccelerometerSample, axisDominanceThreshold?: number): AndroidAccelerometerOrientationSample;
15
+ export declare function getOverlayRotationDeg(orientation: ScannerPhysicalOrientation): number;
@@ -0,0 +1,42 @@
1
+ export const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
2
+ export function classifyAndroidAccelerometerOrientation(sample, axisDominanceThreshold = ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD) {
3
+ const { x, y, z } = sample;
4
+ const xAbs = Math.abs(x);
5
+ const yAbs = Math.abs(y);
6
+ let orientation = null;
7
+ let ignoredReason = null;
8
+ let dominantAxis = 'ambiguous';
9
+ if (xAbs > yAbs + axisDominanceThreshold) {
10
+ dominantAxis = 'x';
11
+ orientation = x > 0 ? 'landscapeRight' : 'landscapeLeft';
12
+ }
13
+ else if (yAbs > xAbs + axisDominanceThreshold) {
14
+ dominantAxis = 'y';
15
+ orientation = y > 0 ? 'portraitUpsideDown' : 'portrait';
16
+ }
17
+ else {
18
+ ignoredReason = 'ambiguous_xy';
19
+ }
20
+ return {
21
+ x,
22
+ y,
23
+ z,
24
+ orientation,
25
+ ignoredReason,
26
+ dominantAxis,
27
+ xyDominance: Math.abs(xAbs - yAbs),
28
+ };
29
+ }
30
+ export function getOverlayRotationDeg(orientation) {
31
+ switch (orientation) {
32
+ case 'landscapeLeft':
33
+ return 90;
34
+ case 'landscapeRight':
35
+ return -90;
36
+ case 'portraitUpsideDown':
37
+ return 180;
38
+ case 'portrait':
39
+ default:
40
+ return 0;
41
+ }
42
+ }
@@ -14,6 +14,17 @@ const CRITICAL_EVENTS = new Set([
14
14
  'camera_init_failure',
15
15
  'camera_preview_timeout',
16
16
  'camera_permission_denied',
17
+ 'camera_scanner_mounted',
18
+ 'camera_scanner_disposed',
19
+ 'camera_android_native_orientation_started',
20
+ 'camera_android_native_orientation_no_events',
21
+ 'camera_android_native_orientation_error',
22
+ 'camera_android_native_orientation_start_failure',
23
+ 'camera_android_native_orientation_done',
24
+ 'camera_android_accelerometer_started',
25
+ 'camera_android_accelerometer_no_events',
26
+ 'camera_android_accelerometer_error',
27
+ 'camera_android_accelerometer_start_failure',
17
28
  ]);
18
29
  export class TelemetryReporter {
19
30
  constructor(apiKey, baseUrl) {
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.17";
1
+ export declare const SDK_VERSION = "2.4.20";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.17';
1
+ export const SDK_VERSION = '2.4.20';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.17",
3
+ "version": "2.4.20",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -178,12 +178,21 @@ export class VerifyAIClient {
178
178
  const telemetryError = eventType === 'request_timeout'
179
179
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
180
180
  : normalized;
181
+ const metadata: Record<string, string | number> = {
182
+ method: context.method,
183
+ path: context.path,
184
+ status: normalized.status,
185
+ timeout_ms: this.timeout,
186
+ };
187
+ if (normalized.code) metadata.code = normalized.code;
188
+ if (normalized.requestId) metadata.request_id = normalized.requestId;
181
189
  this.telemetry.track(
182
190
  eventType,
183
191
  {
184
192
  component: 'client',
185
193
  error: telemetryError,
186
194
  errorCode,
195
+ metadata,
187
196
  },
188
197
  );
189
198
  }
@@ -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
+ }
@@ -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.17';
1
+ export const SDK_VERSION = '2.4.20';