@switchlabs/verify-ai-react-native 1.1.1 → 2.1.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.
package/README.md CHANGED
@@ -4,18 +4,20 @@ React Native SDK for Verify AI photo verification.
4
4
 
5
5
  ## Install
6
6
 
7
- Expo-managed apps:
7
+ Core SDK (client, hooks, types):
8
8
 
9
9
  ```bash
10
- npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
10
+ npm install @switchlabs/verify-ai-react-native
11
11
  ```
12
12
 
13
- React Native CLI:
13
+ With built-in camera scanner:
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
17
17
  ```
18
18
 
19
+ For offline queue support, also install `@react-native-async-storage/async-storage`.
20
+
19
21
  If you want on-device inference, also install `expo-file-system`,
20
22
  `react-native-fast-tflite`, and configure `react-native-fast-tflite` for the
21
23
  delegates you plan to use.
@@ -44,18 +46,18 @@ function ParkingScreen() {
44
46
 
45
47
  ## Scanner Component
46
48
 
49
+ The scanner is exported from a separate subpath to avoid pulling in `expo-camera` for consumers that only need the client.
50
+
47
51
  ```tsx
48
- import { useVerifyAI, VerifyAIScanner } from '@switchlabs/verify-ai-react-native';
52
+ import { useVerifyAI } from '@switchlabs/verify-ai-react-native';
53
+ import { VerifyAIScanner } from '@switchlabs/verify-ai-react-native/scanner';
49
54
 
