@switchlabs/verify-ai-react-native 2.3.2 → 2.4.1

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.
@@ -118,9 +118,12 @@ export class VerifyAIClient {
118
118
  : normalized.status >= 400 && normalized.status < 500
119
119
  ? 'request_error'
120
120
  : 'server_error';
121
+ const telemetryError = eventType === 'request_timeout'
122
+ ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
123
+ : normalized;
121
124
  this.telemetry.track(eventType, {
122
125
  component: 'client',
123
- error: normalized,
126
+ error: telemetryError,
124
127
  errorCode,
125
128
  });
126
129
  }
@@ -188,6 +191,9 @@ export class VerifyAIClient {
188
191
  if (request.provider) {
189
192
  formData.append('provider', request.provider);
190
193
  }
194
+ if (request.include_image_data) {
195
+ formData.append('include_image_data', 'true');
196
+ }
191
197
  const headers = {
192
198
  'X-API-Key': this.apiKey,
193
199
  // Do NOT set Content-Type — fetch auto-sets multipart boundary
@@ -217,6 +223,7 @@ export class VerifyAIClient {
217
223
  policy: request.policy,
218
224
  metadata: request.metadata,
219
225
  provider: request.provider,
226
+ include_image_data: request.include_image_data,
220
227
  });
221
228
  }
222
229
  catch {
@@ -1,14 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useRef, useState, useCallback, useEffect } from 'react';
3
- import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 'react-native';
3
+ import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState, useWindowDimensions, } from 'react-native';
4
4
  import { CameraView, useCameraPermissions, } from 'expo-camera';
5
5
  import { useTelemetry } from '../telemetry/TelemetryContext';
6
6
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
7
- const FALLBACK_QUALITY = 0.5;
7
+ const FALLBACK_QUALITY = 0.65;
8
8
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
9
- const MANIPULATOR_QUALITY = 0.7;
9
+ const MANIPULATOR_QUALITY = 0.8;
10
10
  /** Max dimension (px) on longest side when resize is available. */
11
- const MAX_DIMENSION = 2048;
11
+ const MAX_DIMENSION = 1600;
12
12
  function getErrorDisplay(error, showTechnicalDetails) {
13
13
  if (!error) {
14
14
  return {
@@ -89,16 +89,46 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
89
89
  const cameraReadyRef = useRef(false);
90
90
  const cameraInitFailedRef = useRef(false);
91
91
  const permissionDeniedTrackedRef = useRef(false);
92
+ // Track dimensions to detect orientation changes and remount camera
93
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
94
+ const prevDimensionsRef = useRef({ width: windowWidth, height: windowHeight });
95
+ useEffect(() => {
96
+ const prev = prevDimensionsRef.current;
97
+ const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
98
+ prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
99
+ if (orientationChanged && !terminated) {
100
+ // Force camera remount to fix preview distortion on iOS
101
+ setCameraReady(false);
102
+ cameraReadyRef.current = false;
103
+ setCameraKey((k) => k + 1);
104
+ }
105
+ }, [windowWidth, windowHeight, terminated]);
106
+ // Resume camera when app returns from background/inactive (e.g. notification bar)
107
+ useEffect(() => {
108
+ if (terminated)
109
+ return;
110
+ const subscription = AppState.addEventListener('change', (nextState) => {
111
+ if (nextState === 'active') {
112
+ // Force camera remount — on iOS, AVCaptureSession often fails to resume
113
+ // its preview layer after returning from the notification bar or control center.
114
+ setCameraReady(false);
115
+ cameraReadyRef.current = false;
116
+ setCameraKey((k) => k + 1);
117
+ }
118
+ });
119
+ return () => subscription.remove();
120
+ }, [terminated]);
121
+ const pausePreview = useCallback(() => {
122
+ cameraRef.current?.pausePreview?.().catch(() => { });
123
+ }, []);
92
124
  // Release camera (and torch) when a terminal result is reached or on unmount.
93
125
  const releaseCamera = useCallback(() => {
94
126
  setTerminated(true);
95
- cameraRef.current?.pausePreview?.().catch(() => { });
96
- }, []);
127
+ pausePreview();
128
+ }, [pausePreview]);
97
129
  useEffect(() => {
98
- return () => {
99
- cameraRef.current?.pausePreview?.().catch(() => { });
100
- };
101
- }, []);
130
+ return pausePreview;
131
+ }, [pausePreview]);
102
132
  // Camera init callbacks
