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

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.
@@ -188,6 +188,9 @@ export class VerifyAIClient {
188
188
  if (request.provider) {
189
189
  formData.append('provider', request.provider);
190
190
  }
191
+ if (request.include_image_data) {
192
+ formData.append('include_image_data', 'true');
193
+ }
191
194
  const headers = {
192
195
  'X-API-Key': this.apiKey,
193
196
  // Do NOT set Content-Type — fetch auto-sets multipart boundary
@@ -217,6 +220,7 @@ export class VerifyAIClient {
217
220
  policy: request.policy,
218
221
  metadata: request.metadata,
219
222
  provider: request.provider,
223
+ include_image_data: request.include_image_data,
220
224
  });
221
225
  }
222
226
  catch {
@@ -4,11 +4,11 @@ import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 're
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,17 @@ 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
+ const pausePreview = useCallback(() => {
93
+ cameraRef.current?.pausePreview?.().catch(() => { });
94
+ }, []);
92
95
  // Release camera (and torch) when a terminal result is reached or on unmount.
93
96
  const releaseCamera = useCallback(() => {
94
97
  setTerminated(true);
95
- cameraRef.current?.pausePreview?.().catch(() => { });
96
- }, []);
98
+ pausePreview();
99
+ }, [pausePreview]);
97
100
  useEffect(() => {
98
- return () => {
99
- cameraRef.current?.pausePreview?.().catch(() => { });
100
- };
101
- }, []);
101
+ return pausePreview;
102
+ }, [pausePreview]);
102
103
  // Camera init callbacks
103
104
  const onCameraReady = useCallback(() => {
104
105
  setCameraReady(true);
@@ -325,12 +326,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
325
326
  overlay.guideFrameAspectRatio
326
327
  ? { aspectRatio: overlay.guideFrameAspectRatio }
327
328
  : 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: [
329
+ ], 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
330
  styles.resultIconCircle,
330
331
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
332
+ result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
333
+ !result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
331
334
  ], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
332
335
  styles.resultLabel,
333
336
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
337
+ result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
338
+ !result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
334
339
  ], children: result.is_compliant
335
340
  ? (overlay?.successMessage || 'Verified')
336
341
  : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
@@ -358,12 +363,13 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
358
363
  errorTitle = display.title;
359
364
  errorMessage = display.message;
360
365
  }
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 })] }));
366
+ 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
367
  })(), !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
368
  styles.captureButton,
369
+ overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
364
370
  (!cameraReady || status === 'capturing' || status === 'processing') &&
365
371
  styles.captureButtonDisabled,
366
- ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }, cameraKey) }));
372
+ ], 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
373
  }
368
374
  const CORNER_SIZE = 30;
369
375
  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,18 @@ 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 app crashes.
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 crashed previous session.
32
+ */
33
+ private persistBuffer;
34
+ /**
35
+ * Load crash-persisted events from a previous session and merge into current buffer.
36
+ * Runs once per instance. If orphaned events are found, emits an `sdk_crash` event
37
+ * so the monitor can detect and investigate app crashes.
38
+ */
39
+ private loadPersistedBuffer;
24
40
  }
@@ -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 crash-persisted events from the 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 crash 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 crash-persisted events from 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,110 @@ export class TelemetryReporter {
138
167
  this.flushTimer = null;
139
168
  }
140
169
  }
