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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -4
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +7 -4
  5. package/lib/components/VerifyAIScanner.js +235 -18
  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 +24 -0
  28. package/lib/telemetry/TelemetryReporter.js +141 -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 +282 -21
  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 +184 -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,26 @@ import {
10
10
  import {
11
11
  CameraView,
12
12
  useCameraPermissions,
13
- type CameraCapturedPicture,
14
13
  } from 'expo-camera';
15
14
  import type {
16
15
  VerificationResult,
17
16
  ScannerStatus,
18
17
  ScannerOverlayConfig,
19
18
  } from '../types';
19
+ import { useTelemetry } from '../telemetry/TelemetryContext';
20
+ import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
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;
20
28
 
21
29
  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. */
30
+ /** Called with base64 image data when the user captures a photo. */
31
+ onCapture: (base64: string) => Promise<VerificationResult | null>;
32
+ /** Called when a terminal verification result is reached. */
25
33
  onResult?: (result: VerificationResult) => void;
26
34
  /** Called when an error occurs. */
27
35
  onError?: (error: Error) => void;
@@ -35,6 +43,64 @@ export interface VerifyAIScannerProps {
35
43
  captureRef?: React.MutableRefObject<(() => void) | null>;
36
44
  /** Whether to enable the camera torch/flashlight. */
37
45
  enableTorch?: boolean;
46
+ /** Optional telemetry reporter (falls back to TelemetryContext). */
47
+ telemetry?: TelemetryReporter | null;
48
+ }
49
+
50
+ type ErrorWithDetails = Error & {
51
+ status?: number;
52
+ requestId?: string;
53
+ code?: string;
54
+ body?: {
55
+ request_id?: string;
56
+ code?: string;
57
+ status?: number;
58
+ };
59
+ };
60
+
61
+ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
62
+ if (!error) {
63
+ return {
64
+ title: 'Something went wrong',
65
+ message: "We couldn't process your photo. Please try again.",
66
+ };
67
+ }
68
+
69
+ const status = error.status ?? error.body?.status;
70
+ const code = error.code ?? error.body?.code;
71
+ const requestId = error.requestId ?? error.body?.request_id;
72
+
73
+ let message = error.message?.trim() || "We couldn't process your photo. Please try again.";
74
+
75
+ if (code === 'timeout' || status === 408 || error.name === 'AbortError') {
76
+ message = 'Verification timed out. Please try again.';
77
+ } else if (code === 'network_error' || status === 0) {
78
+ message = 'Network request failed. Check your connection and try again.';
79
+ } else if (status === 401) {
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.';
83
+ } else if (status === 429) {
84
+ message = 'Verification is temporarily unavailable. Please try again.';
85
+ } else if (status !== undefined && status >= 500) {
86
+ message = 'Verify AI is unavailable right now. Please try again.';
87
+ }
88
+
89
+ if (showTechnicalDetails) {
90
+ const details = [
91
+ status != null ? `status ${status}` : null,
92
+ requestId ? `request ${requestId}` : null,
93
+ ].filter(Boolean).join(' · ');
94
+
95
+ if (details) {
96
+ message = `${message}\n\n${details}`;
97
+ }
98
+ }
99
+
100
+ return {
101
+ title: 'Verification failed',
102
+ message,
103
+ };
38
104
  }
39
105
 
