@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.
- 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 +12 -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 +198 -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;
|
|
@@ -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 {
|
|
104
|
+
* const { verifyMultipart } = useVerifyAI({
|
|
105
|
+
* apiKey: 'vai_...',
|
|
106
|
+
* enableOnDeviceML: true,
|
|
107
|
+
* });
|
|
47
108
|
*
|
|
48
109
|
* <VerifyAIScanner
|
|
49
|
-
* onCapture={(
|
|
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?.
|
|
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(
|
|
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
|
-
|
|
253
|
-
|
|
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} />
|