170
+ /**
171
+ * Persist the current buffer to AsyncStorage so events survive app crashes.
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 crashed previous session.
176
+ */
177
+ persistBuffer() {
178
+ if (!this.loadedPersisted)
179
+ return; // Don't overwrite orphaned crash 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 crash-persisted events from a previous session and merge into current buffer.
200
+ * Runs once per instance. If orphaned events are found, emits an `sdk_crash` event
201
+ * so the monitor can detect and investigate app crashes.
202
+ */
203
+ async loadPersistedBuffer() {
204
+ if (this.loadedPersisted)
205
+ return;
206
+ if (this.loadPersistedPromise) {
207
+ await this.loadPersistedPromise;
208
+ return;
209
+ }
210
+ this.loadPersistedPromise = (async () => {
211
+ try {
212
+ const storage = await getStorage();
213
+ const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
214
+ if (!raw)
215
+ return;
216
+ const entries = JSON.parse(raw);
217
+ let orphanedCount = 0;
218
+ const orphanedTypes = [];
219
+ for (const [key, event] of Object.entries(entries)) {
220
+ if (event.session_id === this.sessionId)
221
+ continue; // Same session, skip
222
+ orphanedCount++;
223
+ orphanedTypes.push(event.event_type);
224
+ // Merge orphaned events into the current buffer
225
+ const existing = this.buffer.get(key);
226
+ if (existing) {
227
+ existing.event_count += event.event_count;
228
+ if (event.first_occurred_at < existing.first_occurred_at) {
229
+ existing.first_occurred_at = event.first_occurred_at;
230
+ }
231
+ if (event.last_occurred_at > existing.last_occurred_at) {
232
+ existing.last_occurred_at = event.last_occurred_at;
233
+ }
234
+ }
235
+ else {
236
+ this.buffer.set(key, event);
237
+ }
238
+ }
239
+ // Emit sdk_crash event if we recovered orphaned events from a dead session
240
+ if (orphanedCount > 0) {
241
+ const now = new Date().toISOString();
242
+ const uniqueTypes = [...new Set(orphanedTypes)];
243
+ const crashEvent = {
244
+ event_type: 'sdk_crash',
245
+ component: 'TelemetryReporter',
246
+ error_message: `Recovered ${orphanedCount} unflushed event(s) from crashed/killed session: ${uniqueTypes.join(', ')}`,
247
+ sdk_platform: Platform.OS,
248
+ sdk_version: SDK_VERSION,
249
+ os_name: Platform.OS,
250
+ os_version: String(Platform.Version),
251
+ session_id: this.sessionId,
252
+ event_count: 1,
253
+ first_occurred_at: now,
254
+ last_occurred_at: now,
255
+ };
256
+ this.buffer.set(`sdk_crash|recovered|${this.sessionId}`, crashEvent);
257
+ }
258
+ // Clear persisted data now that it's loaded into memory. The merged
259
+ // buffer will be re-persisted below until a flush succeeds.
260
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
261
+ }
262
+ catch {
263
+ // Never throw from telemetry
264
+ }
265
+ finally {
266
+ this.loadedPersisted = true;
267
+ this.loadPersistedPromise = null;
268
+ if (this.buffer.size > 0 && !this.disposed) {
269
+ this.persistBuffer();
270
+ this.scheduleFlush();
271
+ }
272
+ }
273
+ })();
274
+ await this.loadPersistedPromise;
275
+ }
141
276
  }
@@ -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.0";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.3.2';
1
+ export const SDK_VERSION = '2.4.0';
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.0",
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
  }
