@switchlabs/verify-ai-react-native 1.1.1 → 1.1.2

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.
package/README.md CHANGED
@@ -7,13 +7,13 @@ React Native SDK for Verify AI photo verification.
7
7
  Expo-managed apps:
8
8
 
9
9
  ```bash
10
- npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
10
+ npx expo install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
11
11
  ```
12
12
 
13
13
  React Native CLI:
14
14
 
15
15
  ```bash
16
- npm install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
16
+ npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
17
17
  ```
18
18
 
19
19
  If you want on-device inference, also install `expo-file-system`,
@@ -63,6 +63,10 @@ function ScannerScreen() {
63
63
  }
64
64
  ```
65
65
 
66
+ `verifyMultipart()` is the recommended path for live camera captures. The
67
+ built-in offline queue currently replays base64 `verify()` requests only; raw
68
+ multipart uploads are not persisted automatically.
69
+
66
70
  ## Offline Mode
67
71
 
68
72
  Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
@@ -3,8 +3,8 @@ import { type ViewStyle } from 'react-native';
3
3
  import type { VerificationResult, ScannerOverlayConfig } from '../types';
4
4
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
5
5
  export interface VerifyAIScannerProps {
6
- /** Called with the image URI when the user captures a photo. */
7
- onCapture: (imageUri: string) => Promise<VerificationResult | null>;
6
+ /** Called with base64 image data when the user captures a photo. */
7
+ onCapture: (base64: string) => Promise<VerificationResult | null>;
8
8
  /** Called when a terminal verification result is reached. */
9
9
  onResult?: (result: VerificationResult) => void;
10
10
  /** Called when an error occurs. */
@@ -28,13 +28,10 @@ export interface VerifyAIScannerProps {
28
28
  *
29
29
  * @example
30
30
  * ```tsx
31
- * const { verifyMultipart } = useVerifyAI({
32
- * apiKey: 'vai_...',
33
- * enableOnDeviceML: true,
34
- * });
31
+ * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
35
32
  *
36
33
  * <VerifyAIScanner
37
- * onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
34
+ * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
38
35
  * onResult={(result) => {
39
36
  * if (result.is_compliant) navigation.navigate('Success');
40
37
  * }}
@@ -2,8 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useRef, useState, useCallback, useEffect } from 'react';
3
3
  import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 'react-native';
4
4
  import { CameraView, useCameraPermissions, } from 'expo-camera';
5
- import * as ImageManipulator from 'expo-image-manipulator';
6
5
  import { useTelemetry } from '../telemetry/TelemetryContext';
