@switchlabs/verify-ai-react-native 1.1.2 → 2.3.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 expo-image-manipulator @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 expo-image-manipulator @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
  />
@@ -63,12 +65,10 @@ function ScannerScreen() {
63
65
  }
64
66
  ```
65
67
 
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
-
70
68
  ## Offline Mode
71
69
 
70
+ Requires `@react-native-async-storage/async-storage` to be installed.
71
+
72
72
  Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
73
73
 
74
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
  }
@@ -181,11 +195,37 @@ export class VerifyAIClient {
181
195
  if (options?.idempotencyKey) {
182
196
  headers['Idempotency-Key'] = options.idempotencyKey;
183
197
  }
184
- return this.executeRequest('/verify', {
185
- method: 'POST',
186
- headers,
187
- body: formData,
188
- });
198
+ try {
199
+ return await this.executeRequest('/verify', {
200
+ method: 'POST',
201
+ headers,
202
+ body: formData,
203
+ });
204
+ }
205
+ catch (error) {
206
+ // Blank 500 means the server crashed during multipart parsing (e.g.
207
+ // Vercel function crash). Retry as JSON/base64 with a fresh request
208
+ // — the crashed request may have partially recorded the idempotency key.
209
+ if (error instanceof VerifyAIRequestError && error.status >= 500 && error.isServerError) {
210
+ try {
211
+ const { Buffer } = await import('buffer');
212
+ const fileResponse = await fetch(request.imageUri);
213
+ const arrayBuffer = await fileResponse.arrayBuffer();
214
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
215
+ return this.verify({
216
+ image: base64,
217
+ policy: request.policy,
218
+ metadata: request.metadata,
219
+ provider: request.provider,
220
+ });
221
+ }
222
+ catch {
223
+ // If the retry itself fails, throw the original error
224
+ throw error;
225
+ }
226
+ }
227
+ throw error;
228
+ }
189
229
  }
190
230
  /**
191
231
  * List past verifications with optional filters.
@@ -312,12 +312,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
312
312
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
313
313
  }
314
314
  const showBottomCard = status === 'success' || status === 'error';
315
- return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: !terminated && enableTorch, onCameraReady: onCameraReady, onMountError: onMountError, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
316
- styles.guideFrame,
317
- overlay.guideFrameAspectRatio
318
- ? { aspectRatio: overlay.guideFrameAspectRatio }
319
- : undefined,
320
- ], 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] })] }) })), 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: [
315
+ return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: !terminated && enableTorch, onCameraReady: onCameraReady, onMountError: onMountError, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: styles.guideContainer, children: [_jsxs(View, { style: [
316
+ styles.guideFrame,
317
+ overlay.guideFrameAspectRatio
318
+ ? { aspectRatio: overlay.guideFrameAspectRatio }
319
+ : undefined,
320
+ ], 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: [
321
321
  styles.resultIconCircle,
322
322
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
323
323
  ], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
@@ -334,9 +334,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
334
334
  }
335
335
  else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
336
336
  const remaining = overlay.maxAttempts - attemptCountRef.current;
337
- errorTitle = 'Not Verified';
338
- const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
339
- errorMessage = template.replace('{remaining}', String(remaining));
337
+ errorTitle = overlay?.failureMessage || 'Not Verified';
338
+ if (overlay?.retryMessage) {
339
+ errorMessage = overlay.retryMessage.replace('{remaining}', String(remaining));
340
+ }
341
+ else {
342
+ // Show actual API feedback with retry count (matches Flutter)
343
+ const feedback = result.feedback?.trim();
344
+ const retryInfo = `${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`;
345
+ errorMessage = feedback ? `${feedback}\n\n${retryInfo}` : `Please try again. ${retryInfo}`;
346
+ }
340
347
  }
341
348
  else {
342
349
  const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
@@ -382,6 +389,14 @@ const styles = StyleSheet.create({
382
389
  alignItems: 'center',
383
390
  paddingHorizontal: 40,
384
391
  },
392
+ guideCaptionText: {
393
+ color: 'rgba(255,255,255,0.8)',
394
+ fontSize: 13,
395
+ fontWeight: '500',
396
+ textAlign: 'center',
397
+ marginTop: 12,
398
+ lineHeight: 18,
399
+ },
385
400
  guideFrame: {
386
401
  width: '100%',
387
402
  aspectRatio: 4 / 3,
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
  }
@@ -30,6 +30,8 @@ export interface VerificationResult {
30
30
  feedback: string;
31
31
  metadata: Record<string, unknown>;
32
32
  image_url: string | null;
33
+ /** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
34
+ category?: string;
33
35
  }
34
36
  export interface VerificationListResponse {
35
37
  data: VerificationResult[];
@@ -87,6 +89,8 @@ export interface ScannerOverlayConfig {
87
89
  guideOverlayContent?: React.ReactNode;
88
90
  /** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
89
91
  guideOverlayOpacity?: number;
92
+ /** Caption text shown directly below the guide frame. */
93
+ guideCaption?: string;
90
94
  processingMessage?: string;
91
95
  successMessage?: string;
92
96
  failureMessage?: string;
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.1.1";
1
+ export declare const SDK_VERSION = "2.3.0";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '1.1.1';
1
+ export const SDK_VERSION = '2.3.0';
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "1.1.2",
3
+ "version": "2.3.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
  }
@@ -246,11 +262,35 @@ export class VerifyAIClient {
246
262
  headers['Idempotency-Key'] = options.idempotencyKey;
247
263
  }
248
264
 
249
- return this.executeRequest<VerificationResult>('/verify', {
250
- method: 'POST',
251
- headers,
252
- body: formData,
253
- });
265
+ try {
266
+ return await this.executeRequest<VerificationResult>('/verify', {
267
+ method: 'POST',
268
+ headers,
269
+ body: formData,
270
+ });
271
+ } catch (error) {
272
+ // Blank 500 means the server crashed during multipart parsing (e.g.
273
+ // Vercel function crash). Retry as JSON/base64 with a fresh request
274
+ // — the crashed request may have partially recorded the idempotency key.
275
+ if (error instanceof VerifyAIRequestError && error.status >= 500 && error.isServerError) {
276
+ try {
277
+ const { Buffer } = await import('buffer');
278
+ const fileResponse = await fetch(request.imageUri);
279
+ const arrayBuffer = await fileResponse.arrayBuffer();
280
+ const base64 = Buffer.from(arrayBuffer).toString('base64');
281
+ return this.verify({
282
+ image: base64,
283
+ policy: request.policy,
284
+ metadata: request.metadata,
285
+ provider: request.provider,
286
+ });
287
+ } catch {
288
+ // If the retry itself fails, throw the original error
289
+ throw error;
290
+ }
291
+ }
292
+ throw error;
293
+ }
254
294
  }
255
295
 
256
296
  /**
@@ -450,6 +450,9 @@ export function VerifyAIScanner({
450
450
  <View style={[styles.corner, styles.cornerBottomLeft]} />
451
451
  <View style={[styles.corner, styles.cornerBottomRight]} />
452
452
  </View>
453
+ {overlay.guideCaption && (
454
+ <Text style={styles.guideCaptionText}>{overlay.guideCaption}</Text>
455
+ )}
453
456
  </View>
454
457
  )}
455
458
 
@@ -505,9 +508,15 @@ export function VerifyAIScanner({
505
508
  errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
506
509
  } else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
507
510
  const remaining = overlay.maxAttempts - attemptCountRef.current;
508
- errorTitle = 'Not Verified';
509
- const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
510
- errorMessage = template.replace('{remaining}', String(remaining));
511
+ errorTitle = overlay?.failureMessage || 'Not Verified';
512
+ if (overlay?.retryMessage) {
513
+ errorMessage = overlay.retryMessage.replace('{remaining}', String(remaining));
514
+ } else {
515
+ // Show actual API feedback with retry count (matches Flutter)
516
+ const feedback = result.feedback?.trim();
517
+ const retryInfo = `${remaining} attempt${remaining === 1 ? '' : 's'} remaining.`;
518
+ errorMessage = feedback ? `${feedback}\n\n${retryInfo}` : `Please try again. ${retryInfo}`;
519
+ }
511
520
  } else {
512
521
  const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
513
522
  errorTitle = display.title;
@@ -595,6 +604,14 @@ const styles = StyleSheet.create({
595
604
  alignItems: 'center',
596
605
  paddingHorizontal: 40,
597
606
  },
607
+ guideCaptionText: {
608
+ color: 'rgba(255,255,255,0.8)',
609
+ fontSize: 13,
610
+ fontWeight: '500',
611
+ textAlign: 'center' as const,
612
+ marginTop: 12,
613
+ lineHeight: 18,
614
+ },
598
615
  guideFrame: {
599
616
  width: '100%',
600
617
  aspectRatio: 4 / 3,
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
  }
@@ -34,6 +34,8 @@ export interface VerificationResult {
34
34
  feedback: string;
35
35
  metadata: Record<string, unknown>;
36
36
  image_url: string | null;
37
+ /** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
38
+ category?: string;
37
39
  }
38
40
 
39
41
  export interface VerificationListResponse {
@@ -98,6 +100,8 @@ export interface ScannerOverlayConfig {
98
100
  guideOverlayContent?: React.ReactNode;
99
101
  /** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
100
102
  guideOverlayOpacity?: number;
103
+ /** Caption text shown directly below the guide frame. */
104
+ guideCaption?: string;
101
105
  processingMessage?: string;
102
106
  successMessage?: string;
103
107
  failureMessage?: string;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '1.1.1';
1
+ export const SDK_VERSION = '2.3.0';