103
133
  const onCameraReady = useCallback(() => {
104
134
  setCameraReady(true);
@@ -325,12 +355,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
325
355
  overlay.guideFrameAspectRatio
326
356
  ? { aspectRatio: overlay.guideFrameAspectRatio }
327
357
  : undefined,
328
- ], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }), overlay.guideCaption && (_jsx(Text, { style: styles.guideCaptionText, 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, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
358
+ ], 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: styles.guideCaptionText, 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, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
329
359
  styles.resultIconCircle,
330
360
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
361
+ result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
362
+ !result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
331
363
  ], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
332
364
  styles.resultLabel,
333
365
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
366
+ result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
367
+ !result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
334
368
  ], children: result.is_compliant
335
369
  ? (overlay?.successMessage || 'Verified')
336
370
  : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
@@ -358,12 +392,13 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
358
392
  errorTitle = display.title;
359
393
  errorMessage = display.message;
360
394
  }
361
- return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
395
+ 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 })] }));
362
396
  })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: styles.instructionsText, children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
363
397
  styles.captureButton,
398
+ overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
364
399
  (!cameraReady || status === 'capturing' || status === 'processing') &&
365
400
  styles.captureButtonDisabled,
366
- ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }, cameraKey) }));
401
+ ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] })] }) }, cameraKey) }));
367
402
  }
368
403
  const CORNER_SIZE = 30;
