@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.
- package/README.md +11 -2
- package/lib/client/index.d.ts +22 -2
- package/lib/client/index.js +132 -19
- package/lib/components/VerifyAIScanner.d.ts +14 -6
- package/lib/components/VerifyAIScanner.js +157 -15
- package/lib/hooks/useVerifyAI.d.ts +32 -10
- package/lib/hooks/useVerifyAI.js +246 -14
- package/lib/index.d.ts +5 -2
- package/lib/index.js +3 -0
- package/lib/ml/featureExtractor.d.ts +16 -0
- package/lib/ml/featureExtractor.js +123 -0
- package/lib/ml/imagePreprocessor.d.ts +2 -0
- package/lib/ml/imagePreprocessor.js +48 -0
- package/lib/ml/index.d.ts +5 -0
- package/lib/ml/index.js +4 -0
- package/lib/ml/inferenceEngine.d.ts +24 -0
- package/lib/ml/inferenceEngine.js +156 -0
- package/lib/ml/modelManager.d.ts +26 -0
- package/lib/ml/modelManager.js +207 -0
- package/lib/ml/policyEngine.d.ts +14 -0
- package/lib/ml/policyEngine.js +161 -0
- package/lib/ml/types.d.ts +84 -0
- package/lib/ml/types.js +4 -0
- package/lib/storage/offlineQueue.js +1 -1
- package/lib/telemetry/TelemetryContext.d.ts +4 -0
- package/lib/telemetry/TelemetryContext.js +5 -0
- package/lib/telemetry/TelemetryReporter.d.ts +23 -0
- package/lib/telemetry/TelemetryReporter.js +140 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/version.d.ts +1 -0
- package/lib/version.js +1 -0
- package/package.json +23 -2
- package/src/client/index.ts +176 -25
- package/src/components/VerifyAIScanner.tsx +201 -18
- package/src/hooks/useVerifyAI.ts +332 -18
- package/src/index.ts +20 -1
- package/src/ml/featureExtractor.ts +160 -0
- package/src/ml/imagePreprocessor.ts +72 -0
- package/src/ml/index.ts +14 -0
- package/src/ml/inferenceEngine.ts +200 -0
- package/src/ml/modelManager.ts +265 -0
- package/src/ml/policyEngine.ts +201 -0
- package/src/ml/types.ts +104 -0
- package/src/storage/offlineQueue.ts +1 -1
- package/src/telemetry/TelemetryContext.tsx +8 -0
- package/src/telemetry/TelemetryReporter.ts +181 -0
- package/src/types/index.ts +20 -0
- 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
|
|
23
|
-
onCapture: (
|
|
24
|
-
/** Called when verification
|
|
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 {
|
|
104
|
+
* const { verifyMultipart } = useVerifyAI({
|
|
105
|
+
* apiKey: 'vai_...',
|
|
106
|
+
* enableOnDeviceML: true,
|
|
107
|
+
* });
|
|
45
108
|
*
|
|
46
109
|
* <VerifyAIScanner
|
|
47
|
-
* onCapture={(
|
|
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?.
|
|
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(
|
|
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
|
-
|
|
250
|
-
|
|
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} />
|