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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,136 @@ 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
+ lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
344
+ lastAndroidOrientationErrorRef.current = error.message;
345
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
346
+ return;
347
+ }
348
+ if (cancelled)
349
+ return;
350
+ if (!Accelerometer) {
351
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', 'expo-sensors Accelerometer is unavailable');
352
+ return;
353
+ }
354
+ const startedAt = new Date().toISOString();
355
+ androidOrientationStartedAtRef.current = startedAt;
356
+ androidOrientationSubscriptionActiveRef.current = true;
357
+ androidOrientationEventCountRef.current = 0;
358
+ androidOrientationChangeCountRef.current = 0;
359
+ lastAndroidOrientationTelemetryAtRef.current = 0;
360
+ trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
361
+ accelerometer_update_interval_ms: 500,
362
+ accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
363
+ });
364
+ noEventTimer = setTimeout(() => {
365
+ if (cancelled || androidOrientationEventCountRef.current > 0)
366
+ return;
367
+ trackAndroidOrientationEvent('camera_android_accelerometer_no_events', `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`);
368
+ }, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
369
+ Accelerometer.setUpdateInterval(500);
370
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
371
+ const at = new Date();
372
+ const atIso = at.toISOString();
373
+ androidOrientationEventCountRef.current++;
374
+ lastAndroidOrientationEventAtRef.current = atIso;
375
+ lastAndroidOrientationXRef.current = Number(x.toFixed(4));
376
+ lastAndroidOrientationYRef.current = Number(y.toFixed(4));
377
+ lastAndroidOrientationZRef.current = Number(z.toFixed(4));
378
+ if (noEventTimer) {
379
+ clearTimeout(noEventTimer);
380
+ noEventTimer = null;
381
+ }
382
+ let next = null;
383
+ let ignoredReason = null;
384
+ if (Math.abs(x) > Math.abs(y) + 0.2) {
385
+ next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
386
+ }
387
+ else if (Math.abs(y) > Math.abs(x) + 0.2) {
388
+ next = y > 0 ? 'portraitUpsideDown' : 'portrait';
389
+ }
390
+ else {
391
+ ignoredReason = 'ambiguous_tilt';
392
+ }
393
+ lastAndroidOrientationDerivedRef.current = next;
394
+ lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
395
+ if (next && next !== lastOrientation) {
396
+ const previous = lastOrientation;
397
+ lastOrientation = next;
398
+ androidOrientationChangeCountRef.current++;
399
+ setPhysicalOrientation(next);
400
+ trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
401
+ previous_physical_orientation: previous,
402
+ next_physical_orientation: next,
403
+ accelerometer_sampled_at: atIso,
404
+ });
405
+ return;
406
+ }
407
+ const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
408
+ at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
409
+ if (shouldTrackSample) {
410
+ lastAndroidOrientationTelemetryAtRef.current = at.getTime();
411
+ trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
412
+ }
413
+ });
414
+ })();
415
+ return () => {
416
+ cancelled = true;
417
+ androidOrientationSubscriptionActiveRef.current = false;
418
+ if (noEventTimer)
419
+ clearTimeout(noEventTimer);
420
+ subscription?.remove();
421
+ };
422
+ }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
298
423
  // Detect orientation changes and remount camera after rotation settles.
299
424
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
300
425
  // animation — the native preview layer initializes with transitional bounds.
@@ -478,9 +603,29 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
478
603
  }