369
404
  const CORNER_THICKNESS = 3;
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, PolicyConfigResponse, } from './types';
8
+ export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, PolicyConfigResponse, } from './types';
@@ -6,6 +6,8 @@ export declare class TelemetryReporter {
6
6
  private apiKey;
7
7
  private disposed;
8
8
  private flushing;
9
+ private loadedPersisted;
10
+ private loadPersistedPromise;
9
11
  constructor(apiKey: string, baseUrl: string);
10
12
  /** Track an error event. Fire-and-forget — never throws. */
11
13
  track(eventType: string, opts?: {
@@ -21,4 +23,19 @@ export declare class TelemetryReporter {
21
23
  private scheduleFlush;
22
24
  private flushNow;
23
25
  private clearFlushTimer;
26
+ /**
27
+ * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
28
+ * Fire-and-forget — never throws or blocks.
29
+ *
30
+ * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
31
+ * to avoid overwriting orphaned events from a previous session before recovery.
32
+ */
33
+ private persistBuffer;
34
+ /**
35
+ * Load persisted events from a previous session and merge into current buffer.
36
+ * Runs once per instance. If orphaned events are found, emits an
37
+ * `sdk_session_recovered` event so monitoring can flag that the prior
38
+ * session ended without a clean telemetry flush.
39
+ */
40
+ private loadPersistedBuffer;
24
41
  }
@@ -1,5 +1,14 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { SDK_VERSION } from '../version';
3
+ let _storage = null;
4
+ async function getStorage() {
5
+ if (!_storage) {
6
+ const mod = await import('@react-native-async-storage/async-storage');
7
+ _storage = mod.default;
8
+ }
9
+ return _storage;
10
+ }
11
+ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
3
12
  /** Event types that flush immediately (critical init failures). */
4
13
  const CRITICAL_EVENTS = new Set([
5
14
  'camera_init_failure',
@@ -12,9 +21,13 @@ export class TelemetryReporter {
12
21
  this.flushTimer = null;
13
22
  this.disposed = false;
14
23
  this.flushing = false;
24
+ this.loadedPersisted = false;
25
+ this.loadPersistedPromise = null;
15
26
  this.apiKey = apiKey;
16
27
  this.baseUrl = baseUrl.replace(/\/$/, '');
17
28
  this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
29
+ // Load any persisted events left behind by a previous session
30
+ this.loadPersistedBuffer();
18
31
  }
19
32
  /** Track an error event. Fire-and-forget — never throws. */
20
33
  track(eventType, opts = {}) {
@@ -59,6 +72,18 @@ export class TelemetryReporter {
59
72
  else {
60
73
  this.scheduleFlush();
61
74
  }
75
+ // Persist immediately once prior-session recovery has completed. If startup
76
+ // recovery is still in flight, write the merged buffer back afterwards.
77
+ if (this.loadedPersisted) {
78
+ this.persistBuffer();
79
+ }
80
+ else {
81
+ void this.loadPersistedBuffer().then(() => {
82
+ if (!this.disposed && this.buffer.size > 0) {
83
+ this.persistBuffer();
84
+ }
85
+ });
86
+ }
62
87
  }
63
88
  catch {
64
89
  // Never throw from telemetry
@@ -66,6 +91,8 @@ export class TelemetryReporter {
66
91
  }
67
92
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
68
93
  async flush() {
94
+ // Merge any persisted events from a previous session before flushing
95
+ await this.loadPersistedBuffer();
69
96
  if (this.buffer.size === 0 || this.flushing)
70
97
  return;
71
98
  this.flushing = true;
@@ -88,6 +115,8 @@ export class TelemetryReporter {
88
115
  if (!response.ok) {
89
116
  throw new Error(`Telemetry request failed with status ${response.status}`);
90
117
  }
118
+ // Success — clear persisted buffer since events are now server-side
119
+ this.persistBuffer(); // buffer is empty at this point, so this clears the key
91
120
  }
92
121
  catch {
93
122
  for (const [dedupKey, event] of bufferedEntries) {
@@ -138,4 +167,111 @@ export class TelemetryReporter {
138
167
  this.flushTimer = null;
139
168
  }
140
169
  }
170
+ /**
171
+ * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
172
+ * Fire-and-forget — never throws or blocks.
173
+ *
174
+ * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
175
+ * to avoid overwriting orphaned events from a previous session before recovery.
176
+ */
177
+ persistBuffer() {
178
+ if (!this.loadedPersisted)
179
+ return; // Don't overwrite orphaned prior-session data before it's loaded
180
+ try {
181
+ const entries = {};
182
+ for (const [key, event] of this.buffer) {
183
+ entries[key] = event;
184
+ }
185
+ getStorage().then(s => {
186
+ if (Object.keys(entries).length > 0) {
187
+ s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
188
+ }
189
+ else {
190
+ s.removeItem(TELEMETRY_PERSIST_KEY);
191
+ }
192
+ }).catch(() => { });
193
+ }
194
+ catch {
195
+ // Never throw from telemetry
196
+ }
197
+ }
198
+ /**
199
+ * Load persisted events from a previous session and merge into current buffer.
200
+ * Runs once per instance. If orphaned events are found, emits an
201
+ * `sdk_session_recovered` event so monitoring can flag that the prior
202
+ * session ended without a clean telemetry flush.
203
+ */
204
+ async loadPersistedBuffer() {
205
+ if (this.loadedPersisted)
206
+ return;
207
+ if (this.loadPersistedPromise) {
208
+ await this.loadPersistedPromise;
209
+ return;
210
+ }
211
+ this.loadPersistedPromise = (async () => {
212
+ try {
213
+ const storage = await getStorage();
214
+ const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
215
+ if (!raw)
216
+ return;
217
+ const entries = JSON.parse(raw);
218
+ let orphanedCount = 0;
219
+ const orphanedTypes = [];
220
+ for (const [key, event] of Object.entries(entries)) {
221
+ if (event.session_id === this.sessionId)
222
+ continue; // Same session, skip
223
+ orphanedCount++;
224
+ orphanedTypes.push(event.event_type);
225
+ // Merge orphaned events into the current buffer
226
+ const existing = this.buffer.get(key);
227
+ if (existing) {
228
+ existing.event_count += event.event_count;
229
+ if (event.first_occurred_at < existing.first_occurred_at) {
230
+ existing.first_occurred_at = event.first_occurred_at;
231
+ }
232
+ if (event.last_occurred_at > existing.last_occurred_at) {
233
+ existing.last_occurred_at = event.last_occurred_at;
234
+ }
235
+ }
236
+ else {
237
+ this.buffer.set(key, event);
238
+ }
239
+ }
240
+ // Emit sdk_session_recovered if we recovered orphaned events from a previous session
241
+ if (orphanedCount > 0) {
242
+ const now = new Date().toISOString();
243
+ const uniqueTypes = [...new Set(orphanedTypes)];
244
+ const crashEvent = {
245
+ event_type: 'sdk_session_recovered',
246
+ component: 'TelemetryReporter',
247
+ error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
248
+ sdk_platform: Platform.OS,
249
+ sdk_version: SDK_VERSION,
250
+ os_name: Platform.OS,
251
+ os_version: String(Platform.Version),
252
+ session_id: this.sessionId,
253
+ event_count: 1,
254
+ first_occurred_at: now,
255
+ last_occurred_at: now,
256
+ };
257
+ this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
258
+ }
259
+ // Clear persisted data now that it's loaded into memory. The merged
260
+ // buffer will be re-persisted below until a flush succeeds.
261
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
262
+ }
263
+ catch {
264
+ // Never throw from telemetry
265
+ }
266
+ finally {
267
+ this.loadedPersisted = true;
268
+ this.loadPersistedPromise = null;
269
+ if (this.buffer.size > 0 && !this.disposed) {
270
+ this.persistBuffer();
271
+ this.scheduleFlush();
272
+ }
273
+ }
274
+ })();
275
+ await this.loadPersistedPromise;
276
+ }
141
277
  }
@@ -12,12 +12,16 @@ export interface VerificationRequest {
12
12
  policy: string;
13
13
  metadata?: Record<string, unknown>;
14
14
  provider?: 'openai' | 'anthropic' | 'gemini';
15
+ /** When true, the response includes the image as a base64 data URI in `image_data`. */
16
+ include_image_data?: boolean;
15
17
  }
16
18
  export interface MultipartVerificationRequest {
17
19
  imageUri: string;
18
20
  policy: string;
19
21
  metadata?: Record<string, unknown>;
20
22
  provider?: 'openai' | 'anthropic' | 'gemini';
23
+ /** When true, the response includes the image as a base64 data URI in `image_data`. */
24
+ include_image_data?: boolean;
21
25
  }
22
26
  export interface VerificationResult {
23
27
  id: string;
@@ -30,6 +34,8 @@ export interface VerificationResult {
30
34
  feedback: string;
31
35
  metadata: Record<string, unknown>;
32
36
  image_url: string | null;
37
+ /** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
38
+ image_data?: string;
33
39
  /** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
34
40
  category?: string;
35
41
  }
@@ -70,6 +76,23 @@ export interface VerifyAIError {
70
76
  method?: string;
71
77
  }
72
78
  export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
79
+ /**
80
+ * Theme customization for the scanner overlay.
81
+ *
82
+ * All properties are optional — unset values fall back to built-in defaults.
83
+ * Pass a `ScannerTheme` to `ScannerOverlayConfig.theme` to match the scanner
84
+ * to your app's brand.
85
+ */
86
+ export interface ScannerTheme {
87
+ /** Color for success states (checkmark circle, success card accent). Default: '#22C55E'. */
88
+ successColor?: string;
89
+ /** Color for failure/error states (warning icon, error card accent). Default: '#F59E0B'. */
90
+ failureColor?: string;
91
+ /** Color for the capture button circle. Default: '#FFFFFF'. */
92
+ captureButtonColor?: string;
93
+ /** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
94
+ cornerColor?: string;
95
+ }
73
96
  export interface ScannerOverlayConfig {
74
97
  title?: string;
75
98
  instructions?: string;
@@ -99,6 +122,8 @@ export interface ScannerOverlayConfig {
99
122
  maxAttempts?: number;
100
123
  autoApproveOnExhaust?: boolean;
101
124
  showTechnicalErrorDetails?: boolean;
125
+ /** Custom theme for scanner colors. */
126
+ theme?: ScannerTheme;
102
127
  }
103
128
  export interface PolicyConfigResponse {
104
129
  maxAttempts: number;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.3.2";
1
+ export declare const SDK_VERSION = "2.4.1";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.3.2';
1
+ export const SDK_VERSION = '2.4.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
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",
@@ -39,13 +39,13 @@
39
39
  "jpeg-js": "^0.4.4"
40
40
  },
41
41
  "peerDependencies": {
42
- "react": ">=18.0.0",
43
- "react-native": ">=0.72.0",
42
+ "@react-native-async-storage/async-storage": ">=1.19.0",
44
43
  "expo-camera": ">=15.0.0",
44
+ "expo-file-system": ">=17.0.0",
45
45
  "expo-image-manipulator": ">=12.0.0",
46
- "@react-native-async-storage/async-storage": ">=1.19.0",
47
- "react-native-fast-tflite": ">=1.0.0",
48
- "expo-file-system": ">=17.0.0"
46
+ "react": ">=18.0.0",
47
+ "react-native": ">=0.72.0",
48
+ "react-native-fast-tflite": ">=1.0.0"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "expo-camera": {
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "devDependencies": {
68
- "@types/jpeg-js": "^0.3.7",
69
68
  "@react-native-async-storage/async-storage": "^2.1.0",
69
+ "@types/jpeg-js": "^0.3.7",
70
70
  "@types/react": "^18.2.0",
71
71
  "expo-camera": "^16.0.0",
72
72
  "expo-file-system": "^18.0.12",
@@ -74,6 +74,7 @@
74
74
  "react": "^18.2.0",
75
75
  "react-native": "^0.76.0",
76
76
  "react-native-fast-tflite": "^1.6.1",
77
- "typescript": "^5.5.0"
77
+ "typescript": "^5.5.0",
78
+ "vitest": "^4.1.0"
78
79
  }
79
80
  }
@@ -173,13 +173,16 @@ export class VerifyAIClient {
173
173
  : normalized.status === 429
174
174
  ? 'rate_limited'
175
175
  : normalized.status >= 400 && normalized.status < 500
176
- ? 'request_error'
176
+ ? 'request_error'
177
177
  : 'server_error';
178
+ const telemetryError = eventType === 'request_timeout'
179
+ ? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
180
+ : normalized;
178
181
  this.telemetry.track(
179
182
  eventType,
180
183
  {
181
184
  component: 'client',
182
- error: normalized,
185
+ error: telemetryError,
183
186
  errorCode,
184
187
  },
185
188
  );
@@ -253,6 +256,9 @@ export class VerifyAIClient {
253
256
  if (request.provider) {
254
257
  formData.append('provider', request.provider);
255
258
  }
259
+ if (request.include_image_data) {
260
+ formData.append('include_image_data', 'true');
261
+ }
256
262
 
257
263
  const headers: Record<string, string> = {
258
264
  'X-API-Key': this.apiKey,
@@ -283,6 +289,7 @@ export class VerifyAIClient {
283
289
  policy: request.policy,
284
290
  metadata: request.metadata,
285
291
  provider: request.provider,
292
+ include_image_data: request.include_image_data,
286
293
  });
287
294
  } catch {
288
295
  // If the retry itself fails, throw the original error
@@ -5,6 +5,8 @@ import {
5
5
  TouchableOpacity,
6
6
  StyleSheet,
7
7
  ActivityIndicator,
8
+ AppState,
9
+ useWindowDimensions,
8
10
  type ViewStyle,
9
11
  } from 'react-native';
10
12
  import {
@@ -20,11 +22,11 @@ import { useTelemetry } from '../telemetry/TelemetryContext';
20
22
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
21
23
 
22
24
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
23
- const FALLBACK_QUALITY = 0.5;
25
+ const FALLBACK_QUALITY = 0.65;
24
26
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
25
- const MANIPULATOR_QUALITY = 0.7;
27
+ const MANIPULATOR_QUALITY = 0.8;
26
28
  /** Max dimension (px) on longest side when resize is available. */
27
- const MAX_DIMENSION = 2048;
29
+ const MAX_DIMENSION = 1600;
28
30
 
29
31
  export interface VerifyAIScannerProps {
30
32
  /** Called with base64 image data when the user captures a photo. */
@@ -152,17 +154,54 @@ export function VerifyAIScanner({
152
154
  const cameraInitFailedRef = useRef(false);
153
155
  const permissionDeniedTrackedRef = useRef(false);
154
156
 
157
+ // Track dimensions to detect orientation changes and remount camera
158
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
159
+ const prevDimensionsRef = useRef({ width: windowWidth, height: windowHeight });
160
+
161
+ useEffect(() => {
162
+ const prev = prevDimensionsRef.current;
163
+ const orientationChanged =
164
+ (prev.width > prev.height) !== (windowWidth > windowHeight);
165
+ prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
166
+
167
+ if (orientationChanged && !terminated) {
168
+ // Force camera remount to fix preview distortion on iOS
169
+ setCameraReady(false);
170
+ cameraReadyRef.current = false;
171
+ setCameraKey((k) => k + 1);
172
+ }
173
+ }, [windowWidth, windowHeight, terminated]);
174
+
175
+ // Resume camera when app returns from background/inactive (e.g. notification bar)
176
+ useEffect(() => {
177
+ if (terminated) return;
178
+
179
+ const subscription = AppState.addEventListener('change', (nextState) => {
180
+ if (nextState === 'active') {
181
+ // Force camera remount — on iOS, AVCaptureSession often fails to resume
182
+ // its preview layer after returning from the notification bar or control center.
183
+ setCameraReady(false);
184
+ cameraReadyRef.current = false;
185
+ setCameraKey((k) => k + 1);
186
+ }
187
+ });
188
+
189
+ return () => subscription.remove();
190
+ }, [terminated]);
191
+
192
+ const pausePreview = useCallback(() => {
193
+ cameraRef.current?.pausePreview?.().catch(() => {});
194
+ }, []);
195
+
155
196
  // Release camera (and torch) when a terminal result is reached or on unmount.
156
197
  const releaseCamera = useCallback(() => {
157
198
  setTerminated(true);
158
- cameraRef.current?.pausePreview?.().catch(() => {});
159
- }, []);
199
+ pausePreview();
200
+ }, [pausePreview]);
160
201
 
161
202
  useEffect(() => {
162
- return () => {
163
- cameraRef.current?.pausePreview?.().catch(() => {});
164
- };
165
- }, []);
203
+ return pausePreview;
204
+ }, [pausePreview]);
166
205
 
167
206
  // Camera init callbacks
168
207
  const onCameraReady = useCallback(() => {
@@ -453,10 +492,10 @@ export function VerifyAIScanner({
453
492
  </View>
454
493
  )}
455
494
  {/* Corner brackets */}
456
- <View style={[styles.corner, styles.cornerTopLeft]} />
457
- <View style={[styles.corner, styles.cornerTopRight]} />
458
- <View style={[styles.corner, styles.cornerBottomLeft]} />
459
- <View style={[styles.corner, styles.cornerBottomRight]} />
495
+ <View style={[styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
496
+ <View style={[styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
497
+ <View style={[styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
498
+ <View style={[styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
460
499
  </View>
461
500
  {overlay.guideCaption && (
462
501
  <Text style={styles.guideCaptionText}>{overlay.guideCaption}</Text>
@@ -486,6 +525,8 @@ export function VerifyAIScanner({
486
525
  style={[
487
526
  styles.resultIconCircle,
488
527
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
528
+ result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
529
+ !result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
489
530
  ]}
490
531
  >
491
532
  <Text style={styles.resultIcon}>
@@ -496,6 +537,8 @@ export function VerifyAIScanner({
496
537
  style={[
497
538
  styles.resultLabel,
498
539
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
540
+ result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
541
+ !result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
499
542
  ]}
500
543
  >
501
544
  {result.is_compliant
@@ -534,10 +577,10 @@ export function VerifyAIScanner({
534
577
  return (
535
578
  <View style={styles.resultCard}>
536
579
  <View style={styles.resultCardHeader}>
537
- <View style={[styles.resultIconCircle, styles.resultIconError]}>
580
+ <View style={[styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined]}>
538
581
  <Text style={styles.resultIcon}>!</Text>
539
582
  </View>
540
- <Text style={[styles.resultLabel, styles.resultLabelError]}>
583
+ <Text style={[styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined]}>
541
584
  {errorTitle}
542
585
  </Text>
543
586
  </View>
@@ -558,6 +601,7 @@ export function VerifyAIScanner({
558
601
  <TouchableOpacity
559
602
  style={[
560
603
  styles.captureButton,
604
+ overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
561
605
  (!cameraReady || status === 'capturing' || status === 'processing') &&
562
606
  styles.captureButtonDisabled,
563
607
  ]}
@@ -565,7 +609,7 @@ export function VerifyAIScanner({
565
609
  disabled={!cameraReady || status === 'capturing' || status === 'processing'}
566
610
  activeOpacity={0.7}
567
611
  >
568
- <View style={styles.captureButtonInner} />
612
+ <View style={[styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined]} />
569
613
  </TouchableOpacity>
570
614
  </View>
571
615
  )}
package/src/index.ts CHANGED
@@ -38,5 +38,6 @@ export type {
38
38
  VerifyAIError,
39
39
  ScannerStatus,
40
40
  ScannerOverlayConfig,
41
+ ScannerTheme,
41
42
  PolicyConfigResponse,
42
43
  } from './types';
@@ -1,6 +1,17 @@
1
1
  import { Platform } from 'react-native';
2
2
  import { SDK_VERSION } from '../version';
3
3
 
4
+ let _storage: typeof import('@react-native-async-storage/async-storage').default | null = null;
5
+ async function getStorage() {
6
+ if (!_storage) {
7
+ const mod = await import('@react-native-async-storage/async-storage');
8
+ _storage = mod.default;
9
+ }
10
+ return _storage;
11
+ }
12
+
13
+ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
14
+
4
15
  interface TelemetryEvent {
5
16
  event_type: string;
6
17
  component?: string;
@@ -38,11 +49,15 @@ export class TelemetryReporter {
38
49
  private apiKey: string;
39
50
  private disposed = false;
40
51
  private flushing = false;
52
+ private loadedPersisted = false;
53
+ private loadPersistedPromise: Promise<void> | null = null;
41
54
 
42
55
  constructor(apiKey: string, baseUrl: string) {
43
56
  this.apiKey = apiKey;
44
57
  this.baseUrl = baseUrl.replace(/\/$/, '');
45
58
  this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
59
+ // Load any persisted events left behind by a previous session
60
+ this.loadPersistedBuffer();
46
61
  }
47
62
 
48
63
  /** Track an error event. Fire-and-forget — never throws. */
@@ -100,6 +115,18 @@ export class TelemetryReporter {
100
115
  } else {
101
116
  this.scheduleFlush();
102
117
  }
118
+
119
+ // Persist immediately once prior-session recovery has completed. If startup
120
+ // recovery is still in flight, write the merged buffer back afterwards.
121
+ if (this.loadedPersisted) {
122
+ this.persistBuffer();
123
+ } else {
124
+ void this.loadPersistedBuffer().then(() => {
125
+ if (!this.disposed && this.buffer.size > 0) {
126
+ this.persistBuffer();
127
+ }
128
+ });
129
+ }
103
130
  } catch {
104
131
  // Never throw from telemetry
105
132
  }
@@ -107,6 +134,9 @@ export class TelemetryReporter {
107
134
 
108
135
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
109
136
  async flush(): Promise<void> {
137
+ // Merge any persisted events from a previous session before flushing
138
+ await this.loadPersistedBuffer();
139
+
110
140
  if (this.buffer.size === 0 || this.flushing) return;
111
141
 
112
142
  this.flushing = true;
@@ -131,6 +161,9 @@ export class TelemetryReporter {
131
161
  if (!response.ok) {
132
162
  throw new Error(`Telemetry request failed with status ${response.status}`);
133
163
  }
164
+
165
+ // Success — clear persisted buffer since events are now server-side
166
+ this.persistBuffer(); // buffer is empty at this point, so this clears the key
134
167
  } catch {
135
168
  for (const [dedupKey, event] of bufferedEntries) {
136
169
  const existing = this.buffer.get(dedupKey);
@@ -181,4 +214,110 @@ export class TelemetryReporter {
181
214
  this.flushTimer = null;
182
215
  }
183
216
  }
217
+
218
+ /**
219
+ * Persist the current buffer to AsyncStorage so events survive abrupt app exits.
220
+ * Fire-and-forget — never throws or blocks.
221
+ *
222
+ * IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
223
+ * to avoid overwriting orphaned events from a previous session before recovery.
224
+ */
225
+ private persistBuffer(): void {
226
+ if (!this.loadedPersisted) return; // Don't overwrite orphaned prior-session data before it's loaded
227
+ try {
228
+ const entries: Record<string, TelemetryEvent> = {};
229
+ for (const [key, event] of this.buffer) {
230
+ entries[key] = event;
231
+ }
232
+ getStorage().then(s => {
233
+ if (Object.keys(entries).length > 0) {
234
+ s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
235
+ } else {
236
+ s.removeItem(TELEMETRY_PERSIST_KEY);
237
+ }
238
+ }).catch(() => { /* never throw from telemetry */ });
239
+ } catch {
240
+ // Never throw from telemetry
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Load persisted events from a previous session and merge into current buffer.
246
+ * Runs once per instance. If orphaned events are found, emits an
247
+ * `sdk_session_recovered` event so monitoring can flag that the prior
248
+ * session ended without a clean telemetry flush.
249
+ */
250
+ private async loadPersistedBuffer(): Promise<void> {
251
+ if (this.loadedPersisted) return;
252
+ if (this.loadPersistedPromise) {
253
+ await this.loadPersistedPromise;
254
+ return;
255
+ }
256
+
257
+ this.loadPersistedPromise = (async () => {
258
+ try {
259
+ const storage = await getStorage();
260
+ const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
261
+ if (!raw) return;
262
+
263
+ const entries = JSON.parse(raw) as Record<string, TelemetryEvent>;
264
+ let orphanedCount = 0;
265
+ const orphanedTypes: string[] = [];
266
+
267
+ for (const [key, event] of Object.entries(entries)) {
268
+ if (event.session_id === this.sessionId) continue; // Same session, skip
269
+ orphanedCount++;
270
+ orphanedTypes.push(event.event_type);
271
+ // Merge orphaned events into the current buffer
272
+ const existing = this.buffer.get(key);
273
+ if (existing) {
274
+ existing.event_count += event.event_count;
275
+ if (event.first_occurred_at < existing.first_occurred_at) {
276
+ existing.first_occurred_at = event.first_occurred_at;
277
+ }
278
+ if (event.last_occurred_at > existing.last_occurred_at) {
279
+ existing.last_occurred_at = event.last_occurred_at;
280
+ }
281
+ } else {
282
+ this.buffer.set(key, event);
283
+ }
284
+ }
285
+
286
+ // Emit sdk_session_recovered if we recovered orphaned events from a previous session
287
+ if (orphanedCount > 0) {
288
+ const now = new Date().toISOString();
289
+ const uniqueTypes = [...new Set(orphanedTypes)];
290
+ const crashEvent: TelemetryEvent = {
291
+ event_type: 'sdk_session_recovered',
292
+ component: 'TelemetryReporter',
293
+ error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
294
+ sdk_platform: Platform.OS,
295
+ sdk_version: SDK_VERSION,
296
+ os_name: Platform.OS,
297
+ os_version: String(Platform.Version),
298
+ session_id: this.sessionId,
299
+ event_count: 1,
300
+ first_occurred_at: now,
301
+ last_occurred_at: now,
302
+ };
303
+ this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
304
+ }
305
+
306
+ // Clear persisted data now that it's loaded into memory. The merged
307
+ // buffer will be re-persisted below until a flush succeeds.
308
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
309
+ } catch {
310
+ // Never throw from telemetry
311
+ } finally {
312
+ this.loadedPersisted = true;
313
+ this.loadPersistedPromise = null;
314
+ if (this.buffer.size > 0 && !this.disposed) {
315
+ this.persistBuffer();
316
+ this.scheduleFlush();
317
+ }
318
+ }
319
+ })();
320
+
321
+ await this.loadPersistedPromise;
322
+ }
184
323
  }
@@ -14,6 +14,8 @@ export interface VerificationRequest {
14
14
  policy: string;
15
15
  metadata?: Record<string, unknown>;
16
16
  provider?: 'openai' | 'anthropic' | 'gemini';
17
+ /** When true, the response includes the image as a base64 data URI in `image_data`. */
18
+ include_image_data?: boolean;
17
19
  }
18
20
 
19
21
  export interface MultipartVerificationRequest {
@@ -21,6 +23,8 @@ export interface MultipartVerificationRequest {
21
23
  policy: string;
22
24
  metadata?: Record<string, unknown>;
23
25
  provider?: 'openai' | 'anthropic' | 'gemini';
26
+ /** When true, the response includes the image as a base64 data URI in `image_data`. */
27
+ include_image_data?: boolean;
24
28
  }
25
29
 
26
30
  export interface VerificationResult {
@@ -34,6 +38,8 @@ export interface VerificationResult {
34
38
  feedback: string;
35
39
  metadata: Record<string, unknown>;
36
40
  image_url: string | null;
41
+ /** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
42
+ image_data?: string;
37
43
  /** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
38
44
  category?: string;
39
45
  }
@@ -81,6 +87,24 @@ export interface VerifyAIError {
81
87
 
82
88
  export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
83
89
 
90
+ /**
91
+ * Theme customization for the scanner overlay.
92
+ *
93
+ * All properties are optional — unset values fall back to built-in defaults.
94
+ * Pass a `ScannerTheme` to `ScannerOverlayConfig.theme` to match the scanner
95
+ * to your app's brand.
96
+ */
97
+ export interface ScannerTheme {
98
+ /** Color for success states (checkmark circle, success card accent). Default: '#22C55E'. */
99
+ successColor?: string;
100
+ /** Color for failure/error states (warning icon, error card accent). Default: '#F59E0B'. */
101
+ failureColor?: string;
102
+ /** Color for the capture button circle. Default: '#FFFFFF'. */
103
+ captureButtonColor?: string;
104
+ /** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
105
+ cornerColor?: string;
106
+ }
107
+
84
108
  export interface ScannerOverlayConfig {
85
109
  title?: string;
86
110
  instructions?: string;
@@ -110,6 +134,8 @@ export interface ScannerOverlayConfig {
110
134
  maxAttempts?: number;
111
135
  autoApproveOnExhaust?: boolean;
112
136
  showTechnicalErrorDetails?: boolean;
137
+ /** Custom theme for scanner colors. */
138
+ theme?: ScannerTheme;
113
139
  }
114
140
 
115
141
  export interface PolicyConfigResponse {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.3.2';
1
+ export const SDK_VERSION = '2.4.1';