@switchlabs/verify-ai-react-native 1.1.1 → 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.
- package/README.md +6 -2
- package/lib/components/VerifyAIScanner.d.ts +4 -7
- package/lib/components/VerifyAIScanner.js +92 -17
- package/lib/telemetry/TelemetryReporter.d.ts +1 -0
- package/lib/telemetry/TelemetryReporter.js +1 -0
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +104 -23
- package/src/telemetry/TelemetryReporter.ts +3 -0
package/README.md
CHANGED
|
@@ -7,13 +7,13 @@ React Native SDK for Verify AI photo verification.
|
|
|
7
7
|
Expo-managed apps:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
10
|
+
npx expo install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
React Native CLI:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
16
|
+
npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
If you want on-device inference, also install `expo-file-system`,
|
|
@@ -63,6 +63,10 @@ function ScannerScreen() {
|
|
|
63
63
|
}
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
`verifyMultipart()` is the recommended path for live camera captures. The
|
|
67
|
+
built-in offline queue currently replays base64 `verify()` requests only; raw
|
|
68
|
+
multipart uploads are not persisted automatically.
|
|
69
|
+
|
|
66
70
|
## Offline Mode
|
|
67
71
|
|
|
68
72
|
Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
|
|
@@ -3,8 +3,8 @@ import { type ViewStyle } from 'react-native';
|
|
|
3
3
|
import type { VerificationResult, ScannerOverlayConfig } from '../types';
|
|
4
4
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
5
5
|
export interface VerifyAIScannerProps {
|
|
6
|
-
/** Called with
|
|
7
|
-
onCapture: (
|
|
6
|
+
/** Called with base64 image data when the user captures a photo. */
|
|
7
|
+
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
8
8
|
/** Called when a terminal verification result is reached. */
|
|
9
9
|
onResult?: (result: VerificationResult) => void;
|
|
10
10
|
/** Called when an error occurs. */
|
|
@@ -28,13 +28,10 @@ export interface VerifyAIScannerProps {
|
|
|
28
28
|
*
|
|
29
29
|
* @example
|
|
30
30
|
* ```tsx
|
|
31
|
-
* const {
|
|
32
|
-
* apiKey: 'vai_...',
|
|
33
|
-
* enableOnDeviceML: true,
|
|
34
|
-
* });
|
|
31
|
+
* const { verify } = useVerifyAI({ apiKey: 'vai_...' });
|
|
35
32
|
*
|
|
36
33
|
* <VerifyAIScanner
|
|
37
|
-
* onCapture={(
|
|
34
|
+
* onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
|
|
38
35
|
* onResult={(result) => {
|
|
39
36
|
* if (result.is_compliant) navigation.navigate('Success');
|
|
40
37
|
* }}
|
|
@@ -2,8 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
3
3
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 'react-native';
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
|
-
import * as ImageManipulator from 'expo-image-manipulator';
|
|
6
5
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
6
|
+
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
7
|
+
const FALLBACK_QUALITY = 0.5;
|
|
8
|
+
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
9
|
+
const MANIPULATOR_QUALITY = 0.7;
|
|
10
|
+
/** Max dimension (px) on longest side when resize is available. */
|
|
11
|
+
const MAX_DIMENSION = 2048;
|
|
7
12
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
8
13
|
if (!error) {
|
|
9
14
|
return {
|
|
@@ -24,6 +29,9 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
24
29
|
else if (status === 401) {
|
|
25
30
|
message = 'Verification is not configured correctly.';
|
|
26
31
|
}
|
|
32
|
+
else if (status === 413) {
|
|
33
|
+
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
34
|
+
}
|
|
27
35
|
else if (status === 429) {
|
|
28
36
|
message = 'Verification is temporarily unavailable. Please try again.';
|
|
29
37
|
}
|
|
@@ -50,13 +58,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
50
58
|
*
|
|
51
59
|
* @example
|
|
52
60
|
* ```tsx
|
|
53
|
-
* const {
|
|
54
|
-
* apiKey: 'vai_...',
|
|
55
|
-
* enableOnDeviceML: true,
|
|
56
|
-
* });
|
|
61
|
+
* const { verify } = useVerifyAI({ apiKey: 'vai_...' });
|
|
57
62
|
*
|
|
58
63
|
* <VerifyAIScanner
|
|
59
|
-
* onCapture={(
|
|
64
|
+
* onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
|
|
60
65
|
* onResult={(result) => {
|
|
61
66
|
* if (result.is_compliant) navigation.navigate('Success');
|
|
62
67
|
* }}
|
|
@@ -157,19 +162,89 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
157
162
|
setResult(null);
|
|
158
163
|
setLastError(null);
|
|
159
164
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
// --- Capture + best-effort resize ---
|
|
166
|
+
// Strategy: try to dynamically import expo-image-manipulator.
|
|
167
|
+
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
168
|
+
// If not available → use expo-camera's built-in base64 at lower quality.
|
|
169
|
+
// This keeps expo-image-manipulator as an *optional* dependency.
|
|
170
|
+
let base64;
|
|
171
|
+
let origWidth = 0;
|
|
172
|
+
let origHeight = 0;
|
|
173
|
+
let processedWidth = 0;
|
|
174
|
+
let processedHeight = 0;
|
|
175
|
+
let didResize = false;
|
|
176
|
+
let ImageManipulator = null;
|
|
177
|
+
try {
|
|
178
|
+
ImageManipulator = await import('expo-image-manipulator');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Not installed — fall back to camera-only base64 below
|
|
182
|
+
}
|
|
183
|
+
if (ImageManipulator) {
|
|
184
|
+
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
185
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
186
|
+
quality: 0.8,
|
|
187
|
+
exif: false,
|
|
188
|
+
});
|
|
189
|
+
if (!photo?.uri) {
|
|
190
|
+
throw new Error('Failed to capture photo');
|
|
191
|
+
}
|
|
192
|
+
origWidth = photo.width ?? 0;
|
|
193
|
+
origHeight = photo.height ?? 0;
|
|
194
|
+
const actions = [];
|
|
195
|
+
if (origWidth > MAX_DIMENSION || origHeight > MAX_DIMENSION) {
|
|
196
|
+
if (origWidth >= origHeight) {
|
|
197
|
+
actions.push({ resize: { width: MAX_DIMENSION } });
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
actions.push({ resize: { height: MAX_DIMENSION } });
|
|
201
|
+
}
|
|
202
|
+
didResize = true;
|
|
203
|
+
}
|
|
204
|
+
const normalized = await ImageManipulator.manipulateAsync(photo.uri, actions, {
|
|
205
|
+
compress: MANIPULATOR_QUALITY,
|
|
206
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
207
|
+
base64: true,
|
|
208
|
+
});
|
|
209
|
+
if (!normalized.base64) {
|
|
210
|
+
throw new Error('ImageManipulator did not return base64');
|
|
211
|
+
}
|
|
212
|
+
base64 = normalized.base64;
|
|
213
|
+
processedWidth = normalized.width;
|
|
214
|
+
processedHeight = normalized.height;
|
|
166
215
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
216
|
+
else {
|
|
217
|
+
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
218
|
+
// No resize is possible without ImageManipulator, but the lower quality
|
|
219
|
+
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
220
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
221
|
+
base64: true,
|
|
222
|
+
quality: FALLBACK_QUALITY,
|
|
223
|
+
exif: false,
|
|
224
|
+
});
|
|
225
|
+
if (!photo?.base64) {
|
|
226
|
+
throw new Error('Failed to capture photo');
|
|
227
|
+
}
|
|
228
|
+
origWidth = photo.width ?? 0;
|
|
229
|
+
origHeight = photo.height ?? 0;
|
|
230
|
+
processedWidth = origWidth;
|
|
231
|
+
processedHeight = origHeight;
|
|
232
|
+
base64 = photo.base64;
|
|
233
|
+
}
|
|
234
|
+
// Best-effort telemetry — never blocks capture
|
|
235
|
+
telemetry?.track('image_processed', {
|
|
236
|
+
component: 'scanner',
|
|
237
|
+
metadata: {
|
|
238
|
+
original_width: origWidth,
|
|
239
|
+
original_height: origHeight,
|
|
240
|
+
processed_width: processedWidth,
|
|
241
|
+
processed_height: processedHeight,
|
|
242
|
+
resized: didResize ? 1 : 0,
|
|
243
|
+
has_manipulator: ImageManipulator ? 1 : 0,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
171
246
|
setStatus('processing');
|
|
172
|
-
const verificationResult = await onCapture(
|
|
247
|
+
const verificationResult = await onCapture(base64);
|
|
173
248
|
attemptCountRef.current++;
|
|
174
249
|
if (verificationResult) {
|
|
175
250
|
const maxAttempts = overlay?.maxAttempts;
|
|
@@ -12,6 +12,7 @@ export declare class TelemetryReporter {
|
|
|
12
12
|
component?: string;
|
|
13
13
|
error?: unknown;
|
|
14
14
|
errorCode?: string;
|
|
15
|
+
metadata?: Record<string, string | number>;
|
|
15
16
|
}): void;
|
|
16
17
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
17
18
|
flush(): Promise<void>;
|
|
@@ -42,6 +42,7 @@ export class TelemetryReporter {
|
|
|
42
42
|
error_message: errorMessage?.slice(0, 1000),
|
|
43
43
|
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
44
44
|
error_code: opts.errorCode,
|
|
45
|
+
metadata: opts.metadata,
|
|
45
46
|
sdk_platform: Platform.OS,
|
|
46
47
|
sdk_version: SDK_VERSION,
|
|
47
48
|
os_name: Platform.OS,
|
package/package.json
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
CameraView,
|
|
12
12
|
useCameraPermissions,
|
|
13
13
|
} from 'expo-camera';
|
|
14
|
-
import * as ImageManipulator from 'expo-image-manipulator';
|
|
15
14
|
import type {
|
|
16
15
|
VerificationResult,
|
|
17
16
|
ScannerStatus,
|
|
@@ -20,9 +19,16 @@ import type {
|
|
|
20
19
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
21
20
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
22
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;
|
|
28
|
+
|
|
23
29
|
export interface VerifyAIScannerProps {
|
|
24
|
-
/** Called with
|
|
25
|
-
onCapture: (
|
|
30
|
+
/** Called with base64 image data when the user captures a photo. */
|
|
31
|
+
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
26
32
|
/** Called when a terminal verification result is reached. */
|
|
27
33
|
onResult?: (result: VerificationResult) => void;
|
|
28
34
|
/** Called when an error occurs. */
|
|
@@ -72,6 +78,8 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
72
78
|
message = 'Network request failed. Check your connection and try again.';
|
|
73
79
|
} else if (status === 401) {
|
|
74
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.';
|
|
75
83
|
} else if (status === 429) {
|
|
76
84
|
message = 'Verification is temporarily unavailable. Please try again.';
|
|
77
85
|
} else if (status !== undefined && status >= 500) {
|
|
@@ -101,13 +109,10 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
101
109
|
*
|
|
102
110
|
* @example
|
|
103
111
|
* ```tsx
|
|
104
|
-
* const {
|
|
105
|
-
* apiKey: 'vai_...',
|
|
106
|
-
* enableOnDeviceML: true,
|
|
107
|
-
* });
|
|
112
|
+
* const { verify } = useVerifyAI({ apiKey: 'vai_...' });
|
|
108
113
|
*
|
|
109
114
|
* <VerifyAIScanner
|
|
110
|
-
* onCapture={(
|
|
115
|
+
* onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
|
|
111
116
|
* onResult={(result) => {
|
|
112
117
|
* if (result.is_compliant) navigation.navigate('Success');
|
|
113
118
|
* }}
|
|
@@ -230,26 +235,102 @@ export function VerifyAIScanner({
|
|
|
230
235
|
setLastError(null);
|
|
231
236
|
|
|
232
237
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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;
|
|
249
|
+
|
|
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
|
|
255
|
+
}
|
|
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
|
+
});
|
|
237
307
|
|
|
238
|
-
|
|
239
|
-
|
|
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;
|
|
240
317
|
}
|
|
241
318
|
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
});
|
|
250
331
|
|
|
251
332
|
setStatus('processing');
|
|
252
|
-
const verificationResult = await onCapture(
|
|
333
|
+
const verificationResult = await onCapture(base64);
|
|
253
334
|
|
|
254
335
|
attemptCountRef.current++;
|
|
255
336
|
|
|
@@ -7,6 +7,7 @@ interface TelemetryEvent {
|
|
|
7
7
|
error_message?: string;
|
|
8
8
|
error_stack?: string;
|
|
9
9
|
error_code?: string;
|
|
10
|
+
metadata?: Record<string, string | number>;
|
|
10
11
|
sdk_platform: string;
|
|
11
12
|
sdk_version: string;
|
|
12
13
|
os_name: string;
|
|
@@ -51,6 +52,7 @@ export class TelemetryReporter {
|
|
|
51
52
|
component?: string;
|
|
52
53
|
error?: unknown;
|
|
53
54
|
errorCode?: string;
|
|
55
|
+
metadata?: Record<string, string | number>;
|
|
54
56
|
} = {},
|
|
55
57
|
): void {
|
|
56
58
|
if (this.disposed) return;
|
|
@@ -80,6 +82,7 @@ export class TelemetryReporter {
|
|
|
80
82
|
error_message: errorMessage?.slice(0, 1000),
|
|
81
83
|
error_stack: errorObj?.stack?.slice(0, 2000),
|
|
82
84
|
error_code: opts.errorCode,
|
|
85
|
+
metadata: opts.metadata,
|
|
83
86
|
sdk_platform: Platform.OS,
|
|
84
87
|
sdk_version: SDK_VERSION,
|
|
85
88
|
os_name: Platform.OS,
|