@switchlabs/verify-ai-react-native 2.4.11 → 2.4.15
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 +17 -0
- package/lib/components/VerifyAIScanner.d.ts +7 -1
- package/lib/components/VerifyAIScanner.js +349 -32
- package/lib/types/index.d.ts +6 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +443 -41
- package/src/types/index.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -1,15 +1,95 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
3
3
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState, Platform, useWindowDimensions, } from 'react-native';
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
5
|
import { VerifyAIRequestError } from '../client';
|
|
6
6
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
8
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
7
9
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
8
10
|
const FALLBACK_QUALITY = 0.65;
|
|
9
11
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
10
12
|
const MANIPULATOR_QUALITY = 0.8;
|
|
11
13
|
/** Max dimension (px) on longest side when resize is available. */
|
|
12
14
|
const MAX_DIMENSION = 1600;
|
|
15
|
+
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
16
|
+
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
17
|
+
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
18
|
+
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
19
|
+
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
20
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
21
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
22
|
+
function getPolicyScannerDefaults(policy) {
|
|
23
|
+
const id = policy?.toLowerCase() ?? '';
|
|
24
|
+
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
25
|
+
const isBike = isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
|
|
26
|
+
const isScooter = id.includes('scooter');
|
|
27
|
+
if (!isBike && !isScooter)
|
|
28
|
+
return null;
|
|
29
|
+
const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
|
|
30
|
+
return {
|
|
31
|
+
title: 'End Ride Photo',
|
|
32
|
+
instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
|
|
33
|
+
showGuideFrame: true,
|
|
34
|
+
guideFrameAspectRatio: 16 / 9,
|
|
35
|
+
guideOverlayContent: isScooter ? _jsx(ScooterOverlay, {}) : _jsx(BikeOverlay, {}),
|
|
36
|
+
guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
|
|
37
|
+
processingMessage: 'Checking parking compliance...',
|
|
38
|
+
failureMessage: 'Parking issue detected',
|
|
39
|
+
retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function applyPolicyDefaults(overlay, policy) {
|
|
43
|
+
const defaults = getPolicyScannerDefaults(policy);
|
|
44
|
+
if (!defaults)
|
|
45
|
+
return overlay;
|
|
46
|
+
return {
|
|
47
|
+
...overlay,
|
|
48
|
+
title: overlay?.title ?? defaults.title,
|
|
49
|
+
instructions: overlay?.instructions ?? defaults.instructions,
|
|
50
|
+
showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
|
|
51
|
+
guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
|
|
52
|
+
guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
|
|
53
|
+
guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
|
|
54
|
+
processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
|
|
55
|
+
failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
|
|
56
|
+
retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createScannerError(message, code, name) {
|
|
60
|
+
const error = new Error(message);
|
|
61
|
+
error.name = name;
|
|
62
|
+
error.code = code;
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
function sleep(ms) {
|
|
66
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
}
|
|
68
|
+
function isNativeCameraCaptureError(error) {
|
|
69
|
+
const message = error.message.toLowerCase();
|
|
70
|
+
return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
|
|
71
|
+
error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
|
|
72
|
+
message === 'failed to capture photo' ||
|
|
73
|
+
message === 'failed to capture image' ||
|
|
74
|
+
message === 'image could not be captured');
|
|
75
|
+
}
|
|
76
|
+
function normalizeScannerError(err) {
|
|
77
|
+
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
78
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
79
|
+
error.name = 'CameraCaptureError';
|
|
80
|
+
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
81
|
+
}
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
function compactTelemetryMetadata(metadata) {
|
|
85
|
+
const compacted = {};
|
|
86
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
87
|
+
if (value === null || value === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
90
|
+
}
|
|
91
|
+
return compacted;
|
|
92
|
+
}
|
|
13
93
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
14
94
|
if (!error) {
|
|
15
95
|
return {
|
|
@@ -27,9 +107,18 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
27
107
|
else if (code === 'network_error' || status === 0) {
|
|
28
108
|
message = 'Network request failed. Check your connection and try again.';
|
|
29
109
|
}
|
|
110
|
+
else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
111
|
+
message = 'The camera had trouble taking a photo. Please try again.';
|
|
112
|
+
}
|
|
113
|
+
else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
114
|
+
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
115
|
+
}
|
|
30
116
|
else if (status === 401) {
|
|
31
117
|
message = 'Verification is not configured correctly.';
|
|
32
118
|
}
|
|
119
|
+
else if (status === 403) {
|
|
120
|
+
message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
|
|
121
|
+
}
|
|
33
122
|
else if (status === 413) {
|
|
34
123
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
35
124
|
}
|
|
@@ -53,6 +142,14 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
53
142
|
message,
|
|
54
143
|
};
|
|
55
144
|
}
|
|
145
|
+
function isTerminalRequestError(error) {
|
|
146
|
+
const status = error.status ?? error.body?.status;
|
|
147
|
+
return (status === 400 ||
|
|
148
|
+
status === 401 ||
|
|
149
|
+
status === 403 ||
|
|
150
|
+
status === 422 ||
|
|
151
|
+
(status !== undefined && status >= 500));
|
|
152
|
+
}
|
|
56
153
|
/**
|
|
57
154
|
* Camera scanner component for capturing verification photos.
|
|
58
155
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -74,9 +171,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
74
171
|
* />
|
|
75
172
|
* ```
|
|
76
173
|
*/
|
|
77
|
-
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
174
|
+
export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
78
175
|
const contextTelemetry = useTelemetry();
|
|
79
176
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
177
|
+
const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
|
|
80
178
|
const cameraRef = useRef(null);
|
|
81
179
|
const [status, setStatus] = useState('idle');
|
|
82
180
|
const [result, setResult] = useState(null);
|
|
@@ -87,10 +185,18 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
87
185
|
const [terminated, setTerminated] = useState(false);
|
|
88
186
|
const [cameraReady, setCameraReady] = useState(false);
|
|
89
187
|
const [cameraKey, setCameraKey] = useState(0);
|
|
188
|
+
const terminalResultRef = useRef(null);
|
|
189
|
+
const terminalResultTimerRef = useRef(null);
|
|
190
|
+
const terminalResultDeliveredRef = useRef(false);
|
|
90
191
|
const cameraReadyRef = useRef(false);
|
|
91
192
|
const cameraEverReadyRef = useRef(false);
|
|
92
193
|
const cameraInitFailedRef = useRef(false);
|
|
93
194
|
const permissionDeniedTrackedRef = useRef(false);
|
|
195
|
+
const lastCameraReadyAtRef = useRef(null);
|
|
196
|
+
const lastAppStateRemountAtRef = useRef(null);
|
|
197
|
+
const lastOrientationRemountAtRef = useRef(null);
|
|
198
|
+
const lastCaptureRetryAtRef = useRef(null);
|
|
199
|
+
const cameraRemountCountRef = useRef(0);
|
|
94
200
|
// Track dimensions for orientation detection and responsive layout
|
|
95
201
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
96
202
|
const isLandscape = windowWidth > windowHeight;
|
|
@@ -155,6 +261,40 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
155
261
|
return 0;
|
|
156
262
|
}
|
|
157
263
|
})();
|
|
264
|
+
const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
|
|
265
|
+
policy,
|
|
266
|
+
status,
|
|
267
|
+
camera_ready: cameraReadyRef.current,
|
|
268
|
+
camera_ever_ready: cameraEverReadyRef.current,
|
|
269
|
+
camera_init_failed: cameraInitFailedRef.current,
|
|
270
|
+
camera_key: cameraKey,
|
|
271
|
+
camera_remount_count: cameraRemountCountRef.current,
|
|
272
|
+
terminated,
|
|
273
|
+
exhausted,
|
|
274
|
+
app_state: AppState.currentState,
|
|
275
|
+
sdk_platform: Platform.OS,
|
|
276
|
+
window_width: windowWidth,
|
|
277
|
+
window_height: windowHeight,
|
|
278
|
+
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
279
|
+
physical_orientation: physicalOrientation,
|
|
280
|
+
overlay_rotation_deg: overlayRotationDeg,
|
|
281
|
+
last_camera_ready_at: lastCameraReadyAtRef.current,
|
|
282
|
+
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
283
|
+
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
284
|
+
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
285
|
+
...extra,
|
|
286
|
+
}), [
|
|
287
|
+
cameraKey,
|
|
288
|
+
exhausted,
|
|
289
|
+
isLandscape,
|
|
290
|
+
overlayRotationDeg,
|
|
291
|
+
physicalOrientation,
|
|
292
|
+
policy,
|
|
293
|
+
status,
|
|
294
|
+
terminated,
|
|
295
|
+
windowHeight,
|
|
296
|
+
windowWidth,
|
|
297
|
+
]);
|
|
158
298
|
// Detect orientation changes and remount camera after rotation settles.
|
|
159
299
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
160
300
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -164,9 +304,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
164
304
|
const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
|
|
165
305
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
166
306
|
if (orientationChanged && !terminated) {
|
|
307
|
+
const at = new Date().toISOString();
|
|
308
|
+
lastOrientationRemountAtRef.current = at;
|
|
309
|
+
cameraRemountCountRef.current++;
|
|
167
310
|
telemetry?.track('camera_orientation_remount', {
|
|
168
311
|
component: 'scanner',
|
|
169
312
|
metadata: {
|
|
313
|
+
...buildScannerTelemetryMetadata({
|
|
314
|
+
remount_reason: 'orientation_change',
|
|
315
|
+
remount_requested_at: at,
|
|
316
|
+
}),
|
|
170
317
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
171
318
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
172
319
|
},
|
|
@@ -180,17 +327,24 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
180
327
|
}, 400);
|
|
181
328
|
return () => clearTimeout(timer);
|
|
182
329
|
}
|
|
183
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
330
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
184
331
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
185
332
|
useEffect(() => {
|
|
186
333
|
if (terminated)
|
|
187
334
|
return;
|
|
188
335
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
189
336
|
if (nextState === 'active') {
|
|
337
|
+
const at = new Date().toISOString();
|
|
338
|
+
lastAppStateRemountAtRef.current = at;
|
|
339
|
+
cameraRemountCountRef.current++;
|
|
190
340
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
191
341
|
// its preview layer after returning from the notification bar or control center.
|
|
192
342
|
telemetry?.track('camera_appstate_remount', {
|
|
193
343
|
component: 'scanner',
|
|
344
|
+
metadata: buildScannerTelemetryMetadata({
|
|
345
|
+
remount_reason: 'appstate_active',
|
|
346
|
+
remount_requested_at: at,
|
|
347
|
+
}),
|
|
194
348
|
});
|
|
195
349
|
setCameraReady(false);
|
|
196
350
|
cameraReadyRef.current = false;
|
|
@@ -198,7 +352,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
198
352
|
}
|
|
199
353
|
});
|
|
200
354
|
return () => subscription.remove();
|
|
201
|
-
}, [terminated]);
|
|
355
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
202
356
|
const pausePreview = useCallback(() => {
|
|
203
357
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
204
358
|
}, []);
|
|
@@ -208,10 +362,51 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
208
362
|
pausePreview();
|
|
209
363
|
}, [pausePreview]);
|
|
210
364
|
useEffect(() => {
|
|
211
|
-
return
|
|
365
|
+
return () => {
|
|
366
|
+
pausePreview();
|
|
367
|
+
if (terminalResultTimerRef.current) {
|
|
368
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
369
|
+
terminalResultTimerRef.current = null;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
212
372
|
}, [pausePreview]);
|
|
373
|
+
const deliverTerminalResult = useCallback((nextResult) => {
|
|
374
|
+
if (terminalResultDeliveredRef.current)
|
|
375
|
+
return;
|
|
376
|
+
terminalResultDeliveredRef.current = true;
|
|
377
|
+
if (terminalResultTimerRef.current) {
|
|
378
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
379
|
+
terminalResultTimerRef.current = null;
|
|
380
|
+
}
|
|
381
|
+
onResult?.(nextResult);
|
|
382
|
+
}, [onResult]);
|
|
383
|
+
const scheduleTerminalResult = useCallback((nextResult) => {
|
|
384
|
+
terminalResultRef.current = nextResult;
|
|
385
|
+
terminalResultDeliveredRef.current = false;
|
|
386
|
+
if (!onResult)
|
|
387
|
+
return;
|
|
388
|
+
if (terminalResultTimerRef.current) {
|
|
389
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
390
|
+
}
|
|
391
|
+
terminalResultTimerRef.current = setTimeout(() => {
|
|
392
|
+
deliverTerminalResult(nextResult);
|
|
393
|
+
}, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
|
|
394
|
+
}, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
|
|
395
|
+
const deliverVisibleTerminalResult = useCallback(() => {
|
|
396
|
+
const current = terminalResultRef.current ?? result;
|
|
397
|
+
if (current)
|
|
398
|
+
deliverTerminalResult(current);
|
|
399
|
+
}, [deliverTerminalResult, result]);
|
|
400
|
+
const handleClose = useCallback(() => {
|
|
401
|
+
if (terminalResultTimerRef.current) {
|
|
402
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
403
|
+
terminalResultTimerRef.current = null;
|
|
404
|
+
}
|
|
405
|
+
onClose?.(terminalResultRef.current ?? result);
|
|
406
|
+
}, [onClose, result]);
|
|
213
407
|
// Camera init callbacks
|
|
214
408
|
const onCameraReady = useCallback(() => {
|
|
409
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
215
410
|
setCameraReady(true);
|
|
216
411
|
cameraReadyRef.current = true;
|
|
217
412
|
cameraEverReadyRef.current = true;
|
|
@@ -229,15 +424,21 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
229
424
|
telemetry?.track('camera_init_failure', {
|
|
230
425
|
component: 'scanner',
|
|
231
426
|
error,
|
|
427
|
+
metadata: buildScannerTelemetryMetadata({
|
|
428
|
+
mount_error_message: event.message,
|
|
429
|
+
}),
|
|
232
430
|
});
|
|
233
|
-
}, [onError, telemetry]);
|
|
234
|
-
// Startup watchdog — if camera hasn't fired onCameraReady
|
|
431
|
+
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
432
|
+
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
235
433
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
236
434
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
237
435
|
// recreate the CameraView, which starts a fresh native session.
|
|
238
436
|
useEffect(() => {
|
|
239
437
|
if (!permission?.granted || terminated)
|
|
240
438
|
return;
|
|
439
|
+
const watchdogMs = Platform.OS === 'android'
|
|
440
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
441
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
241
442
|
const timer = setTimeout(() => {
|
|
242
443
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
243
444
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -246,18 +447,23 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
246
447
|
// surfacing new information. If the camera truly stays broken the
|
|
247
448
|
// user will see capture failures, which is a more meaningful signal.
|
|
248
449
|
if (!cameraEverReadyRef.current) {
|
|
450
|
+
cameraRemountCountRef.current++;
|
|
249
451
|
telemetry?.track('camera_preview_timeout', {
|
|
250
452
|
component: 'scanner',
|
|
251
|
-
error:
|
|
453
|
+
error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
|
|
454
|
+
metadata: buildScannerTelemetryMetadata({
|
|
455
|
+
watchdog_ms: watchdogMs,
|
|
456
|
+
remount_reason: 'startup_watchdog',
|
|
457
|
+
}),
|
|
252
458
|
});
|
|
253
459
|
setCameraReady(false);
|
|
254
460
|
cameraReadyRef.current = false;
|
|
255
461
|
setCameraKey((k) => k + 1);
|
|
256
462
|
}
|
|
257
463
|
}
|
|
258
|
-
},
|
|
464
|
+
}, watchdogMs);
|
|
259
465
|
return () => clearTimeout(timer);
|
|
260
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
466
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
261
467
|
// Track permission denied
|
|
262
468
|
useEffect(() => {
|
|
263
469
|
if (permission &&
|
|
@@ -265,14 +471,17 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
265
471
|
permission.canAskAgain === false &&
|
|
266
472
|
!permissionDeniedTrackedRef.current) {
|
|
267
473
|
permissionDeniedTrackedRef.current = true;
|
|
268
|
-
telemetry?.track('camera_permission_denied', {
|
|
474
|
+
telemetry?.track('camera_permission_denied', {
|
|
475
|
+
component: 'scanner',
|
|
476
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
477
|
+
});
|
|
269
478
|
}
|
|
270
|
-
}, [permission, telemetry]);
|
|
479
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
271
480
|
const handleCapture = useCallback(async () => {
|
|
272
481
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
273
482
|
return;
|
|
274
483
|
if (!cameraReadyRef.current) {
|
|
275
|
-
const error =
|
|
484
|
+
const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
|
|
276
485
|
setResult(null);
|
|
277
486
|
setLastError(error);
|
|
278
487
|
setStatus('error');
|
|
@@ -280,6 +489,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
280
489
|
telemetry?.track('camera_not_ready', {
|
|
281
490
|
component: 'scanner',
|
|
282
491
|
error,
|
|
492
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
283
493
|
});
|
|
284
494
|
setTimeout(() => setStatus('idle'), 2000);
|
|
285
495
|
return;
|
|
@@ -287,6 +497,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
287
497
|
setStatus('capturing');
|
|
288
498
|
setResult(null);
|
|
289
499
|
setLastError(null);
|
|
500
|
+
terminalResultRef.current = null;
|
|
501
|
+
terminalResultDeliveredRef.current = false;
|
|
502
|
+
if (terminalResultTimerRef.current) {
|
|
503
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
504
|
+
terminalResultTimerRef.current = null;
|
|
505
|
+
}
|
|
506
|
+
let nativeCaptureAttempts = 0;
|
|
507
|
+
let captureRetryAttempted = false;
|
|
508
|
+
let captureRetryReady = false;
|
|
509
|
+
let lastNativeCaptureErrorMessage = null;
|
|
290
510
|
try {
|
|
291
511
|
// --- Capture + best-effort resize ---
|
|
292
512
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
@@ -306,14 +526,58 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
306
526
|
catch {
|
|
307
527
|
// Not installed — fall back to camera-only base64 below
|
|
308
528
|
}
|
|
529
|
+
const waitForCameraReady = async (timeoutMs) => {
|
|
530
|
+
const start = Date.now();
|
|
531
|
+
while (Date.now() - start < timeoutMs) {
|
|
532
|
+
if (cameraReadyRef.current && cameraRef.current) {
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
await sleep(100);
|
|
536
|
+
}
|
|
537
|
+
return cameraReadyRef.current && !!cameraRef.current;
|
|
538
|
+
};
|
|
539
|
+
const takePictureWithRetry = async (options, requiredField) => {
|
|
540
|
+
let lastError = null;
|
|
541
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
542
|
+
nativeCaptureAttempts = attempt;
|
|
543
|
+
try {
|
|
544
|
+
const photo = await cameraRef.current?.takePictureAsync(options);
|
|
545
|
+
if (!photo?.[requiredField]) {
|
|
546
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
547
|
+
}
|
|
548
|
+
return photo;
|
|
549
|
+
}
|
|
550
|
+
catch (captureErr) {
|
|
551
|
+
const normalized = normalizeScannerError(captureErr);
|
|
552
|
+
lastError = normalized;
|
|
553
|
+
lastNativeCaptureErrorMessage = normalized.message;
|
|
554
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
555
|
+
captureRetryAttempted = true;
|
|
556
|
+
const retryAt = new Date().toISOString();
|
|
557
|
+
lastCaptureRetryAtRef.current = retryAt;
|
|
558
|
+
cameraRemountCountRef.current++;
|
|
559
|
+
setCameraReady(false);
|
|
560
|
+
cameraReadyRef.current = false;
|
|
561
|
+
setCameraKey((key) => key + 1);
|
|
562
|
+
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
563
|
+
captureRetryReady = await waitForCameraReady(2500);
|
|
564
|
+
if (captureRetryReady) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw normalized;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
throw lastError ?? createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
572
|
+
};
|
|
309
573
|
if (ImageManipulator) {
|
|
310
574
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
311
|
-
const photo = await
|
|
575
|
+
const photo = await takePictureWithRetry({
|
|
312
576
|
quality: 0.8,
|
|
313
577
|
exif: false,
|
|
314
|
-
});
|
|
578
|
+
}, 'uri');
|
|
315
579
|
if (!photo?.uri) {
|
|
316
|
-
throw
|
|
580
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
317
581
|
}
|
|
318
582
|
origWidth = photo.width ?? 0;
|
|
319
583
|
origHeight = photo.height ?? 0;
|
|
@@ -343,13 +607,13 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
343
607
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
344
608
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
345
609
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
346
|
-
const photo = await
|
|
610
|
+
const photo = await takePictureWithRetry({
|
|
347
611
|
base64: true,
|
|
348
612
|
quality: FALLBACK_QUALITY,
|
|
349
613
|
exif: false,
|
|
350
|
-
});
|
|
614
|
+
}, 'base64');
|
|
351
615
|
if (!photo?.base64) {
|
|
352
|
-
throw
|
|
616
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
353
617
|
}
|
|
354
618
|
origWidth = photo.width ?? 0;
|
|
355
619
|
origHeight = photo.height ?? 0;
|
|
@@ -360,14 +624,19 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
360
624
|
// Best-effort telemetry — never blocks capture
|
|
361
625
|
telemetry?.track('image_processed', {
|
|
362
626
|
component: 'scanner',
|
|
363
|
-
metadata: {
|
|
627
|
+
metadata: buildScannerTelemetryMetadata({
|
|
364
628
|
original_width: origWidth,
|
|
365
629
|
original_height: origHeight,
|
|
366
630
|
processed_width: processedWidth,
|
|
367
631
|
processed_height: processedHeight,
|
|
368
632
|
resized: didResize ? 1 : 0,
|
|
369
633
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
370
|
-
|
|
634
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
635
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
636
|
+
capture_retry_ready: captureRetryReady,
|
|
637
|
+
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
638
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
639
|
+
}),
|
|
371
640
|
});
|
|
372
641
|
setStatus('processing');
|
|
373
642
|
const verificationResult = await onCapture(base64);
|
|
@@ -385,12 +654,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
385
654
|
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
386
655
|
setResult(approvedResult);
|
|
387
656
|
setStatus('success');
|
|
388
|
-
|
|
657
|
+
scheduleTerminalResult(approvedResult);
|
|
389
658
|
}
|
|
390
659
|
else {
|
|
391
660
|
setResult(verificationResult);
|
|
392
661
|
setStatus('error');
|
|
393
|
-
|
|
662
|
+
scheduleTerminalResult(verificationResult);
|
|
394
663
|
}
|
|
395
664
|
return;
|
|
396
665
|
}
|
|
@@ -405,7 +674,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
405
674
|
releaseCamera();
|
|
406
675
|
setResult(verificationResult);
|
|
407
676
|
setStatus('success');
|
|
408
|
-
|
|
677
|
+
scheduleTerminalResult(verificationResult);
|
|
409
678
|
}
|
|
410
679
|
else {
|
|
411
680
|
// null result means queued for offline
|
|
@@ -413,7 +682,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
413
682
|
}
|
|
414
683
|
}
|
|
415
684
|
catch (err) {
|
|
416
|
-
const error = (err
|
|
685
|
+
const error = normalizeScannerError(err);
|
|
686
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
687
|
+
if (terminalRequestError) {
|
|
688
|
+
releaseCamera();
|
|
689
|
+
}
|
|
690
|
+
setResult(null);
|
|
417
691
|
setLastError(error);
|
|
418
692
|
setStatus('error');
|
|
419
693
|
onError?.(error);
|
|
@@ -424,12 +698,23 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
424
698
|
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
425
699
|
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
426
700
|
: isImageFail ? 'image_manipulation_failure'
|
|
427
|
-
: 'unknown_error', {
|
|
701
|
+
: 'unknown_error', {
|
|
702
|
+
component: 'scanner',
|
|
703
|
+
error,
|
|
704
|
+
metadata: buildScannerTelemetryMetadata({
|
|
705
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
706
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
707
|
+
capture_retry_ready: captureRetryReady,
|
|
708
|
+
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
709
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
710
|
+
}),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (!terminalRequestError) {
|
|
714
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
428
715
|
}
|
|
429
|
-
// Reset after a brief pause
|
|
430
|
-
setTimeout(() => setStatus('idle'), 2000);
|
|
431
716
|
}
|
|
432
|
-
}, [status, exhausted, onCapture,
|
|
717
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
433
718
|
// Expose capture to parent via ref
|
|
434
719
|
if (captureRef) {
|
|
435
720
|
captureRef.current = handleCapture;
|
|
@@ -473,9 +758,10 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
473
758
|
? (overlay?.exhaustedMessage || 'Submitted for review')
|
|
474
759
|
: result.is_compliant
|
|
475
760
|
? (overlay?.successMessage || 'Verified')
|
|
476
|
-
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
|
|
761
|
+
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
|
|
477
762
|
let errorTitle;
|
|
478
763
|
let errorMessage;
|
|
764
|
+
let showCloseAction = false;
|
|
479
765
|
if (exhausted) {
|
|
480
766
|
errorTitle = 'Attempts Exhausted';
|
|
481
767
|
errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
|
|
@@ -497,8 +783,9 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
497
783
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
498
784
|
errorTitle = display.title;
|
|
499
785
|
errorMessage = display.message;
|
|
786
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
500
787
|
}
|
|
501
|
-
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
|
|
788
|
+
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
|
|
502
789
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
|
|
503
790
|
styles.instructionsText,
|
|
504
791
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
@@ -507,7 +794,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
507
794
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
508
795
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
509
796
|
styles.captureButtonDisabled,
|
|
510
|
-
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] })] }) }, cameraKey) }));
|
|
797
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey) }));
|
|
511
798
|
}
|
|
512
799
|
const CORNER_SIZE = 30;
|
|
513
800
|
const CORNER_THICKNESS = 3;
|
|
@@ -715,6 +1002,36 @@ const styles = StyleSheet.create({
|
|
|
715
1002
|
textAlign: 'center',
|
|
716
1003
|
lineHeight: 22,
|
|
717
1004
|
},
|
|
1005
|
+
cardButton: {
|
|
1006
|
+
alignSelf: 'stretch',
|
|
1007
|
+
backgroundColor: '#111827',
|
|
1008
|
+
borderRadius: 8,
|
|
1009
|
+
paddingVertical: 12,
|
|
1010
|
+
paddingHorizontal: 16,
|
|
1011
|
+
alignItems: 'center',
|
|
1012
|
+
marginTop: 8,
|
|
1013
|
+
},
|
|
1014
|
+
cardButtonText: {
|
|
1015
|
+
color: '#fff',
|
|
1016
|
+
fontSize: 16,
|
|
1017
|
+
fontWeight: '700',
|
|
1018
|
+
},
|
|
1019
|
+
closeButton: {
|
|
1020
|
+
position: 'absolute',
|
|
1021
|
+
top: 52,
|
|
1022
|
+
right: 16,
|
|
1023
|
+
width: 44,
|
|
1024
|
+
height: 44,
|
|
1025
|
+
borderRadius: 22,
|
|
1026
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
1027
|
+
justifyContent: 'center',
|
|
1028
|
+
alignItems: 'center',
|
|
1029
|
+
},
|
|
1030
|
+
closeButtonText: {
|
|
1031
|
+
color: '#fff',
|
|
1032
|
+
fontSize: 18,
|
|
1033
|
+
fontWeight: '700',
|
|
1034
|
+
},
|
|
718
1035
|
// Permission screen
|
|
719
1036
|
permissionContainer: {
|
|
720
1037
|
justifyContent: 'center',
|
package/lib/types/index.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export interface ScannerOverlayConfig {
|
|
|
122
122
|
maxAttempts?: number;
|
|
123
123
|
autoApproveOnExhaust?: boolean;
|
|
124
124
|
showTechnicalErrorDetails?: boolean;
|
|
125
|
+
/** Delay before terminal results are reported through onResult. Default: 3000. */
|
|
126
|
+
terminalResultDisplayMs?: number;
|
|
127
|
+
/** Label for the terminal result action button. Default: "Continue". */
|
|
128
|
+
terminalActionLabel?: string;
|
|
129
|
+
/** Whether to show an action button on terminal result cards. Default: true. */
|
|
130
|
+
showTerminalActionButton?: boolean;
|
|
125
131
|
/** Custom theme for scanner colors. */
|
|
126
132
|
theme?: ScannerTheme;
|
|
127
133
|
}
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.4.
|
|
1
|
+
export declare const SDK_VERSION = "2.4.15";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.15';
|
package/package.json
CHANGED