@@ -253,6 +253,9 @@ export class VerifyAIClient {
253
253
  if (request.provider) {
254
254
  formData.append('provider', request.provider);
255
255
  }
256
+ if (request.include_image_data) {
257
+ formData.append('include_image_data', 'true');
258
+ }
256
259
 
257
260
  const headers: Record<string, string> = {
258
261
  'X-API-Key': this.apiKey,
@@ -283,6 +286,7 @@ export class VerifyAIClient {
283
286
  policy: request.policy,
284
287
  metadata: request.metadata,
285
288
  provider: request.provider,
289
+ include_image_data: request.include_image_data,
286
290
  });
287
291
  } catch {
288
292
  // If the retry itself fails, throw the original error
@@ -20,11 +20,11 @@ import { useTelemetry } from '../telemetry/TelemetryContext';
20
20
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
21
21
 
22
22
  /** Quality used when expo-image-manipulator is not available (lower = smaller). */
23
- const FALLBACK_QUALITY = 0.5;
23
+ const FALLBACK_QUALITY = 0.65;
24
24
  /** Quality used when expo-image-manipulator IS available (resize handles size). */
25
- const MANIPULATOR_QUALITY = 0.7;
25
+ const MANIPULATOR_QUALITY = 0.8;
26
26
  /** Max dimension (px) on longest side when resize is available. */
27
- const MAX_DIMENSION = 2048;
27
+ const MAX_DIMENSION = 1600;
28
28
 
29
29
  export interface VerifyAIScannerProps {
30
30
  /** Called with base64 image data when the user captures a photo. */
@@ -152,17 +152,19 @@ export function VerifyAIScanner({
152
152
  const cameraInitFailedRef = useRef(false);
153
153
  const permissionDeniedTrackedRef = useRef(false);
154
154
 
155
+ const pausePreview = useCallback(() => {
156
+ cameraRef.current?.pausePreview?.().catch(() => {});
157
+ }, []);
158
+
155
159
  // Release camera (and torch) when a terminal result is reached or on unmount.
156
160
  const releaseCamera = useCallback(() => {
157
161
  setTerminated(true);
158
- cameraRef.current?.pausePreview?.().catch(() => {});
159
- }, []);
162
+ pausePreview();
163
+ }, [pausePreview]);
160
164
 
161
165
  useEffect(() => {
162
- return () => {
163
- cameraRef.current?.pausePreview?.().catch(() => {});
164
- };
165
- }, []);
166
+ return pausePreview;
167
+ }, [pausePreview]);
166
168
 
167
169
  // Camera init callbacks
