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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +11 -2
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +12 -6
  5. package/lib/components/VerifyAIScanner.js +157 -15
  6. package/lib/hooks/useVerifyAI.d.ts +32 -10
  7. package/lib/hooks/useVerifyAI.js +246 -14
  8. package/lib/index.d.ts +5 -2
  9. package/lib/index.js +3 -0
  10. package/lib/ml/featureExtractor.d.ts +16 -0
  11. package/lib/ml/featureExtractor.js +123 -0
  12. package/lib/ml/imagePreprocessor.d.ts +2 -0
  13. package/lib/ml/imagePreprocessor.js +48 -0
  14. package/lib/ml/index.d.ts +5 -0
  15. package/lib/ml/index.js +4 -0
  16. package/lib/ml/inferenceEngine.d.ts +24 -0
  17. package/lib/ml/inferenceEngine.js +156 -0
  18. package/lib/ml/modelManager.d.ts +26 -0
  19. package/lib/ml/modelManager.js +207 -0
  20. package/lib/ml/policyEngine.d.ts +14 -0
  21. package/lib/ml/policyEngine.js +161 -0
  22. package/lib/ml/types.d.ts +84 -0
  23. package/lib/ml/types.js +4 -0
  24. package/lib/storage/offlineQueue.js +1 -1
  25. package/lib/telemetry/TelemetryContext.d.ts +4 -0
  26. package/lib/telemetry/TelemetryContext.js +5 -0
  27. package/lib/telemetry/TelemetryReporter.d.ts +23 -0
  28. package/lib/telemetry/TelemetryReporter.js +140 -0
  29. package/lib/types/index.d.ts +18 -0
  30. package/lib/version.d.ts +1 -0
  31. package/lib/version.js +1 -0
  32. package/package.json +23 -2
  33. package/src/client/index.ts +176 -25
  34. package/src/components/VerifyAIScanner.tsx +198 -18
  35. package/src/hooks/useVerifyAI.ts +332 -18
  36. package/src/index.ts +20 -1
  37. package/src/ml/featureExtractor.ts +160 -0
  38. package/src/ml/imagePreprocessor.ts +72 -0
  39. package/src/ml/index.ts +14 -0
  40. package/src/ml/inferenceEngine.ts +200 -0
  41. package/src/ml/modelManager.ts +265 -0
  42. package/src/ml/policyEngine.ts +201 -0
  43. package/src/ml/types.ts +104 -0
  44. package/src/storage/offlineQueue.ts +1 -1
  45. package/src/telemetry/TelemetryContext.tsx +8 -0
  46. package/src/telemetry/TelemetryReporter.ts +181 -0
  47. package/src/types/index.ts +20 -0
  48. package/src/version.ts +1 -0
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState, useCallback } from 'react';
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -10,18 +10,20 @@ import {
10
10
  import {
11
11
  CameraView,
12
12
  useCameraPermissions,
13
- type CameraCapturedPicture,
14
13
  } from 'expo-camera';
14
+ import * as ImageManipulator from 'expo-image-manipulator';
15
15
  import type {
16
16
  VerificationResult,
17
17
  ScannerStatus,
18
18
  ScannerOverlayConfig,
19
19
  } from '../types';
20
+ import { useTelemetry } from '../telemetry/TelemetryContext';
21
+ import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
20
22
 
21
23
  export interface VerifyAIScannerProps {
22
- /** Called with the base64 image data when the user captures a photo. */
23
- onCapture: (base64Image: string) => Promise<VerificationResult | null>;
24
- /** Called when verification completes successfully. */
24
+ /** Called with the image URI when the user captures a photo. */
25
+ onCapture: (imageUri: string) => Promise<VerificationResult | null>;
26
+ /** Called when a terminal verification result is reached. */
25
27
  onResult?: (result: VerificationResult) => void;
26
28
  /** Called when an error occurs. */
27
29
  onError?: (error: Error) => void;
@@ -35,6 +37,62 @@ export interface VerifyAIScannerProps {
35
37
  captureRef?: React.MutableRefObject<(() => void) | null>;
36
38
  /** Whether to enable the camera torch/flashlight. */
37
39
  enableTorch?: boolean;
40
+ /** Optional telemetry reporter (falls back to TelemetryContext). */
41
+ telemetry?: TelemetryReporter | null;
42
+ }
43
+
44
+ type ErrorWithDetails = Error & {
45
+ status?: number;
46
+ requestId?: string;
47
+ code?: string;
48
+ body?: {
49
+ request_id?: string;
50
+ code?: string;
51
+ status?: number;
52
+ };
53
+ };
54
+
55
+ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
56
+ if (!error) {
57
+ return {
58
+ title: 'Something went wrong',
59
+ message: "We couldn't process your photo. Please try again.",
60
+ };
61
+ }
62
+
63
+ const status = error.status ?? error.body?.status;
64
+ const code = error.code ?? error.body?.code;
65
+ const requestId = error.requestId ?? error.body?.request_id;
66
+
67
+ let message = error.message?.trim() || "We couldn't process your photo. Please try again.";
68
+
69
+ if (code === 'timeout' || status === 408 || error.name === 'AbortError') {
70
+ message = 'Verification timed out. Please try again.';
71
+ } else if (code === 'network_error' || status === 0) {
72
+ message = 'Network request failed. Check your connection and try again.';
73
+ } else if (status === 401) {
74
+ message = 'Verification is not configured correctly.';
75
+ } else if (status === 429) {
76
+ message = 'Verification is temporarily unavailable. Please try again.';
77
+ } else if (status !== undefined && status >= 500) {
78
+ message = 'Verify AI is unavailable right now. Please try again.';
79
+ }
80
+
81
+ if (showTechnicalDetails) {
82
+ const details = [
83
+ status != null ? `status ${status}` : null,
84
+ requestId ? `request ${requestId}` : null,
85
+ ].filter(Boolean).join(' · ');
86
+
87
+ if (details) {
88
+ message = `${message}\n\n${details}`;
89
+ }
90
+ }
91
+
92
+ return {
93
+ title: 'Verification failed',
94
+ message,
95
+ };
38
96
  }
39
97
 
40
98
  /**
@@ -43,10 +101,13 @@ export interface VerifyAIScannerProps {
43
101
  *
44
102
  * @example
45
103
  * ```tsx
46
- * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
104
+ * const { verifyMultipart } = useVerifyAI({
105
+ * apiKey: 'vai_...',
106
+ * enableOnDeviceML: true,
107
+ * });
47
108
  *
48
109
  * <VerifyAIScanner
49
- * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
110
+ * onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
50
111
  * onResult={(result) => {
51
112
  * if (result.is_compliant) navigation.navigate('Success');
52
113
  * }}
@@ -67,33 +128,128 @@ export function VerifyAIScanner({
67
128
  showCaptureButton = true,
68
129
  captureRef,
69
130
  enableTorch,
131
+ telemetry: telemetryProp,
70
132
  }: VerifyAIScannerProps) {
133
+ const contextTelemetry = useTelemetry();
134
+ const telemetry = telemetryProp ?? contextTelemetry;
135
+
71
136
  const cameraRef = useRef<CameraView>(null);
72
137
  const [status, setStatus] = useState<ScannerStatus>('idle');
73
138
  const [result, setResult] = useState<VerificationResult | null>(null);
139
+ const [lastError, setLastError] = useState<ErrorWithDetails | null>(null);
74
140
  const [permission, requestPermission] = useCameraPermissions();
75
141
  const attemptCountRef = useRef(0);
76
142
  const [exhausted, setExhausted] = useState(false);
143
+ const [terminated, setTerminated] = useState(false);
144
+ const [cameraReady, setCameraReady] = useState(false);
145
+ const cameraReadyRef = useRef(false);
146
+ const cameraInitFailedRef = useRef(false);
147
+ const permissionDeniedTrackedRef = useRef(false);
148
+
149
+ // Release camera (and torch) when a terminal result is reached or on unmount.
150
+ const releaseCamera = useCallback(() => {
151
+ setTerminated(true);
152
+ cameraRef.current?.pausePreview?.().catch(() => {});
153
+ }, []);
154
+
155
+ useEffect(() => {
156
+ return () => {
157
+ cameraRef.current?.pausePreview?.().catch(() => {});
158
+ };
159
+ }, []);
160
+
161
+ // Camera init callbacks
162
+ const onCameraReady = useCallback(() => {
163
+ setCameraReady(true);
164
+ cameraReadyRef.current = true;
165
+ cameraInitFailedRef.current = false;
166
+ }, []);
167
+
168
+ const onMountError = useCallback((event: { message?: string }) => {
169
+ const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
170
+ setResult(null);
171
+ setLastError(error);
172
+ setStatus('error');
173
+ setCameraReady(false);
174
+ cameraReadyRef.current = false;
175
+ cameraInitFailedRef.current = true;
176
+ onError?.(error);
177
+ telemetry?.track('camera_init_failure', {
178
+ component: 'scanner',
179
+ error,
180
+ });
181
+ }, [onError, telemetry]);
182
+
183
+ // Startup watchdog — if camera hasn't fired onCameraReady within 5s, report timeout
184
+ useEffect(() => {
185
+ if (!permission?.granted || terminated) return;
186
+
187
+ const timer = setTimeout(() => {
188
+ if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
189
+ telemetry?.track('camera_preview_timeout', {
190
+ component: 'scanner',
191
+ error: 'Camera did not initialize within 5 seconds',
192
+ });
193
+ }
194
+ }, 5000);
195
+
196
+ return () => clearTimeout(timer);
197
+ }, [permission?.granted, terminated, telemetry]);
198
+
199
+ // Track permission denied
200
+ useEffect(() => {
201
+ if (
202
+ permission &&
203
+ !permission.granted &&
204
+ permission.canAskAgain === false &&
205
+ !permissionDeniedTrackedRef.current
206
+ ) {
207
+ permissionDeniedTrackedRef.current = true;
208
+ telemetry?.track('camera_permission_denied', { component: 'scanner' });
209
+ }
210
+ }, [permission, telemetry]);
77
211
 
78
212
  const handleCapture = useCallback(async () => {
79
213
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
214
+ if (!cameraReadyRef.current) {
215
+ const error = new Error('Camera is not ready yet. Please wait a moment and try again.') as ErrorWithDetails;
216
+ setResult(null);
217
+ setLastError(error);
218
+ setStatus('error');
219
+ onError?.(error);
220
+ telemetry?.track('camera_not_ready', {
221
+ component: 'scanner',
222
+ error,
223
+ });
224
+ setTimeout(() => setStatus('idle'), 2000);
225
+ return;
226
+ }
80
227
 
81
228
  setStatus('capturing');
82
229
  setResult(null);
230
+ setLastError(null);
83
231
 
84
232
  try {
85
233
  const photo = await cameraRef.current.takePictureAsync({
86
- base64: true,
87
234
  quality: 0.8,
88
235
  exif: false,
89
236
  });
90
237
 
91
- if (!photo?.base64) {
238
+ if (!photo?.uri) {
92
239
  throw new Error('Failed to capture photo');
93
240
  }
94
241
 
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
+ );
250
+
95
251
  setStatus('processing');
96
- const verificationResult = await onCapture(photo.base64);
252
+ const verificationResult = await onCapture(normalized.uri);
97
253
 
98
254
  attemptCountRef.current++;
99
255
 
@@ -106,6 +262,7 @@ export function VerifyAIScanner({
106
262
  maxAttempts != null &&
107
263
  attemptCountRef.current >= maxAttempts) {
108
264
  setExhausted(true);
265
+ releaseCamera();
109
266
  if (autoApprove) {
110
267
  const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
111
268
  setResult(approvedResult);
@@ -114,26 +271,48 @@ export function VerifyAIScanner({
114
271
  } else {
115
272
  setResult(verificationResult);
116
273
  setStatus('error');
274
+ onResult?.(verificationResult);
117
275
  }
118
276
  return;
119
277
  }
120
278
 
279
+ if (!verificationResult.is_compliant &&
280
+ maxAttempts != null &&
281
+ attemptCountRef.current < maxAttempts) {
282
+ setResult(verificationResult);
283
+ setStatus('error');
284
+ setTimeout(() => setStatus('idle'), 3000);
285
+ return;
286
+ }
287
+
288
+ releaseCamera();
121
289
  setResult(verificationResult);
122
290
  setStatus('success');
123
291
  onResult?.(verificationResult);
124
- setTimeout(() => setStatus('idle'), 3000);
125
292
  } else {
126
293
  // null result means queued for offline
127
294
  setStatus('idle');
128
295
  }
129
296
  } catch (err) {
130
- const error = err instanceof Error ? err : new Error(String(err));
297
+ const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
298
+ setLastError(error);
131
299
  setStatus('error');
132
300
  onError?.(error);
301
+
302
+ // Track the error
303
+ const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
304
+ const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
305
+ telemetry?.track(
306
+ isCaptureFail ? 'capture_failure'
307
+ : isImageFail ? 'image_manipulation_failure'
308
+ : 'unknown_error',
309
+ { component: 'scanner', error },
310
+ );
311
+
133
312
  // Reset after a brief pause
134
313
  setTimeout(() => setStatus('idle'), 2000);
135
314
  }
136
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
315
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
137
316
 
138
317
  // Expose capture to parent via ref
139
318
  if (captureRef) {
@@ -159,7 +338,7 @@ export function VerifyAIScanner({
159
338
 
160
339
  return (
161
340
  <View style={[styles.container, style]}>
162
- <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={enableTorch}>
341
+ <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={!terminated && enableTorch} onCameraReady={onCameraReady} onMountError={onMountError}>
163
342
  {/* Overlay */}
164
343
  <View style={styles.overlay}>
165
344
  {overlay?.title && (
@@ -249,8 +428,9 @@ export function VerifyAIScanner({
249
428
  const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
250
429
  errorMessage = template.replace('{remaining}', String(remaining));
251
430
  } else {
252
- errorTitle = 'Something went wrong';
253
- errorMessage = "We couldn't process your photo. Please try again.";
431
+ const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
432
+ errorTitle = display.title;
433
+ errorMessage = display.message;
254
434
  }
255
435
 
256
436
  return (
@@ -280,11 +460,11 @@ export function VerifyAIScanner({
280
460
  <TouchableOpacity
281
461
  style={[
282
462
  styles.captureButton,
283
- (status === 'capturing' || status === 'processing') &&
463
+ (!cameraReady || status === 'capturing' || status === 'processing') &&
284
464
  styles.captureButtonDisabled,
285
465
  ]}
286
466
  onPress={handleCapture}
287
- disabled={status === 'capturing' || status === 'processing'}
467
+ disabled={!cameraReady || status === 'capturing' || status === 'processing'}
288
468
  activeOpacity={0.7}
289
469
  >
290
470
  <View style={styles.captureButtonInner} />