50
55
  function ScannerScreen() {
51
- const { verifyMultipart } = useVerifyAI({
52
- apiKey: 'vai_your_api_key',
53
- enableOnDeviceML: true,
54
- });
56
+ const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
55
57
  return (
56
58
  <VerifyAIScanner
57
- onCapture={(imageUri) =>
58
- verifyMultipart({ imageUri, policy: 'scooter_parking' })
59
+ onCapture={(base64) =>
60
+ verify({ image: base64, policy: 'scooter_parking' })
59
61
  }
60
62
  onResult={(result) => console.log(result.is_compliant ? 'PASS' : 'FAIL')}
61
63
  />
@@ -65,6 +67,8 @@ function ScannerScreen() {
65
67
 
66
68
  ## Offline Mode
67
69
 
70
+ Requires `@react-native-async-storage/async-storage` to be installed.
71
+
68
72
  Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
69
73
 
70
74
  ```tsx
@@ -38,9 +38,6 @@ export declare class VerifyAIClient {
38
38
  * Submit a photo for AI verification using multipart/form-data.
39
39
  * Streams the image directly from disk — avoids base64 encoding overhead.
40
40
  *
41
- * **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
42
- * an image URI instead of a base64 string.
43
- *
44
41
  * @param request - Multipart request with file URI and policy
45
42
  * @param options - Optional verify options (e.g. idempotency key)
46
43
  * @returns The verification result with compliance status and feedback
@@ -1,6 +1,24 @@
1
1
  import { TelemetryReporter } from '../telemetry/TelemetryReporter';
2
+ import { SDK_VERSION } from '../version';
2
3
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
3
4
  const DEFAULT_TIMEOUT = 30000;
5
+ const VEHICLE_TYPE_LABELS = {
6
+ scooter: 'Scooter',
7
+ 'e-bike': 'E-Bike',
8
+ ebike: 'E-Bike',
9
+ bike: 'Bike',
10
+ moped: 'Moped',
11
+ car: 'Car',
12
+ };
13
+ /** Auto-inject sdkVersion and vehicleTypeLabel into metadata. */
14
+ function enrichMetadata(metadata) {
15
+ const enriched = { ...metadata, sdkVersion: SDK_VERSION };
16
+ const vehicleType = enriched.vehicleType;
17
+ if (typeof vehicleType === 'string' && !enriched.vehicleTypeLabel) {
18
+ enriched.vehicleTypeLabel = VEHICLE_TYPE_LABELS[vehicleType.toLowerCase()] || vehicleType;
19
+ }
20
+ return enriched;
21
+ }
4
22
  export class VerifyAIClient {
5
23
  constructor(config) {
6
24
  if (!config.apiKey) {
@@ -142,19 +160,17 @@ export class VerifyAIClient {
142
160
  if (options?.idempotencyKey) {
143
161
  headers['Idempotency-Key'] = options.idempotencyKey;
144
162
  }
163
+ const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
145
164
  return this.request('/verify', {
146
165
  method: 'POST',
147
166
  headers,
148
- body: JSON.stringify(request),
167
+ body: JSON.stringify(enrichedRequest),
149
168
  });
150
169
  }
151
170
  /**
152
171
  * Submit a photo for AI verification using multipart/form-data.
153
172
  * Streams the image directly from disk — avoids base64 encoding overhead.
154
173
  *
155
- * **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
156
- * an image URI instead of a base64 string.
157
- *
158
174
  * @param request - Multipart request with file URI and policy
159
175
  * @param options - Optional verify options (e.g. idempotency key)
160
176
  * @returns The verification result with compliance status and feedback
@@ -168,9 +184,7 @@ export class VerifyAIClient {
168
184
  name: 'photo.jpg',
169
185
  });
170
186
  formData.append('policy', request.policy);
171
- if (request.metadata) {
172
- formData.append('metadata', JSON.stringify(request.metadata));
173
- }
187
+ formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
174
188
  if (request.provider) {
175
189
  formData.append('provider', request.provider);
176
190
  }
@@ -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;
package/lib/index.d.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  export { VerifyAIClient, VerifyAIRequestError } from './client';
2
2
  export { useVerifyAI } from './hooks/useVerifyAI';
3
3
  export type { UseVerifyAIReturn, UseVerifyAIConfig } from './hooks/useVerifyAI';
4
- export { VerifyAIScanner } from './components/VerifyAIScanner';
5
- export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
6
4
  export { TelemetryReporter } from './telemetry/TelemetryReporter';
7
5
  export { TelemetryContext } from './telemetry/TelemetryContext';
8
6
  export { OfflineQueue } from './storage/offlineQueue';
package/lib/index.js CHANGED
@@ -2,8 +2,6 @@
2
2
  export { VerifyAIClient, VerifyAIRequestError } from './client';
3
3
  // Hooks
4
4
  export { useVerifyAI } from './hooks/useVerifyAI';
5
- // Components
6
- export { VerifyAIScanner } from './components/VerifyAIScanner';
7
5
  // Telemetry
8
6
  export { TelemetryReporter } from './telemetry/TelemetryReporter';
9
7
  export { TelemetryContext } from './telemetry/TelemetryContext';
@@ -0,0 +1,2 @@
1
+ export { VerifyAIScanner } from './components/VerifyAIScanner';
2
+ export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
package/lib/scanner.js ADDED
@@ -0,0 +1 @@
1
+ export { VerifyAIScanner } from './components/VerifyAIScanner';
@@ -1,5 +1,12 @@
1
- import AsyncStorage from '@react-native-async-storage/async-storage';
2
1
  import { VerifyAIRequestError } from '../client';
2
+ let _storage = null;
3
+ async function getStorage() {
4
+ if (!_storage) {
5
+ const mod = await import('@react-native-async-storage/async-storage');
6
+ _storage = mod.default;
7
+ }
8
+ return _storage;
9
+ }
3
10
  const MANIFEST_KEY = '@verifyai/queue_manifest';
4
11
  const ITEM_PREFIX = '@verifyai/queue_item_';
5
12
  const LEGACY_KEY = '@verifyai/offline_queue';
@@ -17,13 +24,13 @@ export class OfflineQueue {
17
24
  if (this.migrated)
18
25
  return;
19
26
  this.migrated = true;
20
- const legacy = await AsyncStorage.getItem(LEGACY_KEY);
27
+ const legacy = await (await getStorage()).getItem(LEGACY_KEY);
21
28
  if (!legacy)
22
29
  return;
23
30
  try {
24
31
  const items = JSON.parse(legacy);
25
32
  if (!Array.isArray(items) || items.length === 0) {
26
- await AsyncStorage.removeItem(LEGACY_KEY);
33
+ await (await getStorage()).removeItem(LEGACY_KEY);
27
34
  return;
28
35
  }
29
36
  const ids = [];
@@ -32,24 +39,25 @@ export class OfflineQueue {
32
39
  ids.push(item.id);
33
40
  pairs.push([`${ITEM_PREFIX}${item.id}`, JSON.stringify(item)]);
34
41
  }
35
- await AsyncStorage.multiSet([
42
+ const storage = await getStorage();
43
+ await storage.multiSet([
36
44
  [MANIFEST_KEY, JSON.stringify(ids)],
37
45
  ...pairs,
38
46
  ]);
39
- await AsyncStorage.removeItem(LEGACY_KEY);
47
+ await storage.removeItem(LEGACY_KEY);
40
48
  }
41
49
  catch {
42
50
  // If migration fails, remove corrupt legacy data
43
- await AsyncStorage.removeItem(LEGACY_KEY);
51
+ await (await getStorage()).removeItem(LEGACY_KEY);
44
52
  }
45
53
  }
46
54
  async getManifest() {
47
55
  await this.migrateIfNeeded();
48
- const raw = await AsyncStorage.getItem(MANIFEST_KEY);
56
+ const raw = await (await getStorage()).getItem(MANIFEST_KEY);
49
57
  return raw ? JSON.parse(raw) : [];
50
58
  }
51
59
  async setManifest(ids) {
52
- await AsyncStorage.setItem(MANIFEST_KEY, JSON.stringify(ids));
60
+ await (await getStorage()).setItem(MANIFEST_KEY, JSON.stringify(ids));
53
61
  }
54
62
  /**
55
63
  * Add a verification request to the offline queue.
@@ -64,7 +72,7 @@ export class OfflineQueue {
64
72
  };
65
73
  const ids = await this.getManifest();
66
74
  ids.push(item.id);
67
- await AsyncStorage.setItem(`${ITEM_PREFIX}${item.id}`, JSON.stringify(item));
75
+ await (await getStorage()).setItem(`${ITEM_PREFIX}${item.id}`, JSON.stringify(item));
68
76
  await this.setManifest(ids);
69
77
  return item.id;
70
78
  }
@@ -76,7 +84,7 @@ export class OfflineQueue {
76
84
  if (ids.length === 0)
77
85
  return [];
78
86
  const keys = ids.map((id) => `${ITEM_PREFIX}${id}`);
79
- const pairs = await AsyncStorage.multiGet(keys);
87
+ const pairs = await (await getStorage()).multiGet(keys);
80
88
  const items = [];
81
89
  for (const [, value] of pairs) {
82
90
  if (value) {
@@ -104,7 +112,7 @@ export class OfflineQueue {
104
112
  const ids = await this.getManifest();
105
113
  const filtered = ids.filter((i) => i !== id);
106
114
  await this.setManifest(filtered);
107
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
115
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
108
116
  }
109
117
  /**
110
118
  * Clear all items from the queue.
@@ -113,10 +121,10 @@ export class OfflineQueue {
113
121
  const ids = await this.getManifest();
114
122
  if (ids.length > 0) {
115
123
  const keys = ids.map((id) => `${ITEM_PREFIX}${id}`);
116
- await AsyncStorage.multiRemove([MANIFEST_KEY, ...keys]);
124
+ await (await getStorage()).multiRemove([MANIFEST_KEY, ...keys]);
117
125
  }
118
126
  else {
119
- await AsyncStorage.removeItem(MANIFEST_KEY);
127
+ await (await getStorage()).removeItem(MANIFEST_KEY);
120
128
  }
121
129
  }
122
130
  /**
@@ -140,7 +148,7 @@ export class OfflineQueue {
140
148
  try {
141
149
  const ids = await this.getManifest();
142
150
  for (const id of ids) {
143
- const raw = await AsyncStorage.getItem(`${ITEM_PREFIX}${id}`);
151
+ const raw = await (await getStorage()).getItem(`${ITEM_PREFIX}${id}`);
144
152
  if (!raw)
145
153
  continue;
146
154
  let item;
@@ -149,13 +157,13 @@ export class OfflineQueue {
149
157
  }
150
158
  catch {
151
159
  // Remove corrupt item
152
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
160
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
153
161
  continue;
154
162
  }
155
163
  try {
156
164
  const result = await this.client.verify(item.request, { idempotencyKey: item.id });
157
165
  processed++;
158
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
166
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
159
167
  onResult?.(item.id, result);
160
168
  }
161
169
  catch (err) {
@@ -163,12 +171,12 @@ export class OfflineQueue {
163
171
  const shouldRetry = !requestError || requestError.isRetryable;
164
172
  item.retryCount++;
165
173
  if (shouldRetry && item.retryCount < maxRetries) {
166
- await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
174
+ await (await getStorage()).setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
167
175
  remainingIds.push(id);
168
176
  }
169
177
  else {
170
178
  failed++;
171
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
179
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
172
180
  }
173
181
  }
174
182
  }
@@ -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/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.1.1";
1
+ export declare const SDK_VERSION = "2.1.0";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '1.1.1';
1
+ export const SDK_VERSION = '2.1.0';
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "1.1.1",
3
+ "version": "2.1.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",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.ts",
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./scanner": {
13
+ "types": "./lib/scanner.d.ts",
14
+ "default": "./lib/scanner.js"
15
+ }
16
+ },
7
17
  "files": [
8
18
  "src",
9
19
  "lib"
@@ -10,10 +10,30 @@ import type {
10
10
  PolicyConfigResponse,
11
11
  } from '../types';
12
12
  import { TelemetryReporter } from '../telemetry/TelemetryReporter';
13
+ import { SDK_VERSION } from '../version';
13
14
 
14
15
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
15
16
  const DEFAULT_TIMEOUT = 30000;
16
17
 
18
+ const VEHICLE_TYPE_LABELS: Record<string, string> = {
19
+ scooter: 'Scooter',
20
+ 'e-bike': 'E-Bike',
21
+ ebike: 'E-Bike',
22
+ bike: 'Bike',
23
+ moped: 'Moped',
24
+ car: 'Car',
25
+ };
26
+
27
+ /** Auto-inject sdkVersion and vehicleTypeLabel into metadata. */
28
+ function enrichMetadata(metadata?: Record<string, unknown>): Record<string, unknown> {
29
+ const enriched: Record<string, unknown> = { ...metadata, sdkVersion: SDK_VERSION };
30
+ const vehicleType = enriched.vehicleType;
31
+ if (typeof vehicleType === 'string' && !enriched.vehicleTypeLabel) {
32
+ enriched.vehicleTypeLabel = VEHICLE_TYPE_LABELS[vehicleType.toLowerCase()] || vehicleType;
33
+ }
34
+ return enriched;
35
+ }
36
+
17
37
  interface RequestContext {
18
38
  path: string;
19
39
  url: string;
@@ -204,10 +224,11 @@ export class VerifyAIClient {
204
224
  if (options?.idempotencyKey) {
205
225
  headers['Idempotency-Key'] = options.idempotencyKey;
206
226
  }
227
+ const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
207
228
  return this.request<VerificationResult>('/verify', {
208
229
  method: 'POST',
209
230
  headers,
210
- body: JSON.stringify(request),
231
+ body: JSON.stringify(enrichedRequest),
211
232
  });
212
233
  }
213
234
 
@@ -215,9 +236,6 @@ export class VerifyAIClient {
215
236
  * Submit a photo for AI verification using multipart/form-data.
216
237
  * Streams the image directly from disk — avoids base64 encoding overhead.
217
238
  *
218
- * **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
219
- * an image URI instead of a base64 string.
220
- *
221
239
  * @param request - Multipart request with file URI and policy
222
240
  * @param options - Optional verify options (e.g. idempotency key)
223
241
  * @returns The verification result with compliance status and feedback
@@ -231,9 +249,7 @@ export class VerifyAIClient {
231
249
  name: 'photo.jpg',
232
250
  } as unknown as Blob);
233
251
  formData.append('policy', request.policy);
234
- if (request.metadata) {
235
- formData.append('metadata', JSON.stringify(request.metadata));
236
- }
252
+ formData.append('metadata', JSON.stringify(enrichMetadata(request.metadata)));
237
253
  if (request.provider) {
238
254
  formData.append('provider', request.provider);
239
255
  }
@@ -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
 
package/src/index.ts CHANGED
@@ -5,10 +5,6 @@ export { VerifyAIClient, VerifyAIRequestError } from './client';
5
5
  export { useVerifyAI } from './hooks/useVerifyAI';
6
6
  export type { UseVerifyAIReturn, UseVerifyAIConfig } from './hooks/useVerifyAI';
7
7
 
8
- // Components
9
- export { VerifyAIScanner } from './components/VerifyAIScanner';
10
- export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
11
-
12
8
  // Telemetry
13
9
  export { TelemetryReporter } from './telemetry/TelemetryReporter';
14
10
  export { TelemetryContext } from './telemetry/TelemetryContext';
package/src/scanner.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { VerifyAIScanner } from './components/VerifyAIScanner';
2
+ export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
@@ -1,7 +1,15 @@
1
- import AsyncStorage from '@react-native-async-storage/async-storage';
2
1
  import type { VerificationRequest, VerificationResult, QueueItem } from '../types';
3
2
  import { VerifyAIClient, VerifyAIRequestError } from '../client';
4
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
+
5
13
  const MANIFEST_KEY = '@verifyai/queue_manifest';
6
14
  const ITEM_PREFIX = '@verifyai/queue_item_';
7
15
  const LEGACY_KEY = '@verifyai/offline_queue';
@@ -23,13 +31,13 @@ export class OfflineQueue {
23
31
  if (this.migrated) return;
24
32
  this.migrated = true;
25
33
 
26
- const legacy = await AsyncStorage.getItem(LEGACY_KEY);
34
+ const legacy = await (await getStorage()).getItem(LEGACY_KEY);
27
35
  if (!legacy) return;
28
36
 
29
37
  try {
30
38
  const items: QueueItem[] = JSON.parse(legacy);
31
39
  if (!Array.isArray(items) || items.length === 0) {
32
- await AsyncStorage.removeItem(LEGACY_KEY);
40
+ await (await getStorage()).removeItem(LEGACY_KEY);
33
41
  return;
34
42
  }
35
43
 
@@ -40,25 +48,26 @@ export class OfflineQueue {
40
48
  pairs.push([`${ITEM_PREFIX}${item.id}`, JSON.stringify(item)]);
41
49
  }
42
50
 
43
- await AsyncStorage.multiSet([
51
+ const storage = await getStorage();
52
+ await storage.multiSet([
44
53
  [MANIFEST_KEY, JSON.stringify(ids)],
45
54
  ...pairs,
46
55
  ]);
47
- await AsyncStorage.removeItem(LEGACY_KEY);
56
+ await storage.removeItem(LEGACY_KEY);
48
57
  } catch {
49
58
  // If migration fails, remove corrupt legacy data
50
- await AsyncStorage.removeItem(LEGACY_KEY);
59
+ await (await getStorage()).removeItem(LEGACY_KEY);
51
60
  }
52
61
  }
53
62
 
54
63
  private async getManifest(): Promise<string[]> {
55
64
  await this.migrateIfNeeded();
56
- const raw = await AsyncStorage.getItem(MANIFEST_KEY);
65
+ const raw = await (await getStorage()).getItem(MANIFEST_KEY);
57
66
  return raw ? JSON.parse(raw) : [];
58
67
  }
59
68
 
60
69
  private async setManifest(ids: string[]): Promise<void> {
61
- await AsyncStorage.setItem(MANIFEST_KEY, JSON.stringify(ids));
70
+ await (await getStorage()).setItem(MANIFEST_KEY, JSON.stringify(ids));
62
71
  }
63
72
 
64
73
  /**
@@ -76,7 +85,7 @@ export class OfflineQueue {
76
85
  const ids = await this.getManifest();
77
86
  ids.push(item.id);
78
87
 
79
- await AsyncStorage.setItem(`${ITEM_PREFIX}${item.id}`, JSON.stringify(item));
88
+ await (await getStorage()).setItem(`${ITEM_PREFIX}${item.id}`, JSON.stringify(item));
80
89
  await this.setManifest(ids);
81
90
  return item.id;
82
91
  }
@@ -89,7 +98,7 @@ export class OfflineQueue {
89
98
  if (ids.length === 0) return [];
90
99
 
91
100
  const keys = ids.map((id) => `${ITEM_PREFIX}${id}`);
92
- const pairs = await AsyncStorage.multiGet(keys);
101
+ const pairs = await (await getStorage()).multiGet(keys);
93
102
 
94
103
  const items: QueueItem[] = [];
95
104
  for (const [, value] of pairs) {
@@ -119,7 +128,7 @@ export class OfflineQueue {
119
128
  const ids = await this.getManifest();
120
129
  const filtered = ids.filter((i) => i !== id);
121
130
  await this.setManifest(filtered);
122
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
131
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
123
132
  }
124
133
 
125
134
  /**
@@ -129,9 +138,9 @@ export class OfflineQueue {
129
138
  const ids = await this.getManifest();
130
139
  if (ids.length > 0) {
131
140
  const keys = ids.map((id) => `${ITEM_PREFIX}${id}`);
132
- await AsyncStorage.multiRemove([MANIFEST_KEY, ...keys]);
141
+ await (await getStorage()).multiRemove([MANIFEST_KEY, ...keys]);
133
142
  } else {
134
- await AsyncStorage.removeItem(MANIFEST_KEY);
143
+ await (await getStorage()).removeItem(MANIFEST_KEY);
135
144
  }
136
145
  }
137
146
 
@@ -162,7 +171,7 @@ export class OfflineQueue {
162
171
  const ids = await this.getManifest();
163
172
 
164
173
  for (const id of ids) {
165
- const raw = await AsyncStorage.getItem(`${ITEM_PREFIX}${id}`);
174
+ const raw = await (await getStorage()).getItem(`${ITEM_PREFIX}${id}`);
166
175
  if (!raw) continue;
167
176
 
168
177
  let item: QueueItem;
@@ -170,14 +179,14 @@ export class OfflineQueue {
170
179
  item = JSON.parse(raw);
171
180
  } catch {
172
181
  // Remove corrupt item
173
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
182
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
174
183
  continue;
175
184
  }
176
185
 
177
186
  try {
178
187
  const result = await this.client.verify(item.request, { idempotencyKey: item.id });
179
188
  processed++;
180
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
189
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
181
190
  onResult?.(item.id, result);
182
191
  } catch (err) {
183
192
  const requestError = err instanceof VerifyAIRequestError ? err : null;
@@ -185,11 +194,11 @@ export class OfflineQueue {
185
194
 
186
195
  item.retryCount++;
187
196
  if (shouldRetry && item.retryCount < maxRetries) {
188
- await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
197
+ await (await getStorage()).setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
189
198
  remainingIds.push(id);
190
199
  } else {
191
200
  failed++;
192
- await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
201
+ await (await getStorage()).removeItem(`${ITEM_PREFIX}${id}`);
193
202
  }
194
203
  }
195
204
  }
@@ -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,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '1.1.1';
1
+ export const SDK_VERSION = '2.1.0';