168
170
  const onCameraReady = useCallback(() => {
@@ -453,10 +455,10 @@ export function VerifyAIScanner({
453
455
  </View>
454
456
  )}
455
457
  {/* 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]} />
458
+ <View style={[styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
459
+ <View style={[styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
460
+ <View style={[styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
461
+ <View style={[styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
460
462
  </View>
461
463
  {overlay.guideCaption && (
462
464
  <Text style={styles.guideCaptionText}>{overlay.guideCaption}</Text>
@@ -486,6 +488,8 @@ export function VerifyAIScanner({
486
488
  style={[
487
489
  styles.resultIconCircle,
488
490
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
491
+ result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
492
+ !result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
489
493
  ]}
490
494
  >
491
495
  <Text style={styles.resultIcon}>
@@ -496,6 +500,8 @@ export function VerifyAIScanner({
496
500
  style={[
497
501
  styles.resultLabel,
498
502
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
503
+ result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
504
+ !result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
499
505
  ]}
500
506
  >
501
507
  {result.is_compliant
@@ -534,10 +540,10 @@ export function VerifyAIScanner({
534
540
  return (
535
541
  <View style={styles.resultCard}>
536
542
  <View style={styles.resultCardHeader}>
537
- <View style={[styles.resultIconCircle, styles.resultIconError]}>
543
+ <View style={[styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined]}>
538
544
  <Text style={styles.resultIcon}>!</Text>
539
545
  </View>
540
- <Text style={[styles.resultLabel, styles.resultLabelError]}>
546
+ <Text style={[styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined]}>
541
547
  {errorTitle}
542
548
  </Text>
543
549
  </View>
@@ -558,6 +564,7 @@ export function VerifyAIScanner({
558
564
  <TouchableOpacity
559
565
  style={[
560
566
  styles.captureButton,
567
+ overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
561
568
  (!cameraReady || status === 'capturing' || status === 'processing') &&
562
569
  styles.captureButtonDisabled,
563
570
  ]}
@@ -565,7 +572,7 @@ export function VerifyAIScanner({
565
572
  disabled={!cameraReady || status === 'capturing' || status === 'processing'}
566
573
  activeOpacity={0.7}
567
574
  >
568
- <View style={styles.captureButtonInner} />
575
+ <View style={[styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined]} />
569
576
  </TouchableOpacity>
570
577
  </View>
571
578
  )}
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 crash-persisted events from the 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 crash 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 crash-persisted events from 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,109 @@ export class TelemetryReporter {
181
214
  this.flushTimer = null;
182
215
  }
183
216
  }
217
+
218
+ /**
219
+ * Persist the current buffer to AsyncStorage so events survive app crashes.
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 crashed previous session.
224
+ */
225
+ private persistBuffer(): void {
226
+ if (!this.loadedPersisted) return; // Don't overwrite orphaned crash 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 crash-persisted events from a previous session and merge into current buffer.
246
+ * Runs once per instance. If orphaned events are found, emits an `sdk_crash` event
247
+ * so the monitor can detect and investigate app crashes.
248
+ */
249
+ private async loadPersistedBuffer(): Promise<void> {
250
+ if (this.loadedPersisted) return;
251
+ if (this.loadPersistedPromise) {
252
+ await this.loadPersistedPromise;
253
+ return;
254
+ }
255
+
256
+ this.loadPersistedPromise = (async () => {
257
+ try {
258
+ const storage = await getStorage();
259
+ const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
260
+ if (!raw) return;
261
+
262
+ const entries = JSON.parse(raw) as Record<string, TelemetryEvent>;
263
+ let orphanedCount = 0;
264
+ const orphanedTypes: string[] = [];
265
+
266
+ for (const [key, event] of Object.entries(entries)) {
267
+ if (event.session_id === this.sessionId) continue; // Same session, skip
268
+ orphanedCount++;
269
+ orphanedTypes.push(event.event_type);
270
+ // Merge orphaned events into the current buffer
271
+ const existing = this.buffer.get(key);
272
+ if (existing) {
273
+ existing.event_count += event.event_count;
274
+ if (event.first_occurred_at < existing.first_occurred_at) {
275
+ existing.first_occurred_at = event.first_occurred_at;
276
+ }
277
+ if (event.last_occurred_at > existing.last_occurred_at) {
278
+ existing.last_occurred_at = event.last_occurred_at;
279
+ }
280
+ } else {
281
+ this.buffer.set(key, event);
282
+ }
283
+ }
284
+
285
+ // Emit sdk_crash event if we recovered orphaned events from a dead session
286
+ if (orphanedCount > 0) {
287
+ const now = new Date().toISOString();
288
+ const uniqueTypes = [...new Set(orphanedTypes)];
289
+ const crashEvent: TelemetryEvent = {
290
+ event_type: 'sdk_crash',
291
+ component: 'TelemetryReporter',
292
+ error_message: `Recovered ${orphanedCount} unflushed event(s) from crashed/killed session: ${uniqueTypes.join(', ')}`,
293
+ sdk_platform: Platform.OS,
294
+ sdk_version: SDK_VERSION,
295
+ os_name: Platform.OS,
296
+ os_version: String(Platform.Version),
297
+ session_id: this.sessionId,
298
+ event_count: 1,
299
+ first_occurred_at: now,
300
+ last_occurred_at: now,
301
+ };
302
+ this.buffer.set(`sdk_crash|recovered|${this.sessionId}`, crashEvent);
303
+ }
304
+
305
+ // Clear persisted data now that it's loaded into memory. The merged
306
+ // buffer will be re-persisted below until a flush succeeds.
307
+ await storage.removeItem(TELEMETRY_PERSIST_KEY);
308
+ } catch {
309
+ // Never throw from telemetry
310
+ } finally {
311
+ this.loadedPersisted = true;
312
+ this.loadPersistedPromise = null;
313
+ if (this.buffer.size > 0 && !this.disposed) {
314
+ this.persistBuffer();
315
+ this.scheduleFlush();
316
+ }
317
+ }
318
+ })();
319
+
320
+ await this.loadPersistedPromise;
321
+ }
184
322
  }
@@ -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.0';