@switchlabs/verify-ai-react-native 2.5.1 → 2.5.3

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.
@@ -52,6 +52,49 @@ function stringifyErrorMessage(value, fallback) {
52
52
  }
53
53
  return fallback;
54
54
  }
55
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
56
+ 'rideId',
57
+ 'vehicleType',
58
+ 'verificationAttemptId',
59
+ 'verificationAttempt',
60
+ 'verificationMaxAttempts',
61
+ 'verificationSource',
62
+ 'torchEnabled',
63
+ 'scannerSessionId',
64
+ ];
65
+ function toTelemetryKey(key) {
66
+ return key
67
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
68
+ .replace(/[^a-zA-Z0-9]+/g, '_')
69
+ .replace(/^_+|_+$/g, '')
70
+ .toLowerCase();
71
+ }
72
+ function compactClientTelemetryMetadata(metadata = {}) {
73
+ const compacted = {};
74
+ for (const [key, value] of Object.entries(metadata)) {
75
+ if (value === null || value === undefined)
76
+ continue;
77
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
78
+ }
79
+ return compacted;
80
+ }
81
+ function buildVerifyTelemetryMetadata(requestMetadata, options) {
82
+ const metadata = {
83
+ ...options?.telemetryMetadata,
84
+ };
85
+ if (options?.idempotencyKey) {
86
+ metadata.idempotency_key = options.idempotencyKey;
87
+ }
88
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
89
+ const value = requestMetadata?.[key];
90
+ if (typeof value === 'string' ||
91
+ typeof value === 'number' ||
92
+ typeof value === 'boolean') {
93
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
94
+ }
95
+ }
96
+ return metadata;
97
+ }
55
98
  export class VerifyAIClient {
56
99
  constructor(config) {
57
100
  if (!config.apiKey) {
@@ -101,7 +144,7 @@ export class VerifyAIClient {
101
144
  const message = error instanceof Error ? error.message : stringifyErrorMessage(error, 'VerifyAI request failed');
102
145
  return this.buildRequestError(message, 0, context);
103
146
  }
104
- async executeRequest(path, options = {}) {
147
+ async executeRequest(path, options = {}, telemetryMetadata = {}) {
105
148
  const controller = new AbortController();
106
149
  const timer = setTimeout(() => controller.abort(), this.timeout);
107
150
  const method = (options.method || 'GET').toUpperCase();
@@ -156,6 +199,7 @@ export class VerifyAIClient {
156
199
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
157
200
  : normalized;
158
201
  const metadata = {
202
+ ...compactClientTelemetryMetadata(telemetryMetadata),
159
203
  method: context.method,
160
204
  path: context.path,
161
205
  status: normalized.status,
@@ -178,8 +222,8 @@ export class VerifyAIClient {
178
222
  clearTimeout(timer);
179
223
  }
180
224
  }
181
- async request(path, options = {}) {
182
- return this.executeRequest(path, options);
225
+ async request(path, options = {}, telemetryMetadata = {}) {
226
+ return this.executeRequest(path, options, telemetryMetadata);
183
227
  }
184
228
  /**
185
229
  * Submit a photo for AI verification.
@@ -209,11 +253,12 @@ export class VerifyAIClient {
209
253
  headers['Idempotency-Key'] = options.idempotencyKey;
210
254
  }
211
255
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
256
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
212
257
  return this.request('/verify', {
213
258
  method: 'POST',
214
259
  headers,
215
260
  body: JSON.stringify(enrichedRequest),
216
- });
261
+ }, telemetryMetadata);
217
262
  }
218
263
  /**
219
264
  * Submit a photo for AI verification using multipart/form-data.
@@ -232,7 +277,8 @@ export class VerifyAIClient {
232
277
  name: 'photo.jpg',
233
278
  });
234
279
  formData.append('policy', request.policy);
235
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
280
+ const enrichedMetadata = enrichMetadata(request.metadata);
281
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
236
282
  if (request.provider) {
237
283
  formData.append('provider', request.provider);
238
284
  }
@@ -246,12 +292,13 @@ export class VerifyAIClient {
246
292
  if (options?.idempotencyKey) {
247
293
  headers['Idempotency-Key'] = options.idempotencyKey;
248
294
  }
295
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
249
296
  try {
250
297
  return await this.executeRequest('/verify', {
251
298
  method: 'POST',
252
299
  headers,
253
300
  body: formData,
254
- });
301
+ }, telemetryMetadata);
255
302
  }
256
303
  catch (error) {
257
304
  // 5xx during multipart can mean the server crashed before reservation
@@ -271,7 +318,7 @@ export class VerifyAIClient {
271
318
  metadata: request.metadata,
272
319
  provider: request.provider,
273
320
  include_image_data: request.include_image_data,
274
- }, options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined);
321
+ }, options);
275
322
  }
276
323
  catch {
277
324
  // If the retry itself fails, throw the original error
@@ -20,12 +20,21 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
20
20
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
21
21
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
22
22
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
23
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
23
24
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
25
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
26
+ // iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
27
+ // prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
28
+ // down sessions that were seconds from ready and restarted from scratch, which made slow
29
+ // cold starts *worse* and fired camera_preview_timeout in bursts. Match Android at 10s.
30
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 10000;
26
31
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
27
32
  const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
33
+ // Base backoff between startup remounts. Each successive remount escalates this (see
34
+ // startupRemountBackoffMs below) so later attempts get progressively more uninterrupted
35
+ // time to initialize the native session instead of thrashing at a fixed interval.
28
36
  const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
37
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MAX_MS = 1200;
29
38
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
30
39
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
31
40
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -76,6 +85,9 @@ function createScannerError(message, code, name) {
76
85
  function sleep(ms) {
77
86
  return new Promise((resolve) => setTimeout(resolve, ms));
78
87
  }
88
+ function createScannerSessionId() {
89
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
90
+ }
79
91
  function isNativeCameraCaptureError(error) {
80
92
  const message = error.message.toLowerCase();
81
93
  return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
@@ -261,7 +273,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
261
273
  const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
262
274
  physicalOrientationRef.current = physicalOrientation;
263
275
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
276
+ const scannerSessionIdRef = useRef(createScannerSessionId());
277
+ const captureSequenceRef = useRef(0);
278
+ const torchRetrySuppressedRef = useRef(false);
279
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
264
280
  const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
281
+ scanner_session_id: scannerSessionIdRef.current,
265
282
  policy,
266
283
  status,
267
284
  camera_ready: cameraReadyRef.current,
@@ -290,6 +307,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
290
307
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
291
308
  last_capture_retry_at: lastCaptureRetryAtRef.current,
292
309
  requested_torch_enabled: enableTorch ? 1 : 0,
310
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
293
311
  android_native_orientation_subscription_active: 0,
294
312
  android_native_orientation_event_count: 0,
295
313
  android_native_orientation_change_count: 0,
@@ -342,6 +360,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
342
360
  ]);
343
361
  telemetryRef.current = telemetry;
344
362
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
363
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed) => {
364
+ torchRetrySuppressedRef.current = nextSuppressed;
365
+ setTorchRetrySuppressed(nextSuppressed);
366
+ }, []);
345
367
  useEffect(() => {
346
368
  const reporter = telemetryRef.current;
347
369
  if (reporter) {
@@ -382,7 +404,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
382
404
  cameraStartupAttemptStartedAtRef.current = Date.now();
383
405
  if (opts.log) {
384
406
  console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
385
- `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
407
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
408
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`);
386
409
  }
387
410
  const remount = () => {
388
411
  setCameraKey((k) => k + 1);
@@ -829,6 +852,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
829
852
  const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
830
853
  startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
831
854
  cameraRemountCountRef.current++;
855
+ // Escalate the backoff with each attempt (350, 700, 1050, capped at 1200ms) so a
856
+ // camera that just needs more time isn't repeatedly interrupted at a fixed cadence.
857
+ const startupRemountBackoffMs = Math.min(CAMERA_STARTUP_REMOUNT_BACKOFF_MS * startupWatchdogRemountCount, CAMERA_STARTUP_REMOUNT_BACKOFF_MAX_MS);
832
858
  telemetry?.track('camera_preview_timeout', {
833
859
  component: 'scanner',
834
860
  error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
@@ -837,12 +863,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
837
863
  remount_reason: 'startup_watchdog',
838
864
  startup_watchdog_remount_count: startupWatchdogRemountCount,
839
865
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
840
- startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
866
+ startup_remount_backoff_ms: startupRemountBackoffMs,
841
867
  torch_deferred_until_ready: enableTorch ? 1 : 0,
842
868
  }),
843
869
  });
844
870
  requestCameraRemount('startup_watchdog', {
845
- backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
871
+ backoffMs: startupRemountBackoffMs,
846
872
  log: true,
847
873
  });
848
874
  }
@@ -875,6 +901,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
875
901
  }
876
902
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
877
903
  const handleCapture = useCallback(async () => {
904
+ const captureSequence = captureSequenceRef.current + 1;
905
+ captureSequenceRef.current = captureSequence;
906
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
878
907
  const blockedReason = !cameraRef.current
879
908
  ? 'camera_ref_null'
880
909
  : !cameraReadyRef.current
@@ -892,6 +921,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
892
921
  component: 'scanner',
893
922
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
894
923
  metadata: buildScannerTelemetryMetadata({
924
+ capture_attempt_id: captureAttemptId,
925
+ capture_sequence: captureSequence,
895
926
  capture_blocked_reason: blockedReason,
896
927
  }),
897
928
  });
@@ -912,6 +943,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
912
943
  return;
913
944
  }
914
945
  setStatus('capturing');
946
+ setTorchSuppressedForRetry(false);
915
947
  setResult(null);
916
948
  setLastError(null);
917
949
  terminalResultRef.current = null;
@@ -923,6 +955,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
923
955
  let nativeCaptureAttempts = 0;
924
956
  let captureRetryAttempted = false;
925
957
  let captureRetryReady = false;
958
+ let captureRetryTorchSuppressed = false;
959
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
960
+ let captureRetryRemountBackoffMs = 0;
926
961
  let lastNativeCaptureErrorMessage = null;
927
962
  try {
928
963
  const capturePhysicalOrientation = physicalOrientation;
@@ -931,6 +966,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
931
966
  component: 'scanner',
932
967
  error: 'capture_orientation_context',
933
968
  metadata: buildScannerTelemetryMetadata({
969
+ capture_attempt_id: captureAttemptId,
970
+ capture_sequence: captureSequence,
934
971
  capture_physical_orientation: capturePhysicalOrientation,
935
972
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
936
973
  capture_rotation_applied: 0,
@@ -988,11 +1025,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
988
1025
  lastNativeCaptureErrorMessage = normalized.message;
989
1026
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
990
1027
  captureRetryAttempted = true;
1028
+ captureRetryTorchSuppressed = !!enableTorch;
1029
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1030
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1031
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1032
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1033
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1034
+ : 0;
991
1035
  const retryAt = new Date().toISOString();
992
1036
  lastCaptureRetryAtRef.current = retryAt;
993
1037
  cameraRemountCountRef.current++;
994
- requestCameraRemount('capture_retry');
995
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1038
+ if (captureRetryTorchSuppressed) {
1039
+ setTorchSuppressedForRetry(true);
1040
+ }
1041
+ requestCameraRemount('capture_retry', captureRetryRemountBackoffMs > 0
1042
+ ? { backoffMs: captureRetryRemountBackoffMs }
1043
+ : undefined);
1044
+ await sleep(captureRetrySettleDelayMs);
996
1045
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
997
1046
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
998
1047
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -1002,11 +1051,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1002
1051
  error: normalized,
1003
1052
  errorCode: normalized.code,
1004
1053
  metadata: buildScannerTelemetryMetadata({
1054
+ capture_attempt_id: captureAttemptId,
1055
+ capture_sequence: captureSequence,
1005
1056
  native_capture_attempts: nativeCaptureAttempts,
1006
1057
  capture_retry_attempted: captureRetryAttempted,
1007
1058
  capture_retry_ready: captureRetryReady,
1008
1059
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1060
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1061
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1009
1062
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1063
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1010
1064
  last_native_capture_error: lastNativeCaptureErrorMessage,
1011
1065
  }),
1012
1066
  });
@@ -1074,6 +1128,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1074
1128
  telemetry?.track('image_processed', {
1075
1129
  component: 'scanner',
1076
1130
  metadata: buildScannerTelemetryMetadata({
1131
+ capture_attempt_id: captureAttemptId,
1132
+ capture_sequence: captureSequence,
1077
1133
  original_width: origWidth,
1078
1134
  original_height: origHeight,
1079
1135
  processed_width: processedWidth,
@@ -1083,6 +1139,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1083
1139
  native_capture_attempts: nativeCaptureAttempts,
1084
1140
  capture_retry_attempted: captureRetryAttempted,
1085
1141
  capture_retry_ready: captureRetryReady,
1142
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1143
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1144
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1086
1145
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1087
1146
  last_native_capture_error: lastNativeCaptureErrorMessage,
1088
1147
  }),
@@ -1151,9 +1210,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1151
1210
  component: 'scanner',
1152
1211
  error,
1153
1212
  metadata: buildScannerTelemetryMetadata({
1213
+ capture_attempt_id: captureAttemptId,
1214
+ capture_sequence: captureSequence,
1154
1215
  native_capture_attempts: nativeCaptureAttempts,
1155
1216
  capture_retry_attempted: captureRetryAttempted,
1156
1217
  capture_retry_ready: captureRetryReady,
1218
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1219
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1220
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1157
1221
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1158
1222
  last_native_capture_error: lastNativeCaptureErrorMessage,
1159
1223
  }),
@@ -1163,7 +1227,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1163
1227
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1164
1228
  }
1165
1229
  }
1166
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1230
+ finally {
1231
+ setTorchSuppressedForRetry(false);
1232
+ }
1233
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1167
1234
  // Expose capture to parent via ref
1168
1235
  if (captureRef) {
1169
1236
  captureRef.current = handleCapture;
@@ -1175,7 +1242,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1175
1242
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
1176
1243
  }
1177
1244
  const showBottomCard = status === 'success' || status === 'error';
1178
- const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1245
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1179
1246
  return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1180
1247
  setPhysicalOrientation(event.orientation);
1181
1248
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
@@ -57,6 +57,8 @@ export interface VerificationListParams {
57
57
  export interface VerifyOptions {
58
58
  /** Idempotency key to prevent duplicate verifications on retry. */
59
59
  idempotencyKey?: string;
60
+ /** Optional primitive metadata attached to client-side request telemetry. */
61
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
60
62
  }
61
63
  export interface QueueItem {
62
64
  id: string;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.5.1";
1
+ export declare const SDK_VERSION = "2.5.2";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.1';
1
+ export const SDK_VERSION = '2.5.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,6 +78,65 @@ interface RequestContext {
78
78
  method: string;
79
79
  }
80
80
 
81
+ type ClientTelemetryValue = string | number | boolean | null | undefined;
82
+ type ClientTelemetryMetadata = Record<string, ClientTelemetryValue>;
83
+
84
+ const VERIFY_METADATA_TELEMETRY_KEYS = [
85
+ 'rideId',
86
+ 'vehicleType',
87
+ 'verificationAttemptId',
88
+ 'verificationAttempt',
89
+ 'verificationMaxAttempts',
90
+ 'verificationSource',
91
+ 'torchEnabled',
92
+ 'scannerSessionId',
93
+ ];
94
+
95
+ function toTelemetryKey(key: string): string {
96
+ return key
97
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
98
+ .replace(/[^a-zA-Z0-9]+/g, '_')
99
+ .replace(/^_+|_+$/g, '')
100
+ .toLowerCase();
101
+ }
102
+
103
+ function compactClientTelemetryMetadata(
104
+ metadata: ClientTelemetryMetadata = {},
105
+ ): Record<string, string | number> {
106
+ const compacted: Record<string, string | number> = {};
107
+ for (const [key, value] of Object.entries(metadata)) {
108
+ if (value === null || value === undefined) continue;
109
+ compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
110
+ }
111
+ return compacted;
112
+ }
113
+
114
+ function buildVerifyTelemetryMetadata(
115
+ requestMetadata: Record<string, unknown> | undefined,
116
+ options?: VerifyOptions,
117
+ ): ClientTelemetryMetadata {
118
+ const metadata: ClientTelemetryMetadata = {
119
+ ...options?.telemetryMetadata,
120
+ };
121
+
122
+ if (options?.idempotencyKey) {
123
+ metadata.idempotency_key = options.idempotencyKey;
124
+ }
125
+
126
+ for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
127
+ const value = requestMetadata?.[key];
128
+ if (
129
+ typeof value === 'string' ||
130
+ typeof value === 'number' ||
131
+ typeof value === 'boolean'
132
+ ) {
133
+ metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
134
+ }
135
+ }
136
+
137
+ return metadata;
138
+ }
139
+
81
140
  export class VerifyAIClient {
82
141
  private apiKey: string;
83
142
  private baseUrl: string;
@@ -147,7 +206,8 @@ export class VerifyAIClient {
147
206
 
148
207
  private async executeRequest<T>(
149
208
  path: string,
150
- options: RequestInit = {}
209
+ options: RequestInit = {},
210
+ telemetryMetadata: ClientTelemetryMetadata = {},
151
211
  ): Promise<T> {
152
212
  const controller = new AbortController();
153
213
  const timer = setTimeout(() => controller.abort(), this.timeout);
@@ -218,6 +278,7 @@ export class VerifyAIClient {
218
278
  ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
219
279
  : normalized;
220
280
  const metadata: Record<string, string | number> = {
281
+ ...compactClientTelemetryMetadata(telemetryMetadata),
221
282
  method: context.method,
222
283
  path: context.path,
223
284
  status: normalized.status,
@@ -243,9 +304,10 @@ export class VerifyAIClient {
243
304
 
244
305
  private async request<T>(
245
306
  path: string,
246
- options: RequestInit = {}
307
+ options: RequestInit = {},
308
+ telemetryMetadata: ClientTelemetryMetadata = {},
247
309
  ): Promise<T> {
248
- return this.executeRequest<T>(path, options);
310
+ return this.executeRequest<T>(path, options, telemetryMetadata);
249
311
  }
250
312
 
251
313
  /**
@@ -276,11 +338,12 @@ export class VerifyAIClient {
276
338
  headers['Idempotency-Key'] = options.idempotencyKey;
277
339
  }
278
340
  const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
341
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
279
342
  return this.request<VerificationResult>('/verify', {
280
343
  method: 'POST',
281
344
  headers,
282
345
  body: JSON.stringify(enrichedRequest),
283
- });
346
+ }, telemetryMetadata);
284
347
  }
285
348
 
286
349
  /**
@@ -300,7 +363,8 @@ export class VerifyAIClient {
300
363
  name: 'photo.jpg',
301
364
  } as unknown as Blob);
302
365
  formData.append('policy', request.policy);
303
- formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
366
+ const enrichedMetadata = enrichMetadata(request.metadata);
367
+ formData.append('metadata', JSON.stringify(enrichedMetadata));
304
368
  if (request.provider) {
305
369
  formData.append('provider', request.provider);
306
370
  }
@@ -316,12 +380,14 @@ export class VerifyAIClient {
316
380
  headers['Idempotency-Key'] = options.idempotencyKey;
317
381
  }
318
382
 
383
+ const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
384
+
319
385
  try {
320
386
  return await this.executeRequest<VerificationResult>('/verify', {
321
387
  method: 'POST',
322
388
  headers,
323
389
  body: formData,
324
- });
390
+ }, telemetryMetadata);
325
391
  } catch (error) {
326
392
  // 5xx during multipart can mean the server crashed before reservation
327
393
  // (e.g. Vercel multipart-parse failure) OR after the verification was
@@ -342,7 +408,7 @@ export class VerifyAIClient {
342
408
  provider: request.provider,
343
409
  include_image_data: request.include_image_data,
344
410
  },
345
- options?.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : undefined,
411
+ options,
346
412
  );
347
413
  } catch {
348
414
  // If the retry itself fails, throw the original error
@@ -45,12 +45,21 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
45
45
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
46
46
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
47
47
  const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
48
+ const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
48
49
  const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
50
  const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
- const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
51
+ // iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
52
+ // prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
53
+ // down sessions that were seconds from ready and restarted from scratch, which made slow
54
+ // cold starts *worse* and fired camera_preview_timeout in bursts. Match Android at 10s.
55
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 10000;
51
56
  const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
52
57
  const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
58
+ // Base backoff between startup remounts. Each successive remount escalates this (see
59
+ // startupRemountBackoffMs below) so later attempts get progressively more uninterrupted
60
+ // time to initialize the native session instead of thrashing at a fixed interval.
53
61
  const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
62
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MAX_MS = 1200;
54
63
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
55
64
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
56
65
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -153,6 +162,10 @@ function sleep(ms: number): Promise<void> {
153
162
  return new Promise((resolve) => setTimeout(resolve, ms));
154
163
  }
155
164
 
165
+ function createScannerSessionId(): string {
166
+ return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
167
+ }
168
+
156
169
  function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
157
170
  const message = error.message.toLowerCase();
158
171
  return (
@@ -369,10 +382,15 @@ export function VerifyAIScanner({
369
382
 
370
383
  physicalOrientationRef.current = physicalOrientation;
371
384
  const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
385
+ const scannerSessionIdRef = useRef(createScannerSessionId());
386
+ const captureSequenceRef = useRef(0);
387
+ const torchRetrySuppressedRef = useRef(false);
388
+ const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
372
389
 
373
390
  const buildScannerTelemetryMetadata = useCallback((
374
391
  extra: Record<string, string | number | boolean | null | undefined> = {},
375
392
  ): ScannerTelemetryMetadata => compactTelemetryMetadata({
393
+ scanner_session_id: scannerSessionIdRef.current,
376
394
  policy,
377
395
  status,
378
396
  camera_ready: cameraReadyRef.current,
@@ -401,6 +419,7 @@ export function VerifyAIScanner({
401
419
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
402
420
  last_capture_retry_at: lastCaptureRetryAtRef.current,
403
421
  requested_torch_enabled: enableTorch ? 1 : 0,
422
+ torch_retry_suppressed: torchRetrySuppressedRef.current,
404
423
  android_native_orientation_subscription_active: 0,
405
424
  android_native_orientation_event_count: 0,
406
425
  android_native_orientation_change_count: 0,
@@ -455,6 +474,11 @@ export function VerifyAIScanner({
455
474
  telemetryRef.current = telemetry;
456
475
  buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
457
476
 
477
+ const setTorchSuppressedForRetry = useCallback((nextSuppressed: boolean) => {
478
+ torchRetrySuppressedRef.current = nextSuppressed;
479
+ setTorchRetrySuppressed(nextSuppressed);
480
+ }, []);
481
+
458
482
  useEffect(() => {
459
483
  const reporter = telemetryRef.current;
460
484
  if (reporter) {
@@ -509,7 +533,8 @@ export function VerifyAIScanner({
509
533
  if (opts.log) {
510
534
  console.warn(
511
535
  `VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
512
- `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
536
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
537
+ `torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`,
513
538
  );
514
539
  }
515
540
 
@@ -1025,6 +1050,12 @@ export function VerifyAIScanner({
1025
1050
  const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
1026
1051
  startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
1027
1052
  cameraRemountCountRef.current++;
1053
+ // Escalate the backoff with each attempt (350, 700, 1050, capped at 1200ms) so a
1054
+ // camera that just needs more time isn't repeatedly interrupted at a fixed cadence.
1055
+ const startupRemountBackoffMs = Math.min(
1056
+ CAMERA_STARTUP_REMOUNT_BACKOFF_MS * startupWatchdogRemountCount,
1057
+ CAMERA_STARTUP_REMOUNT_BACKOFF_MAX_MS,
1058
+ );
1028
1059
  telemetry?.track('camera_preview_timeout', {
1029
1060
  component: 'scanner',
1030
1061
  error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
@@ -1033,12 +1064,12 @@ export function VerifyAIScanner({
1033
1064
  remount_reason: 'startup_watchdog',
1034
1065
  startup_watchdog_remount_count: startupWatchdogRemountCount,
1035
1066
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
1036
- startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1067
+ startup_remount_backoff_ms: startupRemountBackoffMs,
1037
1068
  torch_deferred_until_ready: enableTorch ? 1 : 0,
1038
1069
  }),
1039
1070
  });
1040
1071
  requestCameraRemount('startup_watchdog', {
1041
- backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1072
+ backoffMs: startupRemountBackoffMs,
1042
1073
  log: true,
1043
1074
  });
1044
1075
  }
@@ -1076,6 +1107,9 @@ export function VerifyAIScanner({
1076
1107
  }, [buildScannerTelemetryMetadata, permission, telemetry]);
1077
1108
 
1078
1109
  const handleCapture = useCallback(async () => {
1110
+ const captureSequence = captureSequenceRef.current + 1;
1111
+ captureSequenceRef.current = captureSequence;
1112
+ const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
1079
1113
  const blockedReason =
1080
1114
  !cameraRef.current
1081
1115
  ? 'camera_ref_null'
@@ -1095,6 +1129,8 @@ export function VerifyAIScanner({
1095
1129
  component: 'scanner',
1096
1130
  error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1097
1131
  metadata: buildScannerTelemetryMetadata({
1132
+ capture_attempt_id: captureAttemptId,
1133
+ capture_sequence: captureSequence,
1098
1134
  capture_blocked_reason: blockedReason,
1099
1135
  }),
1100
1136
  });
@@ -1120,6 +1156,7 @@ export function VerifyAIScanner({
1120
1156
  }
1121
1157
 
1122
1158
  setStatus('capturing');
1159
+ setTorchSuppressedForRetry(false);
1123
1160
  setResult(null);
1124
1161
  setLastError(null);
1125
1162
  terminalResultRef.current = null;
@@ -1132,6 +1169,9 @@ export function VerifyAIScanner({
1132
1169
  let nativeCaptureAttempts = 0;
1133
1170
  let captureRetryAttempted = false;
1134
1171
  let captureRetryReady = false;
1172
+ let captureRetryTorchSuppressed = false;
1173
+ let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
1174
+ let captureRetryRemountBackoffMs = 0;
1135
1175
  let lastNativeCaptureErrorMessage: string | null = null;
1136
1176
 
1137
1177
  try {
@@ -1141,6 +1181,8 @@ export function VerifyAIScanner({
1141
1181
  component: 'scanner',
1142
1182
  error: 'capture_orientation_context',
1143
1183
  metadata: buildScannerTelemetryMetadata({
1184
+ capture_attempt_id: captureAttemptId,
1185
+ capture_sequence: captureSequence,
1144
1186
  capture_physical_orientation: capturePhysicalOrientation,
1145
1187
  capture_overlay_rotation_deg: captureOverlayRotationDeg,
1146
1188
  capture_rotation_applied: 0,
@@ -1209,11 +1251,26 @@ export function VerifyAIScanner({
1209
1251
 
1210
1252
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
1211
1253
  captureRetryAttempted = true;
1254
+ captureRetryTorchSuppressed = !!enableTorch;
1255
+ captureRetrySettleDelayMs = captureRetryTorchSuppressed
1256
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
1257
+ : CAMERA_CAPTURE_RETRY_DELAY_MS;
1258
+ captureRetryRemountBackoffMs = captureRetryTorchSuppressed
1259
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
1260
+ : 0;
1212
1261
  const retryAt = new Date().toISOString();
1213
1262
  lastCaptureRetryAtRef.current = retryAt;
1214
1263
  cameraRemountCountRef.current++;
1215
- requestCameraRemount('capture_retry');
1216
- await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1264
+ if (captureRetryTorchSuppressed) {
1265
+ setTorchSuppressedForRetry(true);
1266
+ }
1267
+ requestCameraRemount(
1268
+ 'capture_retry',
1269
+ captureRetryRemountBackoffMs > 0
1270
+ ? { backoffMs: captureRetryRemountBackoffMs }
1271
+ : undefined,
1272
+ );
1273
+ await sleep(captureRetrySettleDelayMs);
1217
1274
  const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1218
1275
  ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1219
1276
  : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
@@ -1223,11 +1280,16 @@ export function VerifyAIScanner({
1223
1280
  error: normalized,
1224
1281
  errorCode: normalized.code,
1225
1282
  metadata: buildScannerTelemetryMetadata({
1283
+ capture_attempt_id: captureAttemptId,
1284
+ capture_sequence: captureSequence,
1226
1285
  native_capture_attempts: nativeCaptureAttempts,
1227
1286
  capture_retry_attempted: captureRetryAttempted,
1228
1287
  capture_retry_ready: captureRetryReady,
1229
1288
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1289
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1290
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1230
1291
  capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1292
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1231
1293
  last_native_capture_error: lastNativeCaptureErrorMessage,
1232
1294
  }),
1233
1295
  });
@@ -1321,6 +1383,8 @@ export function VerifyAIScanner({
1321
1383
  telemetry?.track('image_processed', {
1322
1384
  component: 'scanner',
1323
1385
  metadata: buildScannerTelemetryMetadata({
1386
+ capture_attempt_id: captureAttemptId,
1387
+ capture_sequence: captureSequence,
1324
1388
  original_width: origWidth,
1325
1389
  original_height: origHeight,
1326
1390
  processed_width: processedWidth,
@@ -1330,6 +1394,9 @@ export function VerifyAIScanner({
1330
1394
  native_capture_attempts: nativeCaptureAttempts,
1331
1395
  capture_retry_attempted: captureRetryAttempted,
1332
1396
  capture_retry_ready: captureRetryReady,
1397
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1398
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1399
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1333
1400
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1334
1401
  last_native_capture_error: lastNativeCaptureErrorMessage,
1335
1402
  }),
@@ -1404,9 +1471,14 @@ export function VerifyAIScanner({
1404
1471
  component: 'scanner',
1405
1472
  error,
1406
1473
  metadata: buildScannerTelemetryMetadata({
1474
+ capture_attempt_id: captureAttemptId,
1475
+ capture_sequence: captureSequence,
1407
1476
  native_capture_attempts: nativeCaptureAttempts,
1408
1477
  capture_retry_attempted: captureRetryAttempted,
1409
1478
  capture_retry_ready: captureRetryReady,
1479
+ capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1480
+ capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1481
+ capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1410
1482
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1411
1483
  last_native_capture_error: lastNativeCaptureErrorMessage,
1412
1484
  }),
@@ -1417,8 +1489,10 @@ export function VerifyAIScanner({
1417
1489
  if (!terminalRequestError) {
1418
1490
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1419
1491
  }
1492
+ } finally {
1493
+ setTorchSuppressedForRetry(false);
1420
1494
  }
1421
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1495
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
1422
1496
 
1423
1497
  // Expose capture to parent via ref
1424
1498
  if (captureRef) {
@@ -1441,7 +1515,7 @@ export function VerifyAIScanner({
1441
1515
  }
1442
1516
 
1443
1517
  const showBottomCard = status === 'success' || status === 'error';
1444
- const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1518
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
1445
1519
 
1446
1520
  return (
1447
1521
  <View style={[styles.container, style]}>
@@ -64,6 +64,8 @@ export interface VerificationListParams {
64
64
  export interface VerifyOptions {
65
65
  /** Idempotency key to prevent duplicate verifications on retry. */
66
66
  idempotencyKey?: string;
67
+ /** Optional primitive metadata attached to client-side request telemetry. */
68
+ telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
67
69
  }
68
70
 
69
71
  export interface QueueItem {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.1';
1
+ export const SDK_VERSION = '2.5.2';