479
604
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
480
605
  const handleCapture = useCallback(async () => {
481
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
606
+ const blockedReason = !cameraRef.current
607
+ ? 'camera_ref_null'
608
+ : !cameraReadyRef.current
609
+ ? 'camera_not_ready'
610
+ : status === 'capturing'
611
+ ? 'already_capturing'
612
+ : status === 'processing'
613
+ ? 'already_processing'
614
+ : terminated
615
+ ? 'terminated'
616
+ : exhausted
617
+ ? 'exhausted'
618
+ : null;
619
+ telemetry?.track('camera_capture_request', {
620
+ component: 'scanner',
621
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
622
+ metadata: buildScannerTelemetryMetadata({
623
+ capture_blocked_reason: blockedReason,
624
+ }),
625
+ });
626
+ if (blockedReason && blockedReason !== 'camera_not_ready')
482
627
  return;
483
- if (!cameraReadyRef.current) {
628
+ if (blockedReason === 'camera_not_ready') {
484
629
  const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
485
630
  setResult(null);
486
631
  setLastError(error);
@@ -508,6 +653,17 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
508
653
  let captureRetryReady = false;
509
654
  let lastNativeCaptureErrorMessage = null;
510
655
  try {
656
+ const capturePhysicalOrientation = physicalOrientation;
657
+ const captureOverlayRotationDeg = overlayRotationDeg;
658
+ telemetry?.track('camera_capture_orientation_context', {
659
+ component: 'scanner',
660
+ error: 'capture_orientation_context',
661
+ metadata: buildScannerTelemetryMetadata({
662
+ capture_physical_orientation: capturePhysicalOrientation,
663
+ capture_overlay_rotation_deg: captureOverlayRotationDeg,
664
+ capture_rotation_applied: 0,
665
+ }),
666
+ });
511
667
  // --- Capture + best-effort resize ---
512
668
  // Strategy: try to dynamically import expo-image-manipulator.
513
669
  // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
@@ -561,6 +717,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
561
717
  setCameraKey((key) => key + 1);
562
718
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
563
719
  captureRetryReady = await waitForCameraReady(2500);
720
+ telemetry?.track('camera_capture_retry', {
721
+ component: 'scanner',
722
+ error: normalized,
723
+ errorCode: normalized.code,
724
+ metadata: buildScannerTelemetryMetadata({
725
+ native_capture_attempts: nativeCaptureAttempts,
726
+ capture_retry_attempted: captureRetryAttempted,
727
+ capture_retry_ready: captureRetryReady,
728
+ capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
729
+ capture_retry_ready_timeout_ms: 2500,
730
+ last_native_capture_error: lastNativeCaptureErrorMessage,
731
+ }),
732
+ });
564
733
  if (captureRetryReady) {
565
734
  continue;
566
735
  }
@@ -714,7 +883,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
714
883
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
715
884
  }
716
885
  }
717
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
886
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
718
887
  // Expose capture to parent via ref
719
888
  if (captureRef) {
720
889
  captureRef.current = handleCapture;
@@ -730,6 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
730
899
  setPhysicalOrientation(event.orientation);
731
900
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
732
901
  styles.titleText,
902
+ overlay.theme?.titleStyle,
733
903
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
734
904
  ], children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: [styles.guideContainer, isLandscape && styles.guideContainerLandscape], children: [_jsxs(View, { style: [
735
905
  styles.guideFrame,
@@ -739,8 +909,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
739
909
  : undefined,
740
910
  ], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] })] }), overlay.guideCaption && (_jsx(Text, { style: [
741
911
  styles.guideCaptionText,
912
+ overlay.theme?.feedbackStyle,
742
913
  { 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: [
914
+ ], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: [styles.statusText, overlay?.theme?.feedbackStyle], children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
744
915
  styles.resultIconCircle,
745
916
  exhausted
746
917
  ? styles.resultIconExhausted
@@ -758,7 +929,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
758
929
  ? (overlay?.exhaustedMessage || 'Submitted for review')
759
930
  : result.is_compliant
760
931
  ? (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' && (() => {
932
+ : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
762
933
  let errorTitle;
763
934
  let errorMessage;
764
935
  let showCloseAction = false;
@@ -785,9 +956,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
785
956
  errorMessage = display.message;
786
957
  showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
787
958
  }
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" }) }))] }));
959
+ return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
789
960
  })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
790
961
  styles.instructionsText,
962
+ overlay.theme?.feedbackStyle,
791
963
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
792
964
  ], children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
793
965
  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.16";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.15';
1
+ export const SDK_VERSION = '2.4.16';
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.16",
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,165 @@ 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
+ lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
464
+ lastAndroidOrientationErrorRef.current = error.message;
465
+ trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
466
+ return;
467
+ }
468
+ if (cancelled) return;
469
+ if (!Accelerometer) {
470
+ trackAndroidOrientationEvent(
471
+ 'camera_android_accelerometer_start_failure',
472
+ 'expo-sensors Accelerometer is unavailable',
473
+ );
474
+ return;
475
+ }
476
+
477
+ const startedAt = new Date().toISOString();
478
+ androidOrientationStartedAtRef.current = startedAt;
479
+ androidOrientationSubscriptionActiveRef.current = true;
480
+ androidOrientationEventCountRef.current = 0;
481
+ androidOrientationChangeCountRef.current = 0;
482
+ lastAndroidOrientationTelemetryAtRef.current = 0;
483
+ trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
484
+ accelerometer_update_interval_ms: 500,
485
+ accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
486
+ });
487
+
488
+ noEventTimer = setTimeout(() => {
489
+ if (cancelled || androidOrientationEventCountRef.current > 0) return;
490
+ trackAndroidOrientationEvent(
491
+ 'camera_android_accelerometer_no_events',
492
+ `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`,
493
+ );
494
+ }, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
495
+
496
+ Accelerometer.setUpdateInterval(500);
497
+ subscription = Accelerometer.addListener(({ x, y, z }) => {
498
+ const at = new Date();
499
+ const atIso = at.toISOString();
500
+ androidOrientationEventCountRef.current++;
501
+ lastAndroidOrientationEventAtRef.current = atIso;
502
+ lastAndroidOrientationXRef.current = Number(x.toFixed(4));
503
+ lastAndroidOrientationYRef.current = Number(y.toFixed(4));
504
+ lastAndroidOrientationZRef.current = Number(z.toFixed(4));
505
+ if (noEventTimer) {
506
+ clearTimeout(noEventTimer);
507
+ noEventTimer = null;
508
+ }
509
+
510
+ let next: typeof physicalOrientation | null = null;
511
+ let ignoredReason: string | null = null;
512
+ if (Math.abs(x) > Math.abs(y) + 0.2) {
513
+ next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
514
+ } else if (Math.abs(y) > Math.abs(x) + 0.2) {
515
+ next = y > 0 ? 'portraitUpsideDown' : 'portrait';
516
+ } else {
517
+ ignoredReason = 'ambiguous_tilt';
518
+ }
519
+
520
+ lastAndroidOrientationDerivedRef.current = next;
521
+ lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
522
+
523
+ if (next && next !== lastOrientation) {
524
+ const previous = lastOrientation;
525
+ lastOrientation = next;
526
+ androidOrientationChangeCountRef.current++;
527
+ setPhysicalOrientation(next);
528
+ trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
529
+ previous_physical_orientation: previous,
530
+ next_physical_orientation: next,
531
+ accelerometer_sampled_at: atIso,
532
+ });
533
+ return;
534
+ }
535
+
536
+ const shouldTrackSample =
537
+ androidOrientationEventCountRef.current === 1 ||
538
+ at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
539
+ if (shouldTrackSample) {
540
+ lastAndroidOrientationTelemetryAtRef.current = at.getTime();
541
+ trackAndroidOrientationEvent(
542
+ 'camera_android_accelerometer_sample',
543
+ `accelerometer_sample_${androidOrientationEventCountRef.current}`,
544
+ { accelerometer_sampled_at: atIso },
545
+ );
546
+ }
547
+ });
548
+ })();
549
+
550
+ return () => {
551
+ cancelled = true;
552
+ androidOrientationSubscriptionActiveRef.current = false;
553
+ if (noEventTimer) clearTimeout(noEventTimer);
554
+ subscription?.remove();
555
+ };
556
+ }, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
557
+
405
558
  // Detect orientation changes and remount camera after rotation settles.
