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

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.
@@ -8,6 +8,7 @@ import { SDK_VERSION } from '../version';
8
8
  import { BikeOverlay } from './BikeOverlay';
9
9
  import { ScooterOverlay } from './ScooterOverlay';
10
10
  import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
11
+ import { CAMERA_CAPTURE_RETRY_DELAY_MS, getCameraCaptureRetryPlan, } from './cameraCaptureRetry';
11
12
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
12
13
  const FALLBACK_QUALITY = 0.65;
13
14
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
@@ -19,10 +20,6 @@ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
19
20
  const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
20
21
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
21
22
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
22
- const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
23
- const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
24
- const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
25
- const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
26
23
  // iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
27
24
  // prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
28
25
  // down sessions that were seconds from ready and restarted from scratch, which made slow
@@ -297,6 +294,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
297
294
  device_os_version: String(Platform.Version),
298
295
  route_name: telemetryContext?.routeName,
299
296
  is_portrait_locked: telemetryContext?.isPortraitLocked,
297
+ host_reference_id: telemetryContext?.hostReferenceId,
300
298
  window_width: windowWidth,
301
299
  window_height: windowHeight,
302
300
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -917,15 +915,20 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
917
915
  : exhausted
918
916
  ? 'exhausted'
919
917
  : null;
920
- telemetry?.track('camera_capture_request', {
921
- component: 'scanner',
922
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
923
- metadata: buildScannerTelemetryMetadata({
924
- capture_attempt_id: captureAttemptId,
925
- capture_sequence: captureSequence,
926
- capture_blocked_reason: blockedReason,
927
- }),
928
- });
918
+ if (telemetry) {
919
+ // Do not begin native capture until the user's shutter intent is on disk.
920
+ // If the process is force-closed during capture or delivery, the next SDK
921
+ // initialization recovers and sends this exact capture_attempt_id.
922
+ await telemetry.trackDurably('camera_capture_request', {
923
+ component: 'scanner',
924
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
925
+ metadata: buildScannerTelemetryMetadata({
926
+ capture_attempt_id: captureAttemptId,
927
+ capture_sequence: captureSequence,
928
+ capture_blocked_reason: blockedReason,
929
+ }),
930
+ });
931
+ }
929
932
  if (blockedReason && blockedReason !== 'camera_not_ready')
930
933
  return;
931
934
  if (blockedReason === 'camera_not_ready') {
@@ -958,6 +961,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
958
961
  let captureRetryTorchSuppressed = false;
959
962
  let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
960
963
  let captureRetryRemountBackoffMs = 0;
964
+ let captureRetryReadyTimeoutMs = 0;
961
965
  let lastNativeCaptureErrorMessage = null;
962
966
  try {
963
967
  const capturePhysicalOrientation = physicalOrientation;
@@ -1025,13 +1029,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1025
1029
  lastNativeCaptureErrorMessage = normalized.message;
1026
1030
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
1027
1031
  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;
1032
+ const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
1033
+ captureRetryTorchSuppressed = retryPlan.suppressTorch;
1034
+ captureRetrySettleDelayMs = retryPlan.settleDelayMs;
1035
+ captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
1036
+ captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
1035
1037
  const retryAt = new Date().toISOString();
1036
1038
  lastCaptureRetryAtRef.current = retryAt;
1037
1039
  cameraRemountCountRef.current++;
@@ -1042,9 +1044,6 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1042
1044
  ? { backoffMs: captureRetryRemountBackoffMs }
1043
1045
  : undefined);
1044
1046
  await sleep(captureRetrySettleDelayMs);
1045
- const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1046
- ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1047
- : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
1048
1047
  captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
1049
1048
  telemetry?.track('camera_capture_retry', {
1050
1049
  component: 'scanner',
@@ -1142,6 +1141,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1142
1141
  capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1143
1142
  capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1144
1143
  capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1144
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1145
1145
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1146
1146
  last_native_capture_error: lastNativeCaptureErrorMessage,
1147
1147
  }),
@@ -1218,6 +1218,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1218
1218
  capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1219
1219
  capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1220
1220
  capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1221
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1221
1222
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1222
1223
  last_native_capture_error: lastNativeCaptureErrorMessage,
1223
1224
  }),
