@switchlabs/verify-ai-react-native 1.0.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 +14 -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 +201 -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;
@@ -33,6 +35,64 @@ export interface VerifyAIScannerProps {
33
35
  showCaptureButton?: boolean;
34
36
  /** Ref to imperatively trigger capture from parent. */
35
37
  captureRef?: React.MutableRefObject<(() => void) | null>;
38
+ /** Whether to enable the camera torch/flashlight. */
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
+ };
36
96
  }
37
97
 
38
98
  /**
@@ -41,10 +101,13 @@ export interface VerifyAIScannerProps {
41
101
  *
42
102
  * @example
43
103
  * ```tsx
44
- * const { verify } = useVerifyAI({ apiKey: 'vai_...' });
104
+ * const { verifyMultipart } = useVerifyAI({
105
+ * apiKey: 'vai_...',
106
+ * enableOnDeviceML: true,
107
+ * });
45
108
  *
46
109
  * <VerifyAIScanner
47
- * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
110
+ * onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
48
111
  * onResult={(result) => {
49
112
  * if (result.is_compliant) navigation.navigate('Success');
50
113
  * }}
@@ -64,33 +127,129 @@ export function VerifyAIScanner({
64
127
  style,
65
128
  showCaptureButton = true,
66
129
  captureRef,
130
+ enableTorch,
131
+ telemetry: telemetryProp,
67
132
  }: VerifyAIScannerProps) {
133
+ const contextTelemetry = useTelemetry();
134
+ const telemetry = telemetryProp ?? contextTelemetry;
135
+
68
136
  const cameraRef = useRef<CameraView>(null);
69
137
  const [status, setStatus] = useState<ScannerStatus>('idle');
70
138
  const [result, setResult] = useState<VerificationResult | null>(null);
139
+ const [lastError, setLastError] = useState<ErrorWithDetails | null>(null);
71
140
  const [permission, requestPermission] = useCameraPermissions();
72
141
  const attemptCountRef = useRef(0);
73
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]);
74
211
 
75
212
  const handleCapture = useCallback(async () => {
76
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
+ }
77
227
 
78
228
  setStatus('capturing');
79
229
  setResult(null);
230
+ setLastError(null);
80
231
 
81
232
  try {
82
233
  const photo = await cameraRef.current.takePictureAsync({
83
- base64: true,
84
234
  quality: 0.8,
85
235
  exif: false,
86
236
  });
87
237
 
88
- if (!photo?.base64) {
238
+ if (!photo?.uri) {
89
239
  throw new Error('Failed to capture photo');
90
240
  }
91
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
+
92
251
  setStatus('processing');
93
- const verificationResult = await onCapture(photo.base64);
252
+ const verificationResult = await onCapture(normalized.uri);
94
253
 
95
254
  attemptCountRef.current++;
96
255
 
@@ -103,6 +262,7 @@ export function VerifyAIScanner({
103
262
  maxAttempts != null &&
104
263
  attemptCountRef.current >= maxAttempts) {
105
264
  setExhausted(true);
265
+ releaseCamera();
106
266
  if (autoApprove) {
107
267
  const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
108
268
  setResult(approvedResult);
@@ -111,26 +271,48 @@ export function VerifyAIScanner({
111
271
  } else {
112
272
  setResult(verificationResult);
113
273
  setStatus('error');
274
+ onResult?.(verificationResult);
114
275
  }
115
276
  return;
116
277
  }
117
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();
118
289
  setResult(verificationResult);
119
290
  setStatus('success');
120
291
  onResult?.(verificationResult);
121
- setTimeout(() => setStatus('idle'), 3000);
122
292
  } else {
123
293
  // null result means queued for offline
124
294
  setStatus('idle');
125
295
  }
126
296
  } catch (err) {
127
- 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);
128
299
  setStatus('error');
129
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
+
130
312
  // Reset after a brief pause
131
313
  setTimeout(() => setStatus('idle'), 2000);
132
314
  }
133
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
315
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
134
316
 
135
317
  // Expose capture to parent via ref
136
318
  if (captureRef) {
@@ -156,7 +338,7 @@ export function VerifyAIScanner({
156
338
 
157
339
  return (
158
340
  <View style={[styles.container, style]}>
159
- <CameraView ref={cameraRef} style={styles.camera} facing="back">
341
+ <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={!terminated && enableTorch} onCameraReady={onCameraReady} onMountError={onMountError}>
160
342
  {/* Overlay */}
161
343
  <View style={styles.overlay}>
162
344
  {overlay?.title && (
@@ -246,8 +428,9 @@ export function VerifyAIScanner({
246
428
  const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
247
429
  errorMessage = template.replace('{remaining}', String(remaining));
248
430
  } else {
249
- errorTitle = 'Something went wrong';
250
- 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;
251
434
  }
252
435
 
253
436
  return (
@@ -277,11 +460,11 @@ export function VerifyAIScanner({
277
460
  <TouchableOpacity
278
461
  style={[
279
462
  styles.captureButton,
280
- (status === 'capturing' || status === 'processing') &&
463
+ (!cameraReady || status === 'capturing' || status === 'processing') &&
281
464
  styles.captureButtonDisabled,
282
465
  ]}
283
466
  onPress={handleCapture}
284
- disabled={status === 'capturing' || status === 'processing'}
467
+ disabled={!cameraReady || status === 'capturing' || status === 'processing'}
285
468
  activeOpacity={0.7}
286
469
  >
287
470
  <View style={styles.captureButtonInner} />