40
106
  /**
@@ -67,33 +133,204 @@ export function VerifyAIScanner({
67
133
  showCaptureButton = true,
68
134
  captureRef,
69
135
  enableTorch,
136
+ telemetry: telemetryProp,
70
137
  }: VerifyAIScannerProps) {
138
+ const contextTelemetry = useTelemetry();
139
+ const telemetry = telemetryProp ?? contextTelemetry;
140
+
71
141
  const cameraRef = useRef<CameraView>(null);
72
142
  const [status, setStatus] = useState<ScannerStatus>('idle');
73
143
  const [result, setResult] = useState<VerificationResult | null>(null);
144
+ const [lastError, setLastError] = useState<ErrorWithDetails | null>(null);
74
145
  const [permission, requestPermission] = useCameraPermissions();
75
146
  const attemptCountRef = useRef(0);
76
147
  const [exhausted, setExhausted] = useState(false);
148
+ const [terminated, setTerminated] = useState(false);
149
+ const [cameraReady, setCameraReady] = useState(false);
150
+ const cameraReadyRef = useRef(false);
151
+ const cameraInitFailedRef = useRef(false);
152
+ const permissionDeniedTrackedRef = useRef(false);
153
+
154
+ // Release camera (and torch) when a terminal result is reached or on unmount.
155
+ const releaseCamera = useCallback(() => {
156
+ setTerminated(true);
157
+ cameraRef.current?.pausePreview?.().catch(() => {});
158
+ }, []);
159
+
160
+ useEffect(() => {
161
+ return () => {
162
+ cameraRef.current?.pausePreview?.().catch(() => {});
163
+ };
164
+ }, []);
165
+
166
+ // Camera init callbacks
167
+ const onCameraReady = useCallback(() => {
168
+ setCameraReady(true);
169
+ cameraReadyRef.current = true;
170
+ cameraInitFailedRef.current = false;
171
+ }, []);
172
+
173
+ const onMountError = useCallback((event: { message?: string }) => {
174
+ const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
175
+ setResult(null);
176
+ setLastError(error);
177
+ setStatus('error');
178
+ setCameraReady(false);
179
+ cameraReadyRef.current = false;
180
+ cameraInitFailedRef.current = true;
181
+ onError?.(error);
182
+ telemetry?.track('camera_init_failure', {
183
+ component: 'scanner',
184
+ error,
185
+ });
186
+ }, [onError, telemetry]);
187
+
188
+ // Startup watchdog — if camera hasn't fired onCameraReady within 5s, report timeout
189
+ useEffect(() => {
190
+ if (!permission?.granted || terminated) return;
191
+
192
+ const timer = setTimeout(() => {
193
+ if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
194
+ telemetry?.track('camera_preview_timeout', {
195
+ component: 'scanner',
196
+ error: 'Camera did not initialize within 5 seconds',
197
+ });
198
+ }
199
+ }, 5000);
200
+
201
+ return () => clearTimeout(timer);
202
+ }, [permission?.granted, terminated, telemetry]);
203
+
204
+ // Track permission denied
205
+ useEffect(() => {
206
+ if (
207
+ permission &&
208
+ !permission.granted &&
209
+ permission.canAskAgain === false &&
210
+ !permissionDeniedTrackedRef.current
211
+ ) {
212
+ permissionDeniedTrackedRef.current = true;
213
+ telemetry?.track('camera_permission_denied', { component: 'scanner' });
214
+ }
215
+ }, [permission, telemetry]);
77
216
 
78
217
  const handleCapture = useCallback(async () => {
79
218
  if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
219
+ if (!cameraReadyRef.current) {
220
+ const error = new Error('Camera is not ready yet. Please wait a moment and try again.') as ErrorWithDetails;
221
+ setResult(null);
222
+ setLastError(error);
223
+ setStatus('error');
224
+ onError?.(error);
225
+ telemetry?.track('camera_not_ready', {
226
+ component: 'scanner',
227
+ error,
228
+ });
229
+ setTimeout(() => setStatus('idle'), 2000);
230
+ return;
231
+ }
80
232
 
81
233
  setStatus('capturing');
82
234
  setResult(null);
235
+ setLastError(null);
83
236
 
84
237
  try {
85
- const photo = await cameraRef.current.takePictureAsync({
86
- base64: true,
87
- quality: 0.8,
88
- exif: false,
89
- });
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;
90
249
 
91
- if (!photo?.base64) {
92
- throw new Error('Failed to capture photo');
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
93
255
  }
94
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
+ });
307
+
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;
317
+ }
318
+
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
+ });
331
+
95
332
  setStatus('processing');
96
- const verificationResult = await onCapture(photo.base64);
333
+ const verificationResult = await onCapture(base64);
97
334
 
98
335
  attemptCountRef.current++;
99
336
 
@@ -106,6 +343,7 @@ export function VerifyAIScanner({
106
343
  maxAttempts != null &&
107
344
  attemptCountRef.current >= maxAttempts) {
108
345
  setExhausted(true);
346
+ releaseCamera();
109
347
  if (autoApprove) {
110
348
  const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
111
349
  setResult(approvedResult);
@@ -114,26 +352,48 @@ export function VerifyAIScanner({
114
352
  } else {
115
353
  setResult(verificationResult);
116
354
  setStatus('error');
355
+ onResult?.(verificationResult);
117
356
  }
118
357
  return;
119
358
  }
120
359
 
360
+ if (!verificationResult.is_compliant &&
361
+ maxAttempts != null &&
362
+ attemptCountRef.current < maxAttempts) {
363
+ setResult(verificationResult);
364
+ setStatus('error');
365
+ setTimeout(() => setStatus('idle'), 3000);
366
+ return;
367
+ }
368
+
369
+ releaseCamera();
121
370
  setResult(verificationResult);
122
371
  setStatus('success');
123
372
  onResult?.(verificationResult);
124
- setTimeout(() => setStatus('idle'), 3000);
125
373
  } else {
126
374
  // null result means queued for offline
127
375
  setStatus('idle');
128
376
  }
129
377
  } catch (err) {
130
- const error = err instanceof Error ? err : new Error(String(err));
378
+ const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
379
+ setLastError(error);
131
380
  setStatus('error');
132
381
  onError?.(error);
382
+
383
+ // Track the error
384
+ const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
385
+ const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
386
+ telemetry?.track(
387
+ isCaptureFail ? 'capture_failure'
388
+ : isImageFail ? 'image_manipulation_failure'
389
+ : 'unknown_error',
390
+ { component: 'scanner', error },
391
+ );
392
+
133
393
  // Reset after a brief pause
134
394
  setTimeout(() => setStatus('idle'), 2000);
135
395
  }
136
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
396
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
137
397
 
138
398
  // Expose capture to parent via ref
139
399
  if (captureRef) {
@@ -159,7 +419,7 @@ export function VerifyAIScanner({
159
419
 
160
420
  return (
161
421
  <View style={[styles.container, style]}>
162
- <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={enableTorch}>
422
+ <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={!terminated && enableTorch} onCameraReady={onCameraReady} onMountError={onMountError}>
163
423
  {/* Overlay */}
164
424
  <View style={styles.overlay}>
165
425
  {overlay?.title && (
@@ -249,8 +509,9 @@ export function VerifyAIScanner({
249
509
  const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
250
510
  errorMessage = template.replace('{remaining}', String(remaining));
251
511
  } else {
252
- errorTitle = 'Something went wrong';
253
- errorMessage = "We couldn't process your photo. Please try again.";
512
+ const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
513
+ errorTitle = display.title;
514
+ errorMessage = display.message;
254
515
  }
255
516
 
256
517
  return (
@@ -280,11 +541,11 @@ export function VerifyAIScanner({
280
541
  <TouchableOpacity
281
542
  style={[
282
543
  styles.captureButton,
283
- (status === 'capturing' || status === 'processing') &&
544
+ (!cameraReady || status === 'capturing' || status === 'processing') &&
284
545
  styles.captureButtonDisabled,
285
546
  ]}
286
547
  onPress={handleCapture}
287
- disabled={status === 'capturing' || status === 'processing'}
548
+ disabled={!cameraReady || status === 'capturing' || status === 'processing'}
288
549
  activeOpacity={0.7}
289
550
  >
290
551
  <View style={styles.captureButtonInner} />