@@ -0,0 +1,12 @@
1
+ export declare const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
2
+ export declare const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
3
+ export declare const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
4
+ export declare const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
5
+ export type CameraCaptureRetryPlatform = 'ios' | 'android' | string;
6
+ export interface CameraCaptureRetryPlan {
7
+ readyTimeoutMs: number;
8
+ remountBackoffMs: number;
9
+ settleDelayMs: number;
10
+ suppressTorch: boolean;
11
+ }
12
+ export declare function getCameraCaptureRetryPlan(platform: CameraCaptureRetryPlatform, torchRequested: boolean): CameraCaptureRetryPlan;
@@ -0,0 +1,23 @@
1
+ export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
2
+ export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
3
+ export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
4
+ export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
5
+ export function getCameraCaptureRetryPlan(platform, torchRequested) {
6
+ const suppressTorch = torchRequested;
7
+ return {
8
+ readyTimeoutMs: platform === 'android'
9
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
10
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS,
11
+ // Flutter fully restarts its CameraController after native capture errors.
12
+ // On iOS/Expo Camera, a key-only remount can report ready too quickly while
13
+ // the native capture session is still unable to produce a photo, so force a
14
+ // brief unmount before the retry.
15
+ remountBackoffMs: platform === 'ios' || suppressTorch
16
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
17
+ : 0,
18
+ settleDelayMs: suppressTorch
19
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
20
+ : CAMERA_CAPTURE_RETRY_DELAY_MS,
21
+ suppressTorch,
22
+ };
23
+ }
@@ -1,5 +1,12 @@
1
+ interface TelemetryTrackOptions {
2
+ component?: string;
3
+ error?: unknown;
4
+ errorCode?: string;
5
+ metadata?: Record<string, string | number>;
6
+ }
1
7
  export declare class TelemetryReporter {
2
8
  private buffer;
9
+ private inFlightBuffer;
3
10
  private flushTimer;
4
11
  private sessionId;
5
12
  private baseUrl;
@@ -8,6 +15,7 @@ export declare class TelemetryReporter {
8
15
  private flushing;
9
16
  private loadedPersisted;
10
17
  private loadPersistedPromise;
18
+ private persistenceChain;
11
19
  constructor(apiKey: string, baseUrl: string);
12
20
  /** Telemetry POST destination — exposed so the scanner can log an init banner
13
21
  * that helps field debugging confirm where events are being sent. */
@@ -16,12 +24,14 @@ export declare class TelemetryReporter {
16
24
  getApiKeyPrefix(): string;
17
25
  private logDeliveryFailure;
18
26
  /** Track an error event. Fire-and-forget — never throws. */
19
- track(eventType: string, opts?: {
20
- component?: string;
21
- error?: unknown;
22
- errorCode?: string;
23
- metadata?: Record<string, string | number>;
24
- }): void;
27
+ track(eventType: string, opts?: TelemetryTrackOptions): void;
28
+ /**
29
+ * Record an event and wait until it is durably stored before returning.
30
+ *
31
+ * Use this for user intent that must survive an immediate force-close. Network
32
+ * delivery starts after the local write, but callers do not wait on the network.
33
+ */
34
+ trackDurably(eventType: string, opts?: TelemetryTrackOptions): Promise<void>;
25
35
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
26
36
  flush(): Promise<void>;
27
37
  /** Dispose — flush remaining events and stop timers. */
@@ -29,6 +39,8 @@ export declare class TelemetryReporter {
29
39
  private scheduleFlush;
30
40
  private flushNow;
31
41
  private clearFlushTimer;
42
+ private recordEvent;
43
+ private persistAfterRecovery;
32
44
  /**
33
45
  * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
34
46
  * Fire-and-forget — never throws or blocks.
@@ -37,6 +49,7 @@ export declare class TelemetryReporter {
37
49
  * to avoid overwriting orphaned events from a previous session before recovery.
38
50
  */
39
51
  private persistBuffer;
52
+ private persistBufferAsync;
40
53
  /**
41
54
  * Load persisted events from a previous session and merge into current buffer.
42
55
  * Runs once per instance. If orphaned events are found, emits an
@@ -45,3 +58,4 @@ export declare class TelemetryReporter {
45
58
  */
46
59
  private loadPersistedBuffer;
47
60
  }
61
+ export {};
@@ -15,6 +15,7 @@ const CRITICAL_EVENTS = new Set([
15
15
  'camera_preview_timeout',
16
16
  'camera_startup_slow',
17
17
  'camera_permission_denied',
18
+ 'camera_capture_request',
18
19
  'camera_scanner_mounted',
19
20
  'camera_scanner_disposed',
20
21
  'camera_android_native_orientation_started',
@@ -30,11 +31,13 @@ const CRITICAL_EVENTS = new Set([
30
31
  export class TelemetryReporter {
31
32
  constructor(apiKey, baseUrl) {
32
33
  this.buffer = new Map();
34
+ this.inFlightBuffer = new Map();
33
35
  this.flushTimer = null;
34
36
  this.disposed = false;
35
37
  this.flushing = false;
36
38
  this.loadedPersisted = false;
37
39
  this.loadPersistedPromise = null;
40
+ this.persistenceChain = Promise.resolve();
38
41
  this.apiKey = apiKey;
39
42
  this.baseUrl = baseUrl.replace(/\/$/, '');
40
43
  this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
@@ -60,61 +63,42 @@ export class TelemetryReporter {
60
63
  if (this.disposed)
61
64
  return;
62
65
  try {
63
- const now = new Date().toISOString();
64
- const errorObj = opts.error instanceof Error ? opts.error : null;
65
- const errorMessage = errorObj?.message
66
- ?? (typeof opts.error === 'string' ? opts.error : undefined);
67
- const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
68
- const existing = this.buffer.get(dedupKey);
69
- if (existing) {
70
- existing.event_count++;
71
- existing.last_occurred_at = now;
72
- // If the new occurrence is critical, flush immediately
73
- if (CRITICAL_EVENTS.has(eventType)) {
74
- this.flushNow();
75
- }
76
- return;
77
- }
78
- const event = {
79
- event_type: eventType,
80
- component: opts.component,
81
- error_message: errorMessage?.slice(0, 1000),
82
- error_stack: errorObj?.stack?.slice(0, 2000),
83
- error_code: opts.errorCode,
84
- metadata: opts.metadata,
85
- sdk_platform: Platform.OS,
86
- sdk_version: SDK_VERSION,
87
- os_name: Platform.OS,
88
- os_version: String(Platform.Version),
89
- session_id: this.sessionId,
90
- event_count: 1,
91
- first_occurred_at: now,
92
- last_occurred_at: now,
93
- };
94
- this.buffer.set(dedupKey, event);
66
+ this.recordEvent(eventType, opts);
95
67
  if (CRITICAL_EVENTS.has(eventType)) {
96
- this.flushNow();
68
+ // Critical events are written locally before delivery starts. This keeps
69
+ // them recoverable if the process is killed while the POST is in flight.
70
+ void this.persistAfterRecovery().then(() => this.flushNow());
97
71
  }
98
72
  else {
99
73
  this.scheduleFlush();
100
- }
101
- // Persist immediately once prior-session recovery has completed. If startup
102
- // recovery is still in flight, write the merged buffer back afterwards.
103
- if (this.loadedPersisted) {
104
- this.persistBuffer();
105
- }
106
- else {
107
- void this.loadPersistedBuffer().then(() => {
108
- if (!this.disposed && this.buffer.size > 0) {
109
- this.persistBuffer();
110
- }
111
- });
74
+ void this.persistAfterRecovery();
112
75
  }
113
76
  }
114
77
  catch {
115
78
  // Never throw from telemetry
116
79
  }
117
80
  }
81
+ /**
82
+ * Record an event and wait until it is durably stored before returning.
83
+ *
84
+ * Use this for user intent that must survive an immediate force-close. Network
85
+ * delivery starts after the local write, but callers do not wait on the network.
86
+ */
87
+ async trackDurably(eventType, opts = {}) {
88
+ if (this.disposed)
89
+ return;
90
+ try {
91
+ await this.loadPersistedBuffer();
92
+ if (this.disposed)
93
+ return;
94
+ this.recordEvent(eventType, opts);
95
+ await this.persistBufferAsync();
96
+ this.flushNow();
97
+ }
98
+ catch {
99
+ // Never throw from telemetry or block the host capture flow.
100
+ }
101
+ }
118
102
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
119
103
  async flush() {
120
104
  // Merge any persisted events from a previous session before flushing
@@ -125,6 +109,7 @@ export class TelemetryReporter {
125
109
  const bufferedEntries = Array.from(this.buffer.entries());
126
110
  const events = bufferedEntries.map(([, event]) => event);
127
111
  this.buffer.clear();
112
+ this.inFlightBuffer = new Map(bufferedEntries);
128
113
  this.clearFlushTimer();
129
114
  const controller = new AbortController();
130
115
  const timeout = setTimeout(() => controller.abort(), 10000);
@@ -151,8 +136,10 @@ export class TelemetryReporter {
151
136
  loggedDeliveryFailure = true;
152
137
  throw new Error(`Telemetry request failed with status ${response.status}`);
153
138
  }
154
- // Success — clear persisted buffer since events are now server-side
155
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
139
+ this.inFlightBuffer.clear();
140
+ // Success — persist the current buffer. Usually empty, but it may contain
141
+ // events recorded while this request was in flight.
142
+ await this.persistBufferAsync();
156
143
  }
157
144
  catch (error) {
158
145
  if (!loggedDeliveryFailure) {
@@ -175,6 +162,7 @@ export class TelemetryReporter {
175
162
  this.buffer.set(dedupKey, event);
176
163
  }
177
164
  }
165
+ this.inFlightBuffer.clear();
178
166
  }
179
167
  finally {
180
168
  clearTimeout(timeout);
@@ -208,6 +196,42 @@ export class TelemetryReporter {
208
196
  this.flushTimer = null;
209
197
  }
210
198
  }
199
+ recordEvent(eventType, opts) {
200
+ const now = new Date().toISOString();
201
+ const errorObj = opts.error instanceof Error ? opts.error : null;
202
+ const errorMessage = errorObj?.message
203
+ ?? (typeof opts.error === 'string' ? opts.error : undefined);
204
+ const occurrenceId = opts.metadata?.capture_attempt_id;
205
+ const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
206
+ const existing = this.buffer.get(dedupKey);
207
+ if (existing) {
208
+ existing.event_count++;
209
+ existing.last_occurred_at = now;
210
+ if (opts.metadata)
211
+ existing.metadata = opts.metadata;
212
+ return;
213
+ }
214
+ this.buffer.set(dedupKey, {
215
+ event_type: eventType,
216
+ component: opts.component,
217
+ error_message: errorMessage?.slice(0, 1000),
218
+ error_stack: errorObj?.stack?.slice(0, 2000),
219
+ error_code: opts.errorCode,
220
+ metadata: opts.metadata,
221
+ sdk_platform: Platform.OS,
222
+ sdk_version: SDK_VERSION,
223
+ os_name: Platform.OS,
224
+ os_version: String(Platform.Version),
225
+ session_id: this.sessionId,
226
+ event_count: 1,
227
+ first_occurred_at: now,
228
+ last_occurred_at: now,
229
+ });
230
+ }
231
+ async persistAfterRecovery() {
232
+ await this.loadPersistedBuffer();
233
+ await this.persistBufferAsync();
234
+ }
211
235
  /**
212
236
  * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
213
237
  * Fire-and-forget — never throws or blocks.
@@ -216,24 +240,38 @@ export class TelemetryReporter {
216
240
  * to avoid overwriting orphaned events from a previous session before recovery.
217
241
  */
218
242
  persistBuffer() {
243
+ void this.persistBufferAsync();
244
+ }
245
+ persistBufferAsync() {
219
246
  if (!this.loadedPersisted)
220
- return; // Don't overwrite orphaned prior-session data before it's loaded
247
+ return Promise.resolve();
221
248
  try {
222
249
  const entries = {};
250
+ for (const [key, event] of this.inFlightBuffer) {
251
+ entries[key] = event;
252
+ }
223
253
  for (const [key, event] of this.buffer) {
224
254
  entries[key] = event;
225
255
  }
226
- getStorage().then(s => {
256
+ // Serialize writes so an older fire-and-forget snapshot cannot overwrite a
257
+ // newer durable capture marker or resurrect an acknowledged event.
258
+ this.persistenceChain = this.persistenceChain
259
+ .catch(() => { })
260
+ .then(async () => {
261
+ const storage = await getStorage();
227
262
  if (Object.keys(entries).length > 0) {
228
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
263
+ await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
229
264
  }
230
265
  else {
231
- s.removeItem(TELEMETRY_PERSIST_KEY);
266
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
232
267
  }
233
- }).catch(() => { });
268
+ })
269
+ .catch(() => { });
270
+ return this.persistenceChain;
234
271
  }
235
272
  catch {
236
273
  // Never throw from telemetry
274
+ return Promise.resolve();
237
275
  }
238
276
  }
239
277
  /**
@@ -33,6 +33,8 @@ export interface VerificationResult {
33
33
  policy: string;
34
34
  violation_reasons: string[];
35
35
  feedback: string;
36
+ warning_violation_reasons?: string[];
37
+ warning_feedback?: string | null;
36
38
  metadata: Record<string, unknown>;
37
39
  image_url: string | null;
38
40
  /** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
@@ -85,6 +87,8 @@ export interface ScannerTelemetryContext {
85
87
  routeName?: string;
86
88
  /** Whether the host app keeps this screen portrait-locked. */
87
89
  isPortraitLocked?: boolean;
90
+ /** Host-owned identifier used to correlate scanner telemetry with its workflow. */
91
+ hostReferenceId?: string;
88
92
  }
89
93
  /**
90
94
  * Theme customization for the scanner overlay.
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.5.2";
1
+ export declare const SDK_VERSION = "2.5.5";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.2';
1
+ export const SDK_VERSION = '2.5.5';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "typecheck": "tsc --noEmit",
28
+ "test": "vitest run",
28
29
  "build": "tsc",
29
30
  "prepublishOnly": "npm run build"
30
31
  },
@@ -32,6 +32,10 @@ import {
32
32
  getOverlayRotationDeg,
33
33
  type ScannerPhysicalOrientation,
34
34
  } from './scannerOrientation';
35
+ import {
36
+ CAMERA_CAPTURE_RETRY_DELAY_MS,
37
+ getCameraCaptureRetryPlan,
38
+ } from './cameraCaptureRetry';
35
39
 
36
40
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
37
41
  const FALLBACK_QUALITY = 0.65;
@@ -44,10 +48,6 @@ const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
44
48
  const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
45
49
  const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
46
50
  const TRANSIENT_ERROR_DISPLAY_MS = 3000;
47
- const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
48
- const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
49
- const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
50
- const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
51
51
  // iOS AVCaptureSession cold start can legitimately exceed 8s on first launch (permission
52
52
  // prompt, first-ever session spin-up, thermal/low-power throttling). An 8s watchdog tore
53
53
  // down sessions that were seconds from ready and restarted from scratch, which made slow
@@ -409,6 +409,7 @@ export function VerifyAIScanner({
409
409
  device_os_version: String(Platform.Version),
410
410
  route_name: telemetryContext?.routeName,
411
411
  is_portrait_locked: telemetryContext?.isPortraitLocked,
412
+ host_reference_id: telemetryContext?.hostReferenceId,
412
413
  window_width: windowWidth,
413
414
  window_height: windowHeight,
414
415
  interface_orientation: isLandscape ? 'landscape' : 'portrait',
@@ -1125,15 +1126,20 @@ export function VerifyAIScanner({
1125
1126
  ? 'exhausted'
1126
1127
  : null;
1127
1128
 
1128
- telemetry?.track('camera_capture_request', {
1129
- component: 'scanner',
1130
- error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1131
- metadata: buildScannerTelemetryMetadata({
1132
- capture_attempt_id: captureAttemptId,
1133
- capture_sequence: captureSequence,
1134
- capture_blocked_reason: blockedReason,
1135
- }),
1136
- });
1129
+ if (telemetry) {
1130
+ // Do not begin native capture until the user's shutter intent is on disk.
1131
+ // If the process is force-closed during capture or delivery, the next SDK
1132
+ // initialization recovers and sends this exact capture_attempt_id.
1133
+ await telemetry.trackDurably('camera_capture_request', {
1134
+ component: 'scanner',
1135
+ error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
1136
+ metadata: buildScannerTelemetryMetadata({
1137
+ capture_attempt_id: captureAttemptId,
1138
+ capture_sequence: captureSequence,
1139
+ capture_blocked_reason: blockedReason,
1140
+ }),
1141
+ });
1142
+ }
1137
1143
 
1138
1144
  if (blockedReason && blockedReason !== 'camera_not_ready') return;
1139
1145
  if (blockedReason === 'camera_not_ready') {
@@ -1172,6 +1178,7 @@ export function VerifyAIScanner({
1172
1178
  let captureRetryTorchSuppressed = false;
1173
1179
  let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
1174
1180
  let captureRetryRemountBackoffMs = 0;
1181
+ let captureRetryReadyTimeoutMs = 0;
1175
1182
  let lastNativeCaptureErrorMessage: string | null = null;
1176
1183
 
1177
1184
  try {
@@ -1251,13 +1258,11 @@ export function VerifyAIScanner({
1251
1258
 
1252
1259
  if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
1253
1260
  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;
1261
+ const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
1262
+ captureRetryTorchSuppressed = retryPlan.suppressTorch;
1263
+ captureRetrySettleDelayMs = retryPlan.settleDelayMs;
1264
+ captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
1265
+ captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
1261
1266
  const retryAt = new Date().toISOString();
1262
1267
  lastCaptureRetryAtRef.current = retryAt;
1263
1268
  cameraRemountCountRef.current++;
@@ -1271,9 +1276,6 @@ export function VerifyAIScanner({
1271
1276
  : undefined,
1272
1277
  );
1273
1278
  await sleep(captureRetrySettleDelayMs);
1274
- const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1275
- ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1276
- : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
1277
1279
  captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
1278
1280
  telemetry?.track('camera_capture_retry', {
1279
1281
  component: 'scanner',
@@ -1397,6 +1399,7 @@ export function VerifyAIScanner({
1397
1399
  capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1398
1400
  capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1399
1401
  capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1402
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1400
1403
  recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
1401
1404
  last_native_capture_error: lastNativeCaptureErrorMessage,
1402
1405
  }),
@@ -1479,6 +1482,7 @@ export function VerifyAIScanner({
1479
1482
  capture_retry_torch_suppressed: captureRetryTorchSuppressed,
1480
1483
  capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
1481
1484
  capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
1485
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1482
1486
  is_native_camera_capture_error: isNativeCameraCaptureError(error),
1483
1487
  last_native_capture_error: lastNativeCaptureErrorMessage,
1484
1488
  }),
@@ -0,0 +1,37 @@
1
+ export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
2
+ export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
3
+ export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
4
+ export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
5
+
6
+ export type CameraCaptureRetryPlatform = 'ios' | 'android' | string;
7
+
8
+ export interface CameraCaptureRetryPlan {
9
+ readyTimeoutMs: number;
10
+ remountBackoffMs: number;
11
+ settleDelayMs: number;
12
+ suppressTorch: boolean;
13
+ }
14
+
15
+ export function getCameraCaptureRetryPlan(
16
+ platform: CameraCaptureRetryPlatform,
17
+ torchRequested: boolean,
18
+ ): CameraCaptureRetryPlan {
19
+ const suppressTorch = torchRequested;
20
+
21
+ return {
22
+ readyTimeoutMs: platform === 'android'
23
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
24
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS,
25
+ // Flutter fully restarts its CameraController after native capture errors.
26
+ // On iOS/Expo Camera, a key-only remount can report ready too quickly while
27
+ // the native capture session is still unable to produce a photo, so force a
28
+ // brief unmount before the retry.
29
+ remountBackoffMs: platform === 'ios' || suppressTorch
30
+ ? CAMERA_CAPTURE_RETRY_DELAY_MS
31
+ : 0,
32
+ settleDelayMs: suppressTorch
33
+ ? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
34
+ : CAMERA_CAPTURE_RETRY_DELAY_MS,
35
+ suppressTorch,
36
+ };
37
+ }
@@ -34,12 +34,20 @@ interface BufferedEvent {
34
34
  dedupKey: string;
35
35
  }
36
36
 
37
+ interface TelemetryTrackOptions {
38
+ component?: string;
39
+ error?: unknown;
40
+ errorCode?: string;
41
+ metadata?: Record<string, string | number>;
42
+ }
43
+
37
44
  /** Event types that flush immediately (critical init failures). */
38
45
  const CRITICAL_EVENTS = new Set([
39
46
  'camera_init_failure',
40
47
  'camera_preview_timeout',
41
48
  'camera_startup_slow',
42
49
  'camera_permission_denied',
50
+ 'camera_capture_request',
43
51
  'camera_scanner_mounted',
44
52
  'camera_scanner_disposed',
45
53
  'camera_android_native_orientation_started',
@@ -55,6 +63,7 @@ const CRITICAL_EVENTS = new Set([
55
63
 
56
64
  export class TelemetryReporter {
57
65
  private buffer: Map<string, TelemetryEvent> = new Map();
66
+ private inFlightBuffer: Map<string, TelemetryEvent> = new Map();
58
67
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
59
68
  private sessionId: string;
60
69
  private baseUrl: string;
@@ -63,6 +72,7 @@ export class TelemetryReporter {
63
72
  private flushing = false;
64
73
  private loadedPersisted = false;
65
74
  private loadPersistedPromise: Promise<void> | null = null;
75
+ private persistenceChain: Promise<void> = Promise.resolve();
66
76
 
67
77
  constructor(apiKey: string, baseUrl: string) {
68
78
  this.apiKey = apiKey;
@@ -94,75 +104,49 @@ export class TelemetryReporter {
94
104
  /** Track an error event. Fire-and-forget — never throws. */
95
105
  track(
96
106
  eventType: string,
97
- opts: {
98
- component?: string;
99
- error?: unknown;
100
- errorCode?: string;
101
- metadata?: Record<string, string | number>;
102
- } = {},
107
+ opts: TelemetryTrackOptions = {},
103
108
  ): void {
104
109
  if (this.disposed) return;
105
110
 
106
111
  try {
107
- const now = new Date().toISOString();
108
- const errorObj = opts.error instanceof Error ? opts.error : null;
109
- const errorMessage = errorObj?.message
110
- ?? (typeof opts.error === 'string' ? opts.error : undefined);
111
-
112
- const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}`;
113
-
114
- const existing = this.buffer.get(dedupKey);
115
- if (existing) {
116
- existing.event_count++;
117
- existing.last_occurred_at = now;
118
- // If the new occurrence is critical, flush immediately
119
- if (CRITICAL_EVENTS.has(eventType)) {
120
- this.flushNow();
121
- }
122
- return;
123
- }
124
-
125
- const event: TelemetryEvent = {
126
- event_type: eventType,
127
- component: opts.component,
128
- error_message: errorMessage?.slice(0, 1000),
129
- error_stack: errorObj?.stack?.slice(0, 2000),
130
- error_code: opts.errorCode,
131
- metadata: opts.metadata,
132
- sdk_platform: Platform.OS,
133
- sdk_version: SDK_VERSION,
134
- os_name: Platform.OS,
135
- os_version: String(Platform.Version),
136
- session_id: this.sessionId,
137
- event_count: 1,
138
- first_occurred_at: now,
139
- last_occurred_at: now,
140
- };
141
-
142
- this.buffer.set(dedupKey, event);
112
+ this.recordEvent(eventType, opts);
143
113
 
144
114
  if (CRITICAL_EVENTS.has(eventType)) {
145
- this.flushNow();
115
+ // Critical events are written locally before delivery starts. This keeps
116
+ // them recoverable if the process is killed while the POST is in flight.
117
+ void this.persistAfterRecovery().then(() => this.flushNow());
146
118
  } else {
147
119
  this.scheduleFlush();
148
- }
149
-
150
- // Persist immediately once prior-session recovery has completed. If startup
151
- // recovery is still in flight, write the merged buffer back afterwards.
152
- if (this.loadedPersisted) {
153
- this.persistBuffer();
154
- } else {
155
- void this.loadPersistedBuffer().then(() => {
156
- if (!this.disposed && this.buffer.size > 0) {
157
- this.persistBuffer();
158
- }
159
- });
120
+ void this.persistAfterRecovery();
160
121
  }
161
122
  } catch {
162
123
  // Never throw from telemetry
163
124
  }
164
125
  }
165
126
 
127
+ /**
128
+ * Record an event and wait until it is durably stored before returning.
129
+ *
130
+ * Use this for user intent that must survive an immediate force-close. Network
131
+ * delivery starts after the local write, but callers do not wait on the network.
132
+ */
133
+ async trackDurably(
134
+ eventType: string,
135
+ opts: TelemetryTrackOptions = {},
136
+ ): Promise<void> {
137
+ if (this.disposed) return;
138
+
139
+ try {
140
+ await this.loadPersistedBuffer();
141
+ if (this.disposed) return;
142
+ this.recordEvent(eventType, opts);
143
+ await this.persistBufferAsync();
144
+ this.flushNow();
145
+ } catch {
146
+ // Never throw from telemetry or block the host capture flow.
147
+ }
148
+ }
149
+
166
150
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
167
151
  async flush(): Promise<void> {
168
152
  // Merge any persisted events from a previous session before flushing
@@ -174,6 +158,7 @@ export class TelemetryReporter {
174
158
  const bufferedEntries = Array.from(this.buffer.entries());
175
159
  const events = bufferedEntries.map(([, event]) => event);
176
160
  this.buffer.clear();
161
+ this.inFlightBuffer = new Map(bufferedEntries);
177
162
  this.clearFlushTimer();
178
163
  const controller = new AbortController();
179
164
  const timeout = setTimeout(() => controller.abort(), 10000);
@@ -202,8 +187,10 @@ export class TelemetryReporter {
202
187
  throw new Error(`Telemetry request failed with status ${response.status}`);
203
188
  }
204
189
 
205
- // Success — clear persisted buffer since events are now server-side
206
- this.persistBuffer(); // buffer is empty at this point, so this clears the key
190
+ this.inFlightBuffer.clear();
191
+ // Success — persist the current buffer. Usually empty, but it may contain
192
+ // events recorded while this request was in flight.
193
+ await this.persistBufferAsync();
207
194
  } catch (error) {
208
195
  if (!loggedDeliveryFailure) {
209
196
  const kind = error instanceof Error ? error.name : typeof error;
@@ -224,6 +211,7 @@ export class TelemetryReporter {
224
211
  this.buffer.set(dedupKey, event);
225
212
  }
226
213
  }
214
+ this.inFlightBuffer.clear();
227
215
  } finally {
228
216
  clearTimeout(timeout);
229
217
  this.flushing = false;
@@ -260,6 +248,45 @@ export class TelemetryReporter {
260
248
  }
261
249
  }
262
250
 
251
+ private recordEvent(eventType: string, opts: TelemetryTrackOptions): void {
252
+ const now = new Date().toISOString();
253
+ const errorObj = opts.error instanceof Error ? opts.error : null;
254
+ const errorMessage = errorObj?.message
255
+ ?? (typeof opts.error === 'string' ? opts.error : undefined);
256
+ const occurrenceId = opts.metadata?.capture_attempt_id;
257
+ const dedupKey = `${eventType}|${errorMessage ?? ''}|${opts.component ?? ''}|${occurrenceId ?? ''}`;
258
+
259
+ const existing = this.buffer.get(dedupKey);
260
+ if (existing) {
261
+ existing.event_count++;
262
+ existing.last_occurred_at = now;
263
+ if (opts.metadata) existing.metadata = opts.metadata;
264
+ return;
265
+ }
266
+
267
+ this.buffer.set(dedupKey, {
268
+ event_type: eventType,
269
+ component: opts.component,
270
+ error_message: errorMessage?.slice(0, 1000),
271
+ error_stack: errorObj?.stack?.slice(0, 2000),
272
+ error_code: opts.errorCode,
273
+ metadata: opts.metadata,
274
+ sdk_platform: Platform.OS,
275
+ sdk_version: SDK_VERSION,
276
+ os_name: Platform.OS,
277
+ os_version: String(Platform.Version),
278
+ session_id: this.sessionId,
279
+ event_count: 1,
280
+ first_occurred_at: now,
281
+ last_occurred_at: now,
282
+ });
283
+ }
284
+
285
+ private async persistAfterRecovery(): Promise<void> {
286
+ await this.loadPersistedBuffer();
287
+ await this.persistBufferAsync();
288
+ }
289
+
263
290
  /**
264
291
  * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
265
292
  * Fire-and-forget — never throws or blocks.
@@ -268,21 +295,38 @@ export class TelemetryReporter {
268
295
  * to avoid overwriting orphaned events from a previous session before recovery.
269
296
  */
270
297
  private persistBuffer(): void {
271
- if (!this.loadedPersisted) return; // Don't overwrite orphaned prior-session data before it's loaded
298
+ void this.persistBufferAsync();
299
+ }
300
+
301
+ private persistBufferAsync(): Promise<void> {
302
+ if (!this.loadedPersisted) return Promise.resolve();
303
+
272
304
  try {
273
305
  const entries: Record<string, TelemetryEvent> = {};
306
+ for (const [key, event] of this.inFlightBuffer) {
307
+ entries[key] = event;
308
+ }
274
309
  for (const [key, event] of this.buffer) {
275
310
  entries[key] = event;
276
311
  }
277
- getStorage().then(s => {
278
- if (Object.keys(entries).length > 0) {
279
- s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
280
- } else {
281
- s.removeItem(TELEMETRY_PERSIST_KEY);
282
- }
283
- }).catch(() => { /* never throw from telemetry */ });
312
+
313
+ // Serialize writes so an older fire-and-forget snapshot cannot overwrite a
314
+ // newer durable capture marker or resurrect an acknowledged event.
315
+ this.persistenceChain = this.persistenceChain
316
+ .catch(() => { /* keep the write queue alive */ })
317
+ .then(async () => {
318
+ const storage = await getStorage();
319
+ if (Object.keys(entries).length > 0) {
320
+ await storage.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
321
+ } else {
322
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
323
+ }
324
+ })
325
+ .catch(() => { /* never throw from telemetry */ });
326
+ return this.persistenceChain;
284
327
  } catch {
285
328
  // Never throw from telemetry
329
+ return Promise.resolve();
286
330
  }
287
331
  }
288
332
 
@@ -37,6 +37,8 @@ export interface VerificationResult {
37
37
  policy: string;
38
38
  violation_reasons: string[];
39
39
  feedback: string;
40
+ warning_violation_reasons?: string[];
41
+ warning_feedback?: string | null;
40
42
  metadata: Record<string, unknown>;
41
43
  image_url: string | null;
42
44
  /** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
@@ -96,6 +98,8 @@ export interface ScannerTelemetryContext {
96
98
  routeName?: string;
97
99
  /** Whether the host app keeps this screen portrait-locked. */
98
100
  isPortraitLocked?: boolean;
101
+ /** Host-owned identifier used to correlate scanner telemetry with its workflow. */
102
+ hostReferenceId?: string;
99
103
  }
100
104
 
101
105
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.5.2';
1
+ export const SDK_VERSION = '2.5.5';