6
+ /** Quality used when expo-image-manipulator is not available (lower = smaller). */
7
+ const FALLBACK_QUALITY = 0.5;
8
+ /** Quality used when expo-image-manipulator IS available (resize handles size). */
9
+ const MANIPULATOR_QUALITY = 0.7;
10
+ /** Max dimension (px) on longest side when resize is available. */
11
+ const MAX_DIMENSION = 2048;
7
12
  function getErrorDisplay(error, showTechnicalDetails) {
8
13
  if (!error) {
9
14
  return {
@@ -24,6 +29,9 @@ function getErrorDisplay(error, showTechnicalDetails) {
24
29
  else if (status === 401) {
25
30
  message = 'Verification is not configured correctly.';
26
31
  }
32
+ else if (status === 413) {
33
+ message = 'Image is too large. Please try again — the photo will be resized automatically.';
34
+ }
27
35
  else if (status === 429) {
28
36
  message = 'Verification is temporarily unavailable. Please try again.';
29
37
  }
@@ -50,13 +58,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
50
58
  *
51
59
  * @example
52
60
  * ```tsx
53
- * const { verifyMultipart } = useVerifyAI({
54
- * apiKey: 'vai_...',
55
- * enableOnDeviceML: true,
56
- * });
61
+ * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
57
62
  *
58
63
  * <VerifyAIScanner
59
- * onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
64
+ * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
60
65
  * onResult={(result) => {
61
66
  * if (result.is_compliant) navigation.navigate('Success');
62
67
  * }}
@@ -157,19 +162,89 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
157
162
  setResult(null);
158
163
  setLastError(null);
159
164
  try {
160
- const photo = await cameraRef.current.takePictureAsync({
161
- quality: 0.8,
162
- exif: false,
163
- });
164
- if (!photo?.uri) {
165
- throw new Error('Failed to capture photo');
165
+ // --- Capture + best-effort resize ---
166
+ // Strategy: try to dynamically import expo-image-manipulator.
167
+ // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
168
+ // If not available → use expo-camera's built-in base64 at lower quality.
169
+ // This keeps expo-image-manipulator as an *optional* dependency.
170
+ let base64;
171
+ let origWidth = 0;
172
+ let origHeight = 0;
173
+ let processedWidth = 0;
174
+ let processedHeight = 0;
175
+ let didResize = false;
176
+ let ImageManipulator = null;
177
+ try {
178
+ ImageManipulator = await import('expo-image-manipulator');
179
+ }
180
+ catch {
181
+ // Not installed — fall back to camera-only base64 below
182
+ }
183
+ if (ImageManipulator) {
184
+ // Capture without base64 — ImageManipulator will produce it after resize.
185
+ const photo = await cameraRef.current.takePictureAsync({
186
+ quality: 0.8,
187
+ exif: false,
188
+ });
189
+ if (!photo?.uri) {
190
+ throw new Error('Failed to capture photo');
191
+ }
192
+ origWidth = photo.width ?? 0;
193
+ origHeight = photo.height ?? 0;
194
+ const actions = [];
195
+ if (origWidth > MAX_DIMENSION || origHeight > MAX_DIMENSION) {
196
+ if (origWidth >= origHeight) {
197
+ actions.push({ resize: { width: MAX_DIMENSION } });
198
+ }
199
+ else {
200
+ actions.push({ resize: { height: MAX_DIMENSION } });
201
+ }
202
+ didResize = true;
203
+ }
204
+ const normalized = await ImageManipulator.manipulateAsync(photo.uri, actions, {
205
+ compress: MANIPULATOR_QUALITY,
206
+ format: ImageManipulator.SaveFormat.JPEG,
207
+ base64: true,
208
+ });
209
+ if (!normalized.base64) {
210
+ throw new Error('ImageManipulator did not return base64');
211
+ }
212
+ base64 = normalized.base64;
213
+ processedWidth = normalized.width;
214
+ processedHeight = normalized.height;
166
215
  }
167
- // Normalize EXIF orientation into actual pixels so the server
168
- // receives an upright image (Gemini and other vision APIs ignore EXIF).
169
- // An empty actions array makes ImageManipulator apply EXIF rotation only.
170
- const normalized = await ImageManipulator.manipulateAsync(photo.uri, [], { compress: 0.8, format: ImageManipulator.SaveFormat.JPEG });
216
+ else {
217
+ // Fallback: capture base64 directly from the camera at reduced quality.
218
+ // No resize is possible without ImageManipulator, but the lower quality
219
+ // significantly reduces payload size (e.g. 50 MP @ 0.5 3–4 MB base64).
220
+ const photo = await cameraRef.current.takePictureAsync({
221
+ base64: true,
222
+ quality: FALLBACK_QUALITY,
223
+ exif: false,
224
+ });
225
+ if (!photo?.base64) {
226
+ throw new Error('Failed to capture photo');
227
+ }
228
+ origWidth = photo.width ?? 0;
229
+ origHeight = photo.height ?? 0;
230
+ processedWidth = origWidth;
231
+ processedHeight = origHeight;
232
+ base64 = photo.base64;
233
+ }
234
+ // Best-effort telemetry — never blocks capture
235
+ telemetry?.track('image_processed', {
236
+ component: 'scanner',
237
+ metadata: {
238
+ original_width: origWidth,
239
+ original_height: origHeight,
240
+ processed_width: processedWidth,
241
+ processed_height: processedHeight,
242
+ resized: didResize ? 1 : 0,
243
+ has_manipulator: ImageManipulator ? 1 : 0,
244
+ },
245
+ });
171
246
  setStatus('processing');
172
- const verificationResult = await onCapture(normalized.uri);
247
+ const verificationResult = await onCapture(base64);
173
248
  attemptCountRef.current++;
174
249
  if (verificationResult) {
175
250
  const maxAttempts = overlay?.maxAttempts;
@@ -12,6 +12,7 @@ export declare class TelemetryReporter {
12
12
  component?: string;
13
13
  error?: unknown;
14
14
  errorCode?: string;
15
+ metadata?: Record<string, string | number>;
15
16
  }): void;
16
17
  /** Flush all buffered events immediately. Returns a promise but never rejects. */
17
18
  flush(): Promise<void>;
@@ -42,6 +42,7 @@ export class TelemetryReporter {
42
42
  error_message: errorMessage?.slice(0, 1000),
43
43
  error_stack: errorObj?.stack?.slice(0, 2000),
44
44
  error_code: opts.errorCode,
45
+ metadata: opts.metadata,
45
46
  sdk_platform: Platform.OS,
46
47
  sdk_version: SDK_VERSION,
47
48
  os_name: Platform.OS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
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",
@@ -11,7 +11,6 @@ import {
11
11
  CameraView,
12
12
  useCameraPermissions,
13
13
  } from 'expo-camera';
14
- import * as ImageManipulator from 'expo-image-manipulator';
15
14
  import type {
16
15
  VerificationResult,
17
16
  ScannerStatus,
@@ -20,9 +19,16 @@ import type {
20
19
  import { useTelemetry } from '../telemetry/TelemetryContext';
21
20
  import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
22
21
 
22
+ /** Quality used when expo-image-manipulator is not available (lower = smaller). */
23
+ const FALLBACK_QUALITY = 0.5;
24
+ /** Quality used when expo-image-manipulator IS available (resize handles size). */
25
+ const MANIPULATOR_QUALITY = 0.7;
26
+ /** Max dimension (px) on longest side when resize is available. */
27
+ const MAX_DIMENSION = 2048;
28
+
23
29
  export interface VerifyAIScannerProps {
24
- /** Called with the image URI when the user captures a photo. */
25
- onCapture: (imageUri: string) => Promise<VerificationResult | null>;
30
+ /** Called with base64 image data when the user captures a photo. */
31
+ onCapture: (base64: string) => Promise<VerificationResult | null>;
26
32
  /** Called when a terminal verification result is reached. */
27
33
  onResult?: (result: VerificationResult) => void;
28
34
  /** Called when an error occurs. */
@@ -72,6 +78,8 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
72
78
  message = 'Network request failed. Check your connection and try again.';
73
79
  } else if (status === 401) {
74
80
  message = 'Verification is not configured correctly.';
81
+ } else if (status === 413) {
82
+ message = 'Image is too large. Please try again — the photo will be resized automatically.';
75
83
  } else if (status === 429) {
76
84
  message = 'Verification is temporarily unavailable. Please try again.';
77
85
  } else if (status !== undefined && status >= 500) {
@@ -101,13 +109,10 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
101
109
  *
102
110
  * @example
103
111
  * ```tsx
104
- * const { verifyMultipart } = useVerifyAI({
105
- * apiKey: 'vai_...',
106
- * enableOnDeviceML: true,
107
- * });
112
+ * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
108
113
  *
109
114
  * <VerifyAIScanner
110
- * onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
115
+ * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
111
116
  * onResult={(result) => {
112
117
  * if (result.is_compliant) navigation.navigate('Success');
113
118
  * }}
@@ -230,26 +235,102 @@ export function VerifyAIScanner({
230
235
  setLastError(null);
231
236
 
232
237
  try {
233
- const photo = await cameraRef.current.takePictureAsync({
234
- quality: 0.8,
235
- exif: false,
236
- });
238
+ // --- Capture + best-effort resize ---
239
+ // Strategy: try to dynamically import expo-image-manipulator.
240
+ // If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
241
+ // If not available → use expo-camera's built-in base64 at lower quality.
242
+ // This keeps expo-image-manipulator as an *optional* dependency.
243
+ let base64: string;
244
+ let origWidth = 0;
245
+ let origHeight = 0;
246
+ let processedWidth = 0;
247
+ let processedHeight = 0;
248
+ let didResize = false;
249
+
250
+ let ImageManipulator: typeof import('expo-image-manipulator') | null = null;
251
+ try {
252
+ ImageManipulator = await import('expo-image-manipulator');
253
+ } catch {
254
+ // Not installed — fall back to camera-only base64 below
255
+ }
256
+
257
+ if (ImageManipulator) {
258
+ // Capture without base64 — ImageManipulator will produce it after resize.
259
+ const photo = await cameraRef.current.takePictureAsync({
260
+ quality: 0.8,
261
+ exif: false,
262
+ });
263
+
264
+ if (!photo?.uri) {
265
+ throw new Error('Failed to capture photo');
266
+ }
267
+
268
+ origWidth = photo.width ?? 0;
269
+ origHeight = photo.height ?? 0;
270
+
271
+ const actions: Array<{ resize: { width?: number; height?: number } }> = [];
272
+ if (origWidth > MAX_DIMENSION || origHeight > MAX_DIMENSION) {
273
+ if (origWidth >= origHeight) {
274
+ actions.push({ resize: { width: MAX_DIMENSION } });
275
+ } else {
276
+ actions.push({ resize: { height: MAX_DIMENSION } });
277
+ }
278
+ didResize = true;
279
+ }
280
+
281
+ const normalized = await ImageManipulator.manipulateAsync(
282
+ photo.uri,
283
+ actions,
284
+ {
285
+ compress: MANIPULATOR_QUALITY,
286
+ format: ImageManipulator.SaveFormat.JPEG,
287
+ base64: true,
288
+ },
289
+ );
290
+
291
+ if (!normalized.base64) {
292
+ throw new Error('ImageManipulator did not return base64');
293
+ }
294
+
295
+ base64 = normalized.base64;
296
+ processedWidth = normalized.width;
297
+ processedHeight = normalized.height;
298
+ } else {
299
+ // Fallback: capture base64 directly from the camera at reduced quality.
300
+ // No resize is possible without ImageManipulator, but the lower quality
301
+ // significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
302
+ const photo = await cameraRef.current.takePictureAsync({
303
+ base64: true,
304
+ quality: FALLBACK_QUALITY,
305
+ exif: false,
306
+ });
237
307
 
238
- if (!photo?.uri) {
239
- throw new Error('Failed to capture photo');
308
+ if (!photo?.base64) {
309
+ throw new Error('Failed to capture photo');
310
+ }
311
+
312
+ origWidth = photo.width ?? 0;
313
+ origHeight = photo.height ?? 0;
314
+ processedWidth = origWidth;
315
+ processedHeight = origHeight;
316
+ base64 = photo.base64;
240
317
  }
241
318
 
242
- // Normalize EXIF orientation into actual pixels so the server
243
- // receives an upright image (Gemini and other vision APIs ignore EXIF).
244
- // An empty actions array makes ImageManipulator apply EXIF rotation only.
245
- const normalized = await ImageManipulator.manipulateAsync(
246
- photo.uri,
247
- [],
248
- { compress: 0.8, format: ImageManipulator.SaveFormat.JPEG },
249
- );
319
+ // Best-effort telemetry never blocks capture
320
+ telemetry?.track('image_processed', {
321
+ component: 'scanner',
322
+ metadata: {
323
+ original_width: origWidth,
324
+ original_height: origHeight,
325
+ processed_width: processedWidth,
326
+ processed_height: processedHeight,
327
+ resized: didResize ? 1 : 0,
328
+ has_manipulator: ImageManipulator ? 1 : 0,
329
+ },
330
+ });
250
331
 
251
332
  setStatus('processing');
252
- const verificationResult = await onCapture(normalized.uri);
333
+ const verificationResult = await onCapture(base64);
253
334
 
254
335
  attemptCountRef.current++;
255
336
 
@@ -7,6 +7,7 @@ interface TelemetryEvent {
7
7
  error_message?: string;
8
8
  error_stack?: string;
9
9
  error_code?: string;
10
+ metadata?: Record<string, string | number>;
10
11
  sdk_platform: string;
11
12
  sdk_version: string;
12
13
  os_name: string;
@@ -51,6 +52,7 @@ export class TelemetryReporter {
51
52
  component?: string;
52
53
  error?: unknown;
53
54
  errorCode?: string;
55
+ metadata?: Record<string, string | number>;
54
56
  } = {},
55
57
  ): void {
56
58
  if (this.disposed) return;
@@ -80,6 +82,7 @@ export class TelemetryReporter {
80
82
  error_message: errorMessage?.slice(0, 1000),
81
83
  error_stack: errorObj?.stack?.slice(0, 2000),
82
84
  error_code: opts.errorCode,
85
+ metadata: opts.metadata,
83
86
  sdk_platform: Platform.OS,
84
87
  sdk_version: SDK_VERSION,
85
88
  os_name: Platform.OS,