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

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.
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { type ViewStyle } from 'react-native';
3
- import type { VerificationResult, ScannerOverlayConfig } from '../types';
3
+ import type { VerificationResult, ScannerOverlayConfig, ScannerTelemetryContext } from '../types';
4
4
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
5
5
  export interface VerifyAIScannerProps {
6
6
  /** Called with base64 image data when the user captures a photo. */
@@ -27,6 +27,8 @@ export interface VerifyAIScannerProps {
27
27
  enableTorch?: boolean;
28
28
  /** Optional telemetry reporter (falls back to TelemetryContext). */
29
29
  telemetry?: TelemetryReporter | null;
30
+ /** Optional host-app context attached to scanner telemetry. */
31
+ telemetryContext?: ScannerTelemetryContext;
30
32
  }
31
33
  /**
32
34
  * Camera scanner component for capturing verification photos.
@@ -49,4 +51,4 @@ export interface VerifyAIScannerProps {
49
51
  * />
50
52
  * ```
51
53
  */
52
- export declare function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton, showCloseButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
54
+ export declare function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton, showCloseButton, captureRef, enableTorch, telemetry: telemetryProp, telemetryContext, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
@@ -19,6 +19,8 @@ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
19
19
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
20
20
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
21
21
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
22
+ const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
23
+ const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
22
24
  function getPolicyScannerDefaults(policy) {
23
25
  const id = policy?.toLowerCase() ?? '';
24
26
  const isForest = id.includes('forest') || id.includes('humanforest');
@@ -90,6 +92,15 @@ function compactTelemetryMetadata(metadata) {
90
92
  }
91
93
  return compacted;
92
94
  }
95
+ function getPlatformConstantString(...keys) {
96
+ const constants = Platform.constants;
97
+ for (const key of keys) {
98
+ const value = constants[key];
99
+ if (typeof value === 'string' && value.trim())
100
+ return value;
101
+ }
102
+ return null;
103
+ }
93
104
  function getErrorDisplay(error, showTechnicalDetails) {
94
105
  if (!error) {
95
106
  return {
@@ -171,7 +182,7 @@ function isTerminalRequestError(error) {
171
182
  * />
172
183
  * ```
173
184
  */
174
- export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
185
+ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, telemetryContext, }) {
175
186
  const contextTelemetry = useTelemetry();
176
187
  const telemetry = telemetryProp ?? contextTelemetry;
177
188
  const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
@@ -197,6 +208,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
197
208
  const lastOrientationRemountAtRef = useRef(null);
198
209
  const lastCaptureRetryAtRef = useRef(null);
199
210
  const cameraRemountCountRef = useRef(0);
211
+ const androidOrientationSubscriptionActiveRef = useRef(false);
212
+ const androidOrientationEventCountRef = useRef(0);
213
+ const androidOrientationChangeCountRef = useRef(0);
214
+ const androidOrientationStartedAtRef = useRef(null);
215
+ const lastAndroidOrientationEventAtRef = useRef(null);
216
+ const lastAndroidOrientationTelemetryAtRef = useRef(0);
217
+ const lastAndroidOrientationXRef = useRef(null);
218
+ const lastAndroidOrientationYRef = useRef(null);
219
+ const lastAndroidOrientationZRef = useRef(null);
220
+ const lastAndroidOrientationDerivedRef = useRef(null);
221
+ const lastAndroidOrientationIgnoredReasonRef = useRef(null);
222
+ const lastAndroidOrientationErrorAtRef = useRef(null);
223
+ const lastAndroidOrientationErrorRef = useRef(null);
200
224
  // Track dimensions for orientation detection and responsive layout
201
225
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
202
226
  const isLandscape = windowWidth > windowHeight;
@@ -207,47 +231,6 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
207
231
  // uses the accelerometer via expo-sensors (the callback is iOS-only). Either
208
232
  // way we rotate the overlay UI to stay readable from the user's viewpoint.
209
233
  const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
210
- useEffect(() => {
211
- if (Platform.OS !== 'android')
212
- return;
213
- let subscription = null;
214
- let cancelled = false;
215
- let lastOrientation = 'portrait';
216
- (async () => {
217
- let Accelerometer = null;
218
- try {
219
- // eslint-disable-next-line @typescript-eslint/no-require-imports
220
- const mod = require('expo-sensors');
221
- Accelerometer = mod?.Accelerometer ?? null;
222
- }
223
- catch {
224
- return;
225
- }
226
- if (cancelled || !Accelerometer)
227
- return;
228
- Accelerometer.setUpdateInterval(500);
229
- subscription = Accelerometer.addListener(({ x, y }) => {
230
- let next;
231
- if (Math.abs(x) > Math.abs(y) + 0.2) {
232
- next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
233
- }
234
- else if (Math.abs(y) > Math.abs(x) + 0.2) {
235
- next = y > 0 ? 'portraitUpsideDown' : 'portrait';
236
- }
237
- else {
238
- return; // ambiguous tilt — ignore
239
- }
240
- if (next !== lastOrientation) {
241
- lastOrientation = next;
242
- setPhysicalOrientation(next);
243
- }
244
- });
245
- })();
246
- return () => {
247
- cancelled = true;
248
- subscription?.remove();
249
- };
250
- }, []);
251
234
  const overlayRotationDeg = (() => {
252
235
  switch (physicalOrientation) {
253
236
  case 'landscapeLeft':
@@ -273,6 +256,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
273
256
  exhausted,
274
257
  app_state: AppState.currentState,
275
258
  sdk_platform: Platform.OS,
259
+ device_model: getPlatformConstantString('Model', 'model'),
260
+ device_os_version: String(Platform.Version),
261
+ route_name: telemetryContext?.routeName,
262
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
276
263
  window_width: windowWidth,
277
264
  window_height: windowHeight,
278
265
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -282,6 +269,18 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
282
269
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
283
270
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
284
271
  last_capture_retry_at: lastCaptureRetryAtRef.current,
272
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
273
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
274
+ android_orientation_event_count: androidOrientationEventCountRef.current,
275
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
276
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
277
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
278
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
279
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
280
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
281
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
282
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
283
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
285
284
  ...extra,
286
285
  }), [
287
286
  cameraKey,
@@ -291,10 +290,146 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
291
290
  physicalOrientation,
292
291
  policy,
293
292
  status,
293
+ telemetryContext?.isPortraitLocked,
294
+ telemetryContext?.routeName,
294
295
  terminated,
295
296
  windowHeight,
296
297
  windowWidth,
297
298
  ]);
299
+ useEffect(() => {
300
+ if (Platform.OS !== 'android')
301
+ return;
302
+ let subscription = null;
303
+ let cancelled = false;
304
+ let noEventTimer = null;
305
+ let lastOrientation = 'portrait';
306
+ const androidMetadata = (extra = {}) => compactTelemetryMetadata({
307
+ policy,
308
+ sdk_platform: Platform.OS,
309
+ device_model: getPlatformConstantString('Model', 'model'),
310
+ device_os_version: String(Platform.Version),
311
+ route_name: telemetryContext?.routeName,
312
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
313
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
314
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
315
+ android_orientation_event_count: androidOrientationEventCountRef.current,
316
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
317
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
318
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
319
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
320
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
321
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
322
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
323
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
324
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
325
+ ...extra,
326
+ });
327
+ const trackAndroidOrientationEvent = (eventType, error, metadata = {}) => {
328
+ telemetry?.track(eventType, {
329
+ component: 'scanner',
330
+ error,
331
+ metadata: androidMetadata(metadata),
332
+ });
333
+ };
334
+ (async () => {
335
+ let Accelerometer = null;
336
+ try {
337
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
338
+ const mod = require('expo-sensors');
339
+ Accelerometer = mod?.Accelerometer ?? null;
340
+ }
341
+ catch (err) {
342
+ const error = normalizeScannerError(err);
343
+ const message = error.message || '';
344
+ const isMissingModule = error.code === 'MODULE_NOT_FOUND' ||
345
+ /cannot find module/i.test(message) ||
346
+ /unable to resolve module/i.test(message) ||
347
+ /requireNativeModule/i.test(message);
348
+ if (isMissingModule) {
349
+ // expo-sensors is an optional peer dep. Host apps that don't bundle it
350
+ // simply lose Android orientation tracking — that's expected, not a failure.
351
+ return;
352
+ }
353
+ lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
354
+ lastAndroidOrientationErrorRef.current = error.message;
355
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
356
+ return;
357
+ }
358
+ if (cancelled)
359
+ return;
360
+ if (!Accelerometer) {
361
+ // Module loaded but no Accelerometer export — same expected-absence case.
362
+ return;
363
+ }
364
+ const startedAt = new Date().toISOString();
365
+ androidOrientationStartedAtRef.current = startedAt;
366
+ androidOrientationSubscriptionActiveRef.current = true;
367
+ androidOrientationEventCountRef.current = 0;
368
+ androidOrientationChangeCountRef.current = 0;
369
+ lastAndroidOrientationTelemetryAtRef.current = 0;
370
+ trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
371
+ accelerometer_update_interval_ms: 500,
372
+ accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
373
+ });
374
+ noEventTimer = setTimeout(() => {
375
+ if (cancelled || androidOrientationEventCountRef.current > 0)
376
+ return;
377
+ trackAndroidOrientationEvent('camera_android_accelerometer_no_events', `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`);
378
+ }, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
379
+ Accelerometer.setUpdateInterval(500);
380
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
381
+ const at = new Date();
382
+ const atIso = at.toISOString();
383
+ androidOrientationEventCountRef.current++;
384
+ lastAndroidOrientationEventAtRef.current = atIso;
385
+ lastAndroidOrientationXRef.current = Number(x.toFixed(4));
386
+ lastAndroidOrientationYRef.current = Number(y.toFixed(4));
387
+ lastAndroidOrientationZRef.current = Number(z.toFixed(4));
388
+ if (noEventTimer) {
389
+ clearTimeout(noEventTimer);
390
+ noEventTimer = null;
391
+ }
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;
405
+ if (next && next !== lastOrientation) {
406
+ 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
+ });
415
+ return;
416
+ }
417
+ const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
418
+ at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
419
+ if (shouldTrackSample) {
420
+ lastAndroidOrientationTelemetryAtRef.current = at.getTime();
421
+ trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
422
+ }
423
+ });
424
+ })();
425
+ return () => {
426
+ cancelled = true;
427
+ androidOrientationSubscriptionActiveRef.current = false;
428
+ if (noEventTimer)
429
+ clearTimeout(noEventTimer);
430
+ subscription?.remove();
431
+ };
432
+ }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
298
433
  // Detect orientation changes and remount camera after rotation settles.
299
434
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
300
435
  // animation — the native preview layer initializes with transitional bounds.
@@ -478,9 +613,29 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
478
613
  }
479
614
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
480
615
  const handleCapture = useCallback(async () => {
481
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
616
+ const blockedReason = !cameraRef.current
617
+ ? 'camera_ref_null'
618
+ : !cameraReadyRef.current
619
+ ? 'camera_not_ready'
620
+ : status === 'capturing'
621
+ ? 'already_capturing'
622
+ : status === 'processing'
623
+ ? 'already_processing'
624
+ : terminated
625
+ ? 'terminated'
626
+ : exhausted
627
+ ? 'exhausted'
628
+ : null;
629
+ telemetry?.track('camera_capture_request', {
630
+ component: 'scanner',
631
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
632
+ metadata: buildScannerTelemetryMetadata({
633
+ capture_blocked_reason: blockedReason,
634
+ }),
635
+ });
636
+ if (blockedReason && blockedReason !== 'camera_not_ready')
482
637
  return;
483
- if (!cameraReadyRef.current) {
638
+ if (blockedReason === 'camera_not_ready') {
484
639
  const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
485
640
  setResult(null);
486
641
  setLastError(error);
@@ -508,6 +663,17 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
508
663
  let captureRetryReady = false;
509
664
  let lastNativeCaptureErrorMessage = null;
510
665
  try {
666
+ const capturePhysicalOrientation = physicalOrientation;
667
+ const captureOverlayRotationDeg = overlayRotationDeg;
668
+ telemetry?.track('camera_capture_orientation_context', {
669
+ component: 'scanner',
670
+ error: 'capture_orientation_context',
671
+ metadata: buildScannerTelemetryMetadata({
672
+ capture_physical_orientation: capturePhysicalOrientation,
673
+ capture_overlay_rotation_deg: captureOverlayRotationDeg,
674
+ capture_rotation_applied: 0,
675
+ }),
676
+ });
511
677
  // --- Capture + best-effort resize ---
512
678
  // Strategy: try to dynamically import expo-image-manipulator.
513
679
  // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
@@ -561,6 +727,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
561
727
  setCameraKey((key) => key + 1);
562
728
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
563
729
  captureRetryReady = await waitForCameraReady(2500);
730
+ telemetry?.track('camera_capture_retry', {
731
+ component: 'scanner',
732
+ error: normalized,
733
+ errorCode: normalized.code,
734
+ metadata: buildScannerTelemetryMetadata({
735
+ native_capture_attempts: nativeCaptureAttempts,
736
+ capture_retry_attempted: captureRetryAttempted,
737
+ capture_retry_ready: captureRetryReady,
738
+ capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
739
+ capture_retry_ready_timeout_ms: 2500,
740
+ last_native_capture_error: lastNativeCaptureErrorMessage,
741
+ }),
742
+ });
564
743
  if (captureRetryReady) {
565
744
  continue;
566
745
  }
@@ -714,7 +893,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
714
893
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
715
894
  }
716
895
  }
717
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
896
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
718
897
  // Expose capture to parent via ref
719
898
  if (captureRef) {
720
899
  captureRef.current = handleCapture;
@@ -730,6 +909,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
730
909
  setPhysicalOrientation(event.orientation);
731
910
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
732
911
  styles.titleText,
912
+ overlay.theme?.titleStyle,
733
913
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
734
914
  ], children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: [styles.guideContainer, isLandscape && styles.guideContainerLandscape], children: [_jsxs(View, { style: [
735
915
  styles.guideFrame,
@@ -739,8 +919,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
739
919
  : undefined,
740
920
  ], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] })] }), overlay.guideCaption && (_jsx(Text, { style: [
741
921
  styles.guideCaptionText,
922
+ overlay.theme?.feedbackStyle,
742
923
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
743
- ], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
924
+ ], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: [styles.statusText, overlay?.theme?.feedbackStyle], children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
744
925
  styles.resultIconCircle,
745
926
  exhausted
746
927
  ? styles.resultIconExhausted
@@ -758,7 +939,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
758
939
  ? (overlay?.exhaustedMessage || 'Submitted for review')
759
940
  : result.is_compliant
760
941
  ? (overlay?.successMessage || 'Verified')
761
- : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
942
+ : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
762
943
  let errorTitle;
763
944
  let errorMessage;
764
945
  let showCloseAction = false;
@@ -785,9 +966,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
785
966
  errorMessage = display.message;
786
967
  showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
787
968
  }
788
- return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
969
+ return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
789
970
  })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
790
971
  styles.instructionsText,
972
+ overlay.theme?.feedbackStyle,
791
973
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
792
974
  ], children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
793
975
  styles.captureButton,
package/lib/index.d.ts CHANGED
@@ -5,4 +5,4 @@ export { TelemetryReporter } from './telemetry/TelemetryReporter';
5
5
  export { TelemetryContext } from './telemetry/TelemetryContext';
6
6
  export { OfflineQueue } from './storage/offlineQueue';
7
7
  export type { BundleManifest, ModelArtifact, FeatureVector, Detection as MLDetection, PolicyAST, PolicyResult, RuleResult, } from './ml/types';
8
- export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, PolicyConfigResponse, } from './types';
8
+ export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, ScannerTelemetryContext, PolicyConfigResponse, } from './types';
@@ -1,4 +1,5 @@
1
1
  import type React from 'react';
2
+ import type { TextStyle } from 'react-native';
2
3
  export interface VerifyAIConfig {
3
4
  apiKey: string;
4
5
  baseUrl?: string;
@@ -76,6 +77,13 @@ export interface VerifyAIError {
76
77
  method?: string;
77
78
  }
78
79
  export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
80
+ /** Optional host-app context attached to scanner telemetry. */
81
+ export interface ScannerTelemetryContext {
82
+ /** Route or screen name from the host app. */
83
+ routeName?: string;
84
+ /** Whether the host app keeps this screen portrait-locked. */
85
+ isPortraitLocked?: boolean;
86
+ }
79
87
  /**
80
88
  * Theme customization for the scanner overlay.
81
89
  *
@@ -92,6 +100,10 @@ export interface ScannerTheme {
92
100
  captureButtonColor?: string;
93
101
  /** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
94
102
  cornerColor?: string;
103
+ /** Text style override for the scanner title. */
104
+ titleStyle?: TextStyle;
105
+ /** Text style override for feedback/instruction text. */
106
+ feedbackStyle?: TextStyle;
95
107
  }
96
108
  export interface ScannerOverlayConfig {
97
109
  title?: string;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.15";
1
+ export declare const SDK_VERSION = "2.4.17";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.15';
1
+ export const SDK_VERSION = '2.4.17';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.15",
3
+ "version": "2.4.17",
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",
@@ -18,6 +18,7 @@ import type {
18
18
  VerificationResult,
19
19
  ScannerStatus,
20
20
  ScannerOverlayConfig,
21
+ ScannerTelemetryContext,
21
22
  } from '../types';
22
23
  import { VerifyAIRequestError } from '../client';
23
24
  import { useTelemetry } from '../telemetry/TelemetryContext';
@@ -38,6 +39,8 @@ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
38
39
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
39
40
  const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
40
41
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
42
+ const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
43
+ const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
41
44
 
42
45
  export interface VerifyAIScannerProps {
43
46
  /** Called with base64 image data when the user captures a photo. */
@@ -64,6 +67,8 @@ export interface VerifyAIScannerProps {
64
67
  enableTorch?: boolean;
65
68
  /** Optional telemetry reporter (falls back to TelemetryContext). */
66
69
  telemetry?: TelemetryReporter | null;
70
+ /** Optional host-app context attached to scanner telemetry. */
71
+ telemetryContext?: ScannerTelemetryContext;
67
72
  }
68
73
 
69
74
  type ErrorWithDetails = Error & {
@@ -165,6 +170,15 @@ function compactTelemetryMetadata(
165
170
  return compacted;
166
171
  }
167
172
 
173
+ function getPlatformConstantString(...keys: string[]): string | null {
174
+ const constants = Platform.constants as Record<string, unknown>;
175
+ for (const key of keys) {
176
+ const value = constants[key];
177
+ if (typeof value === 'string' && value.trim()) return value;
178
+ }
179
+ return null;
180
+ }
181
+
168
182
  function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
169
183
  if (!error) {
170
184
  return {
@@ -261,6 +275,7 @@ export function VerifyAIScanner({
261
275
  captureRef,
262
276
  enableTorch,
263
277
  telemetry: telemetryProp,
278
+ telemetryContext,
264
279
  }: VerifyAIScannerProps) {
265
280
  const contextTelemetry = useTelemetry();
266
281
  const telemetry = telemetryProp ?? contextTelemetry;
@@ -291,6 +306,19 @@ export function VerifyAIScanner({
291
306
  const lastOrientationRemountAtRef = useRef<string | null>(null);
292
307
  const lastCaptureRetryAtRef = useRef<string | null>(null);
293
308
  const cameraRemountCountRef = useRef(0);
309
+ const androidOrientationSubscriptionActiveRef = useRef(false);
310
+ const androidOrientationEventCountRef = useRef(0);
311
+ const androidOrientationChangeCountRef = useRef(0);
312
+ const androidOrientationStartedAtRef = useRef<string | null>(null);
313
+ const lastAndroidOrientationEventAtRef = useRef<string | null>(null);
314
+ const lastAndroidOrientationTelemetryAtRef = useRef(0);
315
+ const lastAndroidOrientationXRef = useRef<number | null>(null);
316
+ const lastAndroidOrientationYRef = useRef<number | null>(null);
317
+ const lastAndroidOrientationZRef = useRef<number | null>(null);
318
+ const lastAndroidOrientationDerivedRef = useRef<string | null>(null);
319
+ const lastAndroidOrientationIgnoredReasonRef = useRef<string | null>(null);
320
+ const lastAndroidOrientationErrorAtRef = useRef<string | null>(null);
321
+ const lastAndroidOrientationErrorRef = useRef<string | null>(null);
294
322
 
295
323
  // Track dimensions for orientation detection and responsive layout
296
324
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -306,51 +334,6 @@ export function VerifyAIScanner({
306
334
  'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight'
307
335
  >('portrait');
308
336
 
309
- useEffect(() => {
310
- if (Platform.OS !== 'android') return;
311
- let subscription: { remove: () => void } | null = null;
312
- let cancelled = false;
313
- let lastOrientation: typeof physicalOrientation = 'portrait';
314
-
315
- (async () => {
316
- let Accelerometer: {
317
- setUpdateInterval: (ms: number) => void;
318
- addListener: (
319
- cb: (data: { x: number; y: number; z: number }) => void,
320
- ) => { remove: () => void };
321
- } | null = null;
322
- try {
323
- // eslint-disable-next-line @typescript-eslint/no-require-imports
324
- const mod = require('expo-sensors');
325
- Accelerometer = mod?.Accelerometer ?? null;
326
- } catch {
327
- return;
328
- }
329
- if (cancelled || !Accelerometer) return;
330
-
331
- Accelerometer.setUpdateInterval(500);
332
- subscription = Accelerometer.addListener(({ x, y }) => {
333
- let next: typeof physicalOrientation;
334
- if (Math.abs(x) > Math.abs(y) + 0.2) {
335
- next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
336
- } else if (Math.abs(y) > Math.abs(x) + 0.2) {
337
- next = y > 0 ? 'portraitUpsideDown' : 'portrait';
338
- } else {
339
- return; // ambiguous tilt — ignore
340
- }
341
- if (next !== lastOrientation) {
342
- lastOrientation = next;
343
- setPhysicalOrientation(next);
344
- }
345
- });
346
- })();
347
-
348
- return () => {
349
- cancelled = true;
350
- subscription?.remove();
351
- };
352
- }, []);
353
-
354
337
  const overlayRotationDeg = (() => {
355
338
  switch (physicalOrientation) {
356
339
  case 'landscapeLeft':
@@ -379,6 +362,10 @@ export function VerifyAIScanner({
379
362
  exhausted,
380
363
  app_state: AppState.currentState,
381
364
  sdk_platform: Platform.OS,
365
+ device_model: getPlatformConstantString('Model', 'model'),
366
+ device_os_version: String(Platform.Version),
367
+ route_name: telemetryContext?.routeName,
368
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
382
369
  window_width: windowWidth,
383
370
  window_height: windowHeight,
384
371
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -388,6 +375,18 @@ export function VerifyAIScanner({
388
375
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
389
376
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
390
377
  last_capture_retry_at: lastCaptureRetryAtRef.current,
378
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
379
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
380
+ android_orientation_event_count: androidOrientationEventCountRef.current,
381
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
382
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
383
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
384
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
385
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
386
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
387
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
388
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
389
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
391
390
  ...extra,
392
391
  }), [
393
392
  cameraKey,
@@ -397,11 +396,173 @@ export function VerifyAIScanner({
397
396
  physicalOrientation,
398
397
  policy,
399
398
  status,
399
+ telemetryContext?.isPortraitLocked,
400
+ telemetryContext?.routeName,
400
401
  terminated,
401
402
  windowHeight,
402
403
  windowWidth,
403
404
  ]);
404
405
 
406
+ useEffect(() => {
407
+ if (Platform.OS !== 'android') return;
408
+
409
+ let subscription: { remove: () => void } | null = null;
410
+ let cancelled = false;
411
+ let noEventTimer: ReturnType<typeof setTimeout> | null = null;
412
+ let lastOrientation: typeof physicalOrientation = 'portrait';
413
+
414
+ const androidMetadata = (
415
+ extra: Record<string, string | number | boolean | null | undefined> = {},
416
+ ) => compactTelemetryMetadata({
417
+ policy,
418
+ sdk_platform: Platform.OS,
419
+ device_model: getPlatformConstantString('Model', 'model'),
420
+ device_os_version: String(Platform.Version),
421
+ route_name: telemetryContext?.routeName,
422
+ is_portrait_locked: telemetryContext?.isPortraitLocked,
423
+ android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
424
+ android_orientation_started_at: androidOrientationStartedAtRef.current,
425
+ android_orientation_event_count: androidOrientationEventCountRef.current,
426
+ android_orientation_change_count: androidOrientationChangeCountRef.current,
427
+ last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
428
+ last_android_orientation_x: lastAndroidOrientationXRef.current,
429
+ last_android_orientation_y: lastAndroidOrientationYRef.current,
430
+ last_android_orientation_z: lastAndroidOrientationZRef.current,
431
+ last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
432
+ last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
433
+ last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
434
+ last_android_orientation_error: lastAndroidOrientationErrorRef.current,
435
+ ...extra,
436
+ });
437
+
438
+ const trackAndroidOrientationEvent = (
439
+ eventType: string,
440
+ error: unknown,
441
+ metadata: Record<string, string | number | boolean | null | undefined> = {},
442
+ ) => {
443
+ telemetry?.track(eventType, {
444
+ component: 'scanner',
445
+ error,
446
+ metadata: androidMetadata(metadata),
447
+ });
448
+ };
449
+
450
+ (async () => {
451
+ let Accelerometer: {
452
+ setUpdateInterval: (ms: number) => void;
453
+ addListener: (
454
+ cb: (data: { x: number; y: number; z: number }) => void,
455
+ ) => { remove: () => void };
456
+ } | null = null;
457
+ try {
458
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
459
+ const mod = require('expo-sensors');
460
+ Accelerometer = mod?.Accelerometer ?? null;
461
+ } catch (err) {
462
+ const error = normalizeScannerError(err);
463
+ const message = error.message || '';
464
+ const isMissingModule =
465
+ (error as { code?: string }).code === 'MODULE_NOT_FOUND' ||
466
+ /cannot find module/i.test(message) ||
467
+ /unable to resolve module/i.test(message) ||
468
+ /requireNativeModule/i.test(message);
469
+ if (isMissingModule) {
470
+ // expo-sensors is an optional peer dep. Host apps that don't bundle it
471
+ // simply lose Android orientation tracking — that's expected, not a failure.
472
+ return;
473
+ }
474
+ lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
475
+ lastAndroidOrientationErrorRef.current = error.message;
476
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
477
+ return;
478
+ }
479
+ if (cancelled) return;
480
+ if (!Accelerometer) {
481
+ // Module loaded but no Accelerometer export — same expected-absence case.
482
+ return;
483
+ }
484
+
485
+ const startedAt = new Date().toISOString();
486
+ androidOrientationStartedAtRef.current = startedAt;
487
+ androidOrientationSubscriptionActiveRef.current = true;
488
+ androidOrientationEventCountRef.current = 0;
489
+ androidOrientationChangeCountRef.current = 0;
490
+ lastAndroidOrientationTelemetryAtRef.current = 0;
491
+ trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
492
+ accelerometer_update_interval_ms: 500,
493
+ accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
494
+ });
495
+
496
+ noEventTimer = setTimeout(() => {
497
+ if (cancelled || androidOrientationEventCountRef.current > 0) return;
498
+ trackAndroidOrientationEvent(
499
+ 'camera_android_accelerometer_no_events',
500
+ `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`,
501
+ );
502
+ }, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
503
+
504
+ Accelerometer.setUpdateInterval(500);
505
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
506
+ const at = new Date();
507
+ const atIso = at.toISOString();
508
+ androidOrientationEventCountRef.current++;
509
+ lastAndroidOrientationEventAtRef.current = atIso;
510
+ lastAndroidOrientationXRef.current = Number(x.toFixed(4));
511
+ lastAndroidOrientationYRef.current = Number(y.toFixed(4));
512
+ lastAndroidOrientationZRef.current = Number(z.toFixed(4));
513
+ if (noEventTimer) {
514
+ clearTimeout(noEventTimer);
515
+ noEventTimer = null;
516
+ }
517
+
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
+
531
+ if (next && next !== lastOrientation) {
532
+ 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
+ });
541
+ return;
542
+ }
543
+
544
+ const shouldTrackSample =
545
+ androidOrientationEventCountRef.current === 1 ||
546
+ at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
547
+ if (shouldTrackSample) {
548
+ lastAndroidOrientationTelemetryAtRef.current = at.getTime();
549
+ trackAndroidOrientationEvent(
550
+ 'camera_android_accelerometer_sample',
551
+ `accelerometer_sample_${androidOrientationEventCountRef.current}`,
552
+ { accelerometer_sampled_at: atIso },
553
+ );
554
+ }
555
+ });
556
+ })();
557
+
558
+ return () => {
559
+ cancelled = true;
560
+ androidOrientationSubscriptionActiveRef.current = false;
561
+ if (noEventTimer) clearTimeout(noEventTimer);
562
+ subscription?.remove();
563
+ };
564
+ }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
565
+
405
566
  // Detect orientation changes and remount camera after rotation settles.
406
567
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
407
568
  // animation — the native preview layer initializes with transitional bounds.
@@ -605,8 +766,31 @@ export function VerifyAIScanner({
605
766
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
606
767
 
607
768
  const handleCapture = useCallback(async () => {
608
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
609
- if (!cameraReadyRef.current) {
769
+ const blockedReason =
770
+ !cameraRef.current
771
+ ? 'camera_ref_null'
772
+ : !cameraReadyRef.current
773
+ ? 'camera_not_ready'
774
+ : status === 'capturing'
775
+ ? 'already_capturing'
776
+ : status === 'processing'
777
+ ? 'already_processing'
778
+ : terminated
779
+ ? 'terminated'
780
+ : exhausted
781
+ ? 'exhausted'
782
+ : null;
783
+
784
+ telemetry?.track('camera_capture_request', {
785
+ component: 'scanner',
786
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
787
+ metadata: buildScannerTelemetryMetadata({
788
+ capture_blocked_reason: blockedReason,
789
+ }),
790
+ });
791
+
792
+ if (blockedReason && blockedReason !== 'camera_not_ready') return;
793
+ if (blockedReason === 'camera_not_ready') {
610
794
  const error = createScannerError(
611
795
  'Camera is not ready yet. Please wait a moment and try again.',
612
796
  CAMERA_NOT_READY_ERROR_CODE,
@@ -641,6 +825,18 @@ export function VerifyAIScanner({
641
825
  let lastNativeCaptureErrorMessage: string | null = null;
642
826
 
643
827
  try {
828
+ const capturePhysicalOrientation = physicalOrientation;
829
+ const captureOverlayRotationDeg = overlayRotationDeg;
830
+ telemetry?.track('camera_capture_orientation_context', {
831
+ component: 'scanner',
832
+ error: 'capture_orientation_context',
833
+ metadata: buildScannerTelemetryMetadata({
834
+ capture_physical_orientation: capturePhysicalOrientation,
835
+ capture_overlay_rotation_deg: captureOverlayRotationDeg,
836
+ capture_rotation_applied: 0,
837
+ }),
838
+ });
839
+
644
840
  // --- Capture + best-effort resize ---
645
841
  // Strategy: try to dynamically import expo-image-manipulator.
646
842
  // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
@@ -704,6 +900,19 @@ export function VerifyAIScanner({
704
900
  setCameraKey((key) => key + 1);
705
901
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
706
902
  captureRetryReady = await waitForCameraReady(2500);
903
+ telemetry?.track('camera_capture_retry', {
904
+ component: 'scanner',
905
+ error: normalized,
906
+ errorCode: normalized.code,
907
+ metadata: buildScannerTelemetryMetadata({
908
+ native_capture_attempts: nativeCaptureAttempts,
909
+ capture_retry_attempted: captureRetryAttempted,
910
+ capture_retry_ready: captureRetryReady,
911
+ capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
912
+ capture_retry_ready_timeout_ms: 2500,
913
+ last_native_capture_error: lastNativeCaptureErrorMessage,
914
+ }),
915
+ });
707
916
  if (captureRetryReady) {
708
917
  continue;
709
918
  }
@@ -891,7 +1100,7 @@ export function VerifyAIScanner({
891
1100
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
892
1101
  }
893
1102
  }
894
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
1103
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
895
1104
 
896
1105
  // Expose capture to parent via ref
897
1106
  if (captureRef) {
@@ -937,6 +1146,7 @@ export function VerifyAIScanner({
937
1146
  <Text
938
1147
  style={[
939
1148
  styles.titleText,
1149
+ overlay.theme?.titleStyle,
940
1150
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
941
1151
  ]}
942
1152
  >
@@ -972,6 +1182,7 @@ export function VerifyAIScanner({
972
1182
  <Text
973
1183
  style={[
974
1184
  styles.guideCaptionText,
1185
+ overlay.theme?.feedbackStyle,
975
1186
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
976
1187
  ]}
977
1188
  >
@@ -985,7 +1196,7 @@ export function VerifyAIScanner({
985
1196
  {status === 'processing' && (
986
1197
  <View style={styles.processingOverlay}>
987
1198
  <ActivityIndicator size="large" color="#fff" />
988
- <Text style={styles.statusText}>
1199
+ <Text style={[styles.statusText, overlay?.theme?.feedbackStyle]}>
989
1200
  {overlay?.processingMessage || 'Analyzing photo...'}
990
1201
  </Text>
991
1202
  </View>
@@ -1030,7 +1241,7 @@ export function VerifyAIScanner({
1030
1241
  : (overlay?.failureMessage || 'Not Verified')}
1031
1242
  </Text>
1032
1243
  </View>
1033
- <Text style={styles.feedbackText}>{result.feedback}</Text>
1244
+ <Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{result.feedback}</Text>
1034
1245
  {overlay?.showTerminalActionButton !== false && onResult && (
1035
1246
  <TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
1036
1247
  <Text style={styles.cardButtonText}>
@@ -1077,7 +1288,7 @@ export function VerifyAIScanner({
1077
1288
  {errorTitle}
1078
1289
  </Text>
1079
1290
  </View>
1080
- <Text style={styles.feedbackText}>{errorMessage}</Text>
1291
+ <Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{errorMessage}</Text>
1081
1292
  {showCloseAction && (
1082
1293
  <TouchableOpacity style={styles.cardButton} onPress={handleClose}>
1083
1294
  <Text style={styles.cardButtonText}>Close</Text>
@@ -1093,6 +1304,7 @@ export function VerifyAIScanner({
1093
1304
  <Text
1094
1305
  style={[
1095
1306
  styles.instructionsText,
1307
+ overlay.theme?.feedbackStyle,
1096
1308
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
1097
1309
  ]}
1098
1310
  >
package/src/index.ts CHANGED
@@ -39,5 +39,6 @@ export type {
39
39
  ScannerStatus,
40
40
  ScannerOverlayConfig,
41
41
  ScannerTheme,
42
+ ScannerTelemetryContext,
42
43
  PolicyConfigResponse,
43
44
  } from './types';
@@ -1,4 +1,5 @@
1
1
  import type React from 'react';
2
+ import type { TextStyle } from 'react-native';
2
3
 
3
4
  export interface VerifyAIConfig {
4
5
  apiKey: string;
@@ -87,6 +88,14 @@ export interface VerifyAIError {
87
88
 
88
89
  export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
89
90
 
91
+ /** Optional host-app context attached to scanner telemetry. */
92
+ export interface ScannerTelemetryContext {
93
+ /** Route or screen name from the host app. */
94
+ routeName?: string;
95
+ /** Whether the host app keeps this screen portrait-locked. */
96
+ isPortraitLocked?: boolean;
97
+ }
98
+
90
99
  /**
91
100
  * Theme customization for the scanner overlay.
92
101
  *
@@ -103,6 +112,10 @@ export interface ScannerTheme {
103
112
  captureButtonColor?: string;
104
113
  /** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
105
114
  cornerColor?: string;
115
+ /** Text style override for the scanner title. */
116
+ titleStyle?: TextStyle;
117
+ /** Text style override for feedback/instruction text. */
118
+ feedbackStyle?: TextStyle;
106
119
  }
107
120
 
108
121
  export interface ScannerOverlayConfig {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.15';
1
+ export const SDK_VERSION = '2.4.17';