406
559
  // On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
407
560
  // animation — the native preview layer initializes with transitional bounds.
@@ -605,8 +758,31 @@ export function VerifyAIScanner({
605
758
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
606
759
 
607
760
  const handleCapture = useCallback(async () => {
608
- if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
609
- if (!cameraReadyRef.current) {
761
+ const blockedReason =
762
+ !cameraRef.current
763
+ ? 'camera_ref_null'
764
+ : !cameraReadyRef.current
765
+ ? 'camera_not_ready'
766
+ : status === 'capturing'
767
+ ? 'already_capturing'
768
+ : status === 'processing'
769
+ ? 'already_processing'
770
+ : terminated
771
+ ? 'terminated'
772
+ : exhausted
773
+ ? 'exhausted'
774
+ : null;
775
+
776
+ telemetry?.track('camera_capture_request', {
777
+ component: 'scanner',
778
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
779
+ metadata: buildScannerTelemetryMetadata({
780
+ capture_blocked_reason: blockedReason,
781
+ }),
782
+ });
783
+
784
+ if (blockedReason && blockedReason !== 'camera_not_ready') return;
785
+ if (blockedReason === 'camera_not_ready') {
610
786
  const error = createScannerError(
611
787
  'Camera is not ready yet. Please wait a moment and try again.',
612
788
  CAMERA_NOT_READY_ERROR_CODE,
@@ -641,6 +817,18 @@ export function VerifyAIScanner({
641
817
  let lastNativeCaptureErrorMessage: string | null = null;
642
818
 
643
819
  try {
820
+ const capturePhysicalOrientation = physicalOrientation;
821
+ const captureOverlayRotationDeg = overlayRotationDeg;
822
+ telemetry?.track('camera_capture_orientation_context', {
823
+ component: 'scanner',
824
+ error: 'capture_orientation_context',
825
+ metadata: buildScannerTelemetryMetadata({
826
+ capture_physical_orientation: capturePhysicalOrientation,
827
+ capture_overlay_rotation_deg: captureOverlayRotationDeg,
828
+ capture_rotation_applied: 0,
829
+ }),
830
+ });
831
+
644
832
  // --- Capture + best-effort resize ---
645
833
  // Strategy: try to dynamically import expo-image-manipulator.
646
834
  // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
@@ -704,6 +892,19 @@ export function VerifyAIScanner({
704
892
  setCameraKey((key) => key + 1);
705
893
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
706
894
  captureRetryReady = await waitForCameraReady(2500);
895
+ telemetry?.track('camera_capture_retry', {
896
+ component: 'scanner',
897
+ error: normalized,
898
+ errorCode: normalized.code,
899
+ metadata: buildScannerTelemetryMetadata({
900
+ native_capture_attempts: nativeCaptureAttempts,
901
+ capture_retry_attempted: captureRetryAttempted,
902
+ capture_retry_ready: captureRetryReady,
903
+ capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
904
+ capture_retry_ready_timeout_ms: 2500,
905
+ last_native_capture_error: lastNativeCaptureErrorMessage,
906
+ }),
907
+ });
707
908
  if (captureRetryReady) {
708
909
  continue;
709
910
  }
@@ -891,7 +1092,7 @@ export function VerifyAIScanner({
891
1092
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
892
1093
  }
893
1094
  }
894
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
1095
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
895
1096
 
896
1097
  // Expose capture to parent via ref
897
1098
  if (captureRef) {
@@ -937,6 +1138,7 @@ export function VerifyAIScanner({
937
1138
  <Text
938
1139
  style={[
939
1140
  styles.titleText,
1141
+ overlay.theme?.titleStyle,
940
1142
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
941
1143
  ]}
942
1144
  >
@@ -972,6 +1174,7 @@ export function VerifyAIScanner({
972
1174
  <Text
973
1175
  style={[
974
1176
  styles.guideCaptionText,
1177
+ overlay.theme?.feedbackStyle,
975
1178
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
976
1179
  ]}
977
1180
  >
@@ -985,7 +1188,7 @@ export function VerifyAIScanner({
985
1188
  {status === 'processing' && (
986
1189
  <View style={styles.processingOverlay}>
987
1190
  <ActivityIndicator size="large" color="#fff" />
988
- <Text style={styles.statusText}>
1191
+ <Text style={[styles.statusText, overlay?.theme?.feedbackStyle]}>
989
1192
  {overlay?.processingMessage || 'Analyzing photo...'}
990
1193
  </Text>
991
1194
  </View>
@@ -1030,7 +1233,7 @@ export function VerifyAIScanner({
1030
1233
  : (overlay?.failureMessage || 'Not Verified')}
1031
1234
  </Text>
1032
1235
  </View>
1033
- <Text style={styles.feedbackText}>{result.feedback}</Text>
1236
+ <Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{result.feedback}</Text>
1034
1237
  {overlay?.showTerminalActionButton !== false && onResult && (
1035
1238
  <TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
1036
1239
  <Text style={styles.cardButtonText}>
@@ -1077,7 +1280,7 @@ export function VerifyAIScanner({
1077
1280
  {errorTitle}
1078
1281
  </Text>
1079
1282
  </View>
1080
- <Text style={styles.feedbackText}>{errorMessage}</Text>
1283
+ <Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{errorMessage}</Text>
1081
1284
  {showCloseAction && (
1082
1285
  <TouchableOpacity style={styles.cardButton} onPress={handleClose}>
1083
1286
  <Text style={styles.cardButtonText}>Close</Text>
@@ -1093,6 +1296,7 @@ export function VerifyAIScanner({
1093
1296
  <Text
1094
1297
  style={[
1095
1298
  styles.instructionsText,
1299
+ overlay.theme?.feedbackStyle,
1096
1300
  { transform: [{ rotate: `${overlayRotationDeg}deg` }] },
1097
1301
  ]}
1098
1302
  >
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.16';