@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,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
import { VerifyAIRequestError } from '../client';
|
|
23
23
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
24
24
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
25
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
26
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
25
27
|
|
|
26
28
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
27
29
|
const FALLBACK_QUALITY = 0.65;
|
|
@@ -29,20 +31,33 @@ const FALLBACK_QUALITY = 0.65;
|
|
|
29
31
|
const MANIPULATOR_QUALITY = 0.8;
|
|
30
32
|
/** Max dimension (px) on longest side when resize is available. */
|
|
31
33
|
const MAX_DIMENSION = 1600;
|
|
34
|
+
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
35
|
+
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
36
|
+
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
37
|
+
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
38
|
+
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
39
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
40
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
32
41
|
|
|
33
42
|
export interface VerifyAIScannerProps {
|
|
34
43
|
/** Called with base64 image data when the user captures a photo. */
|
|
35
44
|
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
45
|
+
/** Optional policy ID used for policy-aware default scanner copy. */
|
|
46
|
+
policy?: string;
|
|
36
47
|
/** Called when a terminal verification result is reached. */
|
|
37
48
|
onResult?: (result: VerificationResult) => void;
|
|
38
49
|
/** Called when an error occurs. */
|
|
39
50
|
onError?: (error: Error) => void;
|
|
51
|
+
/** Called when the user presses the persistent close button. */
|
|
52
|
+
onClose?: (result: VerificationResult | null) => void;
|
|
40
53
|
/** Overlay configuration for the camera view. */
|
|
41
54
|
overlay?: ScannerOverlayConfig;
|
|
42
55
|
/** Custom style for the container. */
|
|
43
56
|
style?: ViewStyle;
|
|
44
57
|
/** Whether to show the default capture button. Set false to use your own. */
|
|
45
58
|
showCaptureButton?: boolean;
|
|
59
|
+
/** Whether to show the persistent close button. */
|
|
60
|
+
showCloseButton?: boolean;
|
|
46
61
|
/** Ref to imperatively trigger capture from parent. */
|
|
47
62
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
48
63
|
/** Whether to enable the camera torch/flashlight. */
|
|
@@ -62,6 +77,94 @@ type ErrorWithDetails = Error & {
|
|
|
62
77
|
};
|
|
63
78
|
};
|
|
64
79
|
|
|
80
|
+
type ScannerTelemetryMetadata = Record<string, string | number>;
|
|
81
|
+
|
|
82
|
+
function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
|
|
83
|
+
const id = policy?.toLowerCase() ?? '';
|
|
84
|
+
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
85
|
+
const isBike =
|
|
86
|
+
isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
|
|
87
|
+
const isScooter = id.includes('scooter');
|
|
88
|
+
|
|
89
|
+
if (!isBike && !isScooter) return null;
|
|
90
|
+
|
|
91
|
+
const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
|
|
92
|
+
return {
|
|
93
|
+
title: 'End Ride Photo',
|
|
94
|
+
instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
|
|
95
|
+
showGuideFrame: true,
|
|
96
|
+
guideFrameAspectRatio: 16 / 9,
|
|
97
|
+
guideOverlayContent: isScooter ? <ScooterOverlay /> : <BikeOverlay />,
|
|
98
|
+
guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
|
|
99
|
+
processingMessage: 'Checking parking compliance...',
|
|
100
|
+
failureMessage: 'Parking issue detected',
|
|
101
|
+
retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function applyPolicyDefaults(
|
|
106
|
+
overlay: ScannerOverlayConfig | undefined,
|
|
107
|
+
policy: string | undefined,
|
|
108
|
+
): ScannerOverlayConfig | undefined {
|
|
109
|
+
const defaults = getPolicyScannerDefaults(policy);
|
|
110
|
+
if (!defaults) return overlay;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...overlay,
|
|
114
|
+
title: overlay?.title ?? defaults.title,
|
|
115
|
+
instructions: overlay?.instructions ?? defaults.instructions,
|
|
116
|
+
showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
|
|
117
|
+
guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
|
|
118
|
+
guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
|
|
119
|
+
guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
|
|
120
|
+
processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
|
|
121
|
+
failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
|
|
122
|
+
retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createScannerError(message: string, code: string, name: string): ErrorWithDetails {
|
|
127
|
+
const error = new Error(message) as ErrorWithDetails;
|
|
128
|
+
error.name = name;
|
|
129
|
+
error.code = code;
|
|
130
|
+
return error;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sleep(ms: number): Promise<void> {
|
|
134
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
|
|
138
|
+
const message = error.message.toLowerCase();
|
|
139
|
+
return (
|
|
140
|
+
error.code === CAMERA_CAPTURE_ERROR_CODE ||
|
|
141
|
+
error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
|
|
142
|
+
message === 'failed to capture photo' ||
|
|
143
|
+
message === 'failed to capture image' ||
|
|
144
|
+
message === 'image could not be captured'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeScannerError(err: unknown): ErrorWithDetails {
|
|
149
|
+
const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
|
|
150
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
151
|
+
error.name = 'CameraCaptureError';
|
|
152
|
+
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
153
|
+
}
|
|
154
|
+
return error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compactTelemetryMetadata(
|
|
158
|
+
metadata: Record<string, string | number | boolean | null | undefined>,
|
|
159
|
+
): ScannerTelemetryMetadata {
|
|
160
|
+
const compacted: ScannerTelemetryMetadata = {};
|
|
161
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
162
|
+
if (value === null || value === undefined) continue;
|
|
163
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
164
|
+
}
|
|
165
|
+
return compacted;
|
|
166
|
+
}
|
|
167
|
+
|
|
65
168
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
66
169
|
if (!error) {
|
|
67
170
|
return {
|
|
@@ -80,8 +183,14 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
80
183
|
message = 'Verification timed out. Please try again.';
|
|
81
184
|
} else if (code === 'network_error' || status === 0) {
|
|
82
185
|
message = 'Network request failed. Check your connection and try again.';
|
|
186
|
+
} else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
187
|
+
message = 'The camera had trouble taking a photo. Please try again.';
|
|
188
|
+
} else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
189
|
+
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
83
190
|
} else if (status === 401) {
|
|
84
191
|
message = 'Verification is not configured correctly.';
|
|
192
|
+
} else if (status === 403) {
|
|
193
|
+
message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
|
|
85
194
|
} else if (status === 413) {
|
|
86
195
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
87
196
|
} else if (status === 429) {
|
|
@@ -107,6 +216,17 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
107
216
|
};
|
|
108
217
|
}
|
|
109
218
|
|
|
219
|
+
function isTerminalRequestError(error: ErrorWithDetails): boolean {
|
|
220
|
+
const status = error.status ?? error.body?.status;
|
|
221
|
+
return (
|
|
222
|
+
status === 400 ||
|
|
223
|
+
status === 401 ||
|
|
224
|
+
status === 403 ||
|
|
225
|
+
status === 422 ||
|
|
226
|
+
(status !== undefined && status >= 500)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
110
230
|
/**
|
|
111
231
|
* Camera scanner component for capturing verification photos.
|
|
112
232
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -130,17 +250,24 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
130
250
|
*/
|
|
131
251
|
export function VerifyAIScanner({
|
|
132
252
|
onCapture,
|
|
253
|
+
policy,
|
|
133
254
|
onResult,
|
|
134
255
|
onError,
|
|
135
|
-
|
|
256
|
+
onClose,
|
|
257
|
+
overlay: overlayProp,
|
|
136
258
|
style,
|
|
137
259
|
showCaptureButton = true,
|
|
260
|
+
showCloseButton = false,
|
|
138
261
|
captureRef,
|
|
139
262
|
enableTorch,
|
|
140
263
|
telemetry: telemetryProp,
|
|
141
264
|
}: VerifyAIScannerProps) {
|
|
142
265
|
const contextTelemetry = useTelemetry();
|
|
143
266
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
267
|
+
const overlay = useMemo(
|
|
268
|
+
() => applyPolicyDefaults(overlayProp, policy),
|
|
269
|
+
[overlayProp, policy],
|
|
270
|
+
);
|
|
144
271
|
|
|
145
272
|
const cameraRef = useRef<CameraView>(null);
|
|
146
273
|
const [status, setStatus] = useState<ScannerStatus>('idle');
|
|
@@ -152,10 +279,18 @@ export function VerifyAIScanner({
|
|
|
152
279
|
const [terminated, setTerminated] = useState(false);
|
|
153
280
|
const [cameraReady, setCameraReady] = useState(false);
|
|
154
281
|
const [cameraKey, setCameraKey] = useState(0);
|
|
282
|
+
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
283
|
+
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
284
|
+
const terminalResultDeliveredRef = useRef(false);
|
|
155
285
|
const cameraReadyRef = useRef(false);
|
|
156
286
|
const cameraEverReadyRef = useRef(false);
|
|
157
287
|
const cameraInitFailedRef = useRef(false);
|
|
158
288
|
const permissionDeniedTrackedRef = useRef(false);
|
|
289
|
+
const lastCameraReadyAtRef = useRef<string | null>(null);
|
|
290
|
+
const lastAppStateRemountAtRef = useRef<string | null>(null);
|
|
291
|
+
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
292
|
+
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
293
|
+
const cameraRemountCountRef = useRef(0);
|
|
159
294
|
|
|
160
295
|
// Track dimensions for orientation detection and responsive layout
|
|
161
296
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -230,6 +365,43 @@ export function VerifyAIScanner({
|
|
|
230
365
|
}
|
|
231
366
|
})();
|
|
232
367
|
|
|
368
|
+
const buildScannerTelemetryMetadata = useCallback((
|
|
369
|
+
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
370
|
+
): ScannerTelemetryMetadata => compactTelemetryMetadata({
|
|
371
|
+
policy,
|
|
372
|
+
status,
|
|
373
|
+
camera_ready: cameraReadyRef.current,
|
|
374
|
+
camera_ever_ready: cameraEverReadyRef.current,
|
|
375
|
+
camera_init_failed: cameraInitFailedRef.current,
|
|
376
|
+
camera_key: cameraKey,
|
|
377
|
+
camera_remount_count: cameraRemountCountRef.current,
|
|
378
|
+
terminated,
|
|
379
|
+
exhausted,
|
|
380
|
+
app_state: AppState.currentState,
|
|
381
|
+
sdk_platform: Platform.OS,
|
|
382
|
+
window_width: windowWidth,
|
|
383
|
+
window_height: windowHeight,
|
|
384
|
+
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
385
|
+
physical_orientation: physicalOrientation,
|
|
386
|
+
overlay_rotation_deg: overlayRotationDeg,
|
|
387
|
+
last_camera_ready_at: lastCameraReadyAtRef.current,
|
|
388
|
+
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
389
|
+
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
390
|
+
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
391
|
+
...extra,
|
|
392
|
+
}), [
|
|
393
|
+
cameraKey,
|
|
394
|
+
exhausted,
|
|
395
|
+
isLandscape,
|
|
396
|
+
overlayRotationDeg,
|
|
397
|
+
physicalOrientation,
|
|
398
|
+
policy,
|
|
399
|
+
status,
|
|
400
|
+
terminated,
|
|
401
|
+
windowHeight,
|
|
402
|
+
windowWidth,
|
|
403
|
+
]);
|
|
404
|
+
|
|
233
405
|
// Detect orientation changes and remount camera after rotation settles.
|
|
234
406
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
235
407
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -241,9 +413,16 @@ export function VerifyAIScanner({
|
|
|
241
413
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
242
414
|
|
|
243
415
|
if (orientationChanged && !terminated) {
|
|
416
|
+
const at = new Date().toISOString();
|
|
417
|
+
lastOrientationRemountAtRef.current = at;
|
|
418
|
+
cameraRemountCountRef.current++;
|
|
244
419
|
telemetry?.track('camera_orientation_remount', {
|
|
245
420
|
component: 'scanner',
|
|
246
421
|
metadata: {
|
|
422
|
+
...buildScannerTelemetryMetadata({
|
|
423
|
+
remount_reason: 'orientation_change',
|
|
424
|
+
remount_requested_at: at,
|
|
425
|
+
}),
|
|
247
426
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
248
427
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
249
428
|
},
|
|
@@ -258,7 +437,7 @@ export function VerifyAIScanner({
|
|
|
258
437
|
}, 400);
|
|
259
438
|
return () => clearTimeout(timer);
|
|
260
439
|
}
|
|
261
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
440
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
262
441
|
|
|
263
442
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
264
443
|
useEffect(() => {
|
|
@@ -266,10 +445,17 @@ export function VerifyAIScanner({
|
|
|
266
445
|
|
|
267
446
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
268
447
|
if (nextState === 'active') {
|
|
448
|
+
const at = new Date().toISOString();
|
|
449
|
+
lastAppStateRemountAtRef.current = at;
|
|
450
|
+
cameraRemountCountRef.current++;
|
|
269
451
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
270
452
|
// its preview layer after returning from the notification bar or control center.
|
|
271
453
|
telemetry?.track('camera_appstate_remount', {
|
|
272
454
|
component: 'scanner',
|
|
455
|
+
metadata: buildScannerTelemetryMetadata({
|
|
456
|
+
remount_reason: 'appstate_active',
|
|
457
|
+
remount_requested_at: at,
|
|
458
|
+
}),
|
|
273
459
|
});
|
|
274
460
|
setCameraReady(false);
|
|
275
461
|
cameraReadyRef.current = false;
|
|
@@ -278,7 +464,7 @@ export function VerifyAIScanner({
|
|
|
278
464
|
});
|
|
279
465
|
|
|
280
466
|
return () => subscription.remove();
|
|
281
|
-
}, [terminated]);
|
|
467
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
282
468
|
|
|
283
469
|
const pausePreview = useCallback(() => {
|
|
284
470
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -291,11 +477,55 @@ export function VerifyAIScanner({
|
|
|
291
477
|
}, [pausePreview]);
|
|
292
478
|
|
|
293
479
|
useEffect(() => {
|
|
294
|
-
return
|
|
480
|
+
return () => {
|
|
481
|
+
pausePreview();
|
|
482
|
+
if (terminalResultTimerRef.current) {
|
|
483
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
484
|
+
terminalResultTimerRef.current = null;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
295
487
|
}, [pausePreview]);
|
|
296
488
|
|
|
489
|
+
const deliverTerminalResult = useCallback((nextResult: VerificationResult) => {
|
|
490
|
+
if (terminalResultDeliveredRef.current) return;
|
|
491
|
+
terminalResultDeliveredRef.current = true;
|
|
492
|
+
if (terminalResultTimerRef.current) {
|
|
493
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
494
|
+
terminalResultTimerRef.current = null;
|
|
495
|
+
}
|
|
496
|
+
onResult?.(nextResult);
|
|
497
|
+
}, [onResult]);
|
|
498
|
+
|
|
499
|
+
const scheduleTerminalResult = useCallback((nextResult: VerificationResult) => {
|
|
500
|
+
terminalResultRef.current = nextResult;
|
|
501
|
+
terminalResultDeliveredRef.current = false;
|
|
502
|
+
if (!onResult) return;
|
|
503
|
+
|
|
504
|
+
if (terminalResultTimerRef.current) {
|
|
505
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
terminalResultTimerRef.current = setTimeout(() => {
|
|
509
|
+
deliverTerminalResult(nextResult);
|
|
510
|
+
}, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
|
|
511
|
+
}, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
|
|
512
|
+
|
|
513
|
+
const deliverVisibleTerminalResult = useCallback(() => {
|
|
514
|
+
const current = terminalResultRef.current ?? result;
|
|
515
|
+
if (current) deliverTerminalResult(current);
|
|
516
|
+
}, [deliverTerminalResult, result]);
|
|
517
|
+
|
|
518
|
+
const handleClose = useCallback(() => {
|
|
519
|
+
if (terminalResultTimerRef.current) {
|
|
520
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
521
|
+
terminalResultTimerRef.current = null;
|
|
522
|
+
}
|
|
523
|
+
onClose?.(terminalResultRef.current ?? result);
|
|
524
|
+
}, [onClose, result]);
|
|
525
|
+
|
|
297
526
|
// Camera init callbacks
|
|
298
527
|
const onCameraReady = useCallback(() => {
|
|
528
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
299
529
|
setCameraReady(true);
|
|
300
530
|
cameraReadyRef.current = true;
|
|
301
531
|
cameraEverReadyRef.current = true;
|
|
@@ -314,16 +544,23 @@ export function VerifyAIScanner({
|
|
|
314
544
|
telemetry?.track('camera_init_failure', {
|
|
315
545
|
component: 'scanner',
|
|
316
546
|
error,
|
|
547
|
+
metadata: buildScannerTelemetryMetadata({
|
|
548
|
+
mount_error_message: event.message,
|
|
549
|
+
}),
|
|
317
550
|
});
|
|
318
|
-
}, [onError, telemetry]);
|
|
551
|
+
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
319
552
|
|
|
320
|
-
// Startup watchdog — if camera hasn't fired onCameraReady
|
|
553
|
+
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
321
554
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
322
555
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
323
556
|
// recreate the CameraView, which starts a fresh native session.
|
|
324
557
|
useEffect(() => {
|
|
325
558
|
if (!permission?.granted || terminated) return;
|
|
326
559
|
|
|
560
|
+
const watchdogMs = Platform.OS === 'android'
|
|
561
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
562
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
563
|
+
|
|
327
564
|
const timer = setTimeout(() => {
|
|
328
565
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
329
566
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -332,19 +569,24 @@ export function VerifyAIScanner({
|
|
|
332
569
|
// surfacing new information. If the camera truly stays broken the
|
|
333
570
|
// user will see capture failures, which is a more meaningful signal.
|
|
334
571
|
if (!cameraEverReadyRef.current) {
|
|
572
|
+
cameraRemountCountRef.current++;
|
|
335
573
|
telemetry?.track('camera_preview_timeout', {
|
|
336
574
|
component: 'scanner',
|
|
337
|
-
error:
|
|
575
|
+
error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
|
|
576
|
+
metadata: buildScannerTelemetryMetadata({
|
|
577
|
+
watchdog_ms: watchdogMs,
|
|
578
|
+
remount_reason: 'startup_watchdog',
|
|
579
|
+
}),
|
|
338
580
|
});
|
|
339
581
|
setCameraReady(false);
|
|
340
582
|
cameraReadyRef.current = false;
|
|
341
583
|
setCameraKey((k) => k + 1);
|
|
342
584
|
}
|
|
343
585
|
}
|
|
344
|
-
},
|
|
586
|
+
}, watchdogMs);
|
|
345
587
|
|
|
346
588
|
return () => clearTimeout(timer);
|
|
347
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
589
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
348
590
|
|
|
349
591
|
// Track permission denied
|
|
350
592
|
useEffect(() => {
|
|
@@ -355,14 +597,21 @@ export function VerifyAIScanner({
|
|
|
355
597
|
!permissionDeniedTrackedRef.current
|
|
356
598
|
) {
|
|
357
599
|
permissionDeniedTrackedRef.current = true;
|
|
358
|
-
telemetry?.track('camera_permission_denied', {
|
|
600
|
+
telemetry?.track('camera_permission_denied', {
|
|
601
|
+
component: 'scanner',
|
|
602
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
603
|
+
});
|
|
359
604
|
}
|
|
360
|
-
}, [permission, telemetry]);
|
|
605
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
361
606
|
|
|
362
607
|
const handleCapture = useCallback(async () => {
|
|
363
608
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
364
609
|
if (!cameraReadyRef.current) {
|
|
365
|
-
const error =
|
|
610
|
+
const error = createScannerError(
|
|
611
|
+
'Camera is not ready yet. Please wait a moment and try again.',
|
|
612
|
+
CAMERA_NOT_READY_ERROR_CODE,
|
|
613
|
+
'CameraNotReadyError',
|
|
614
|
+
);
|
|
366
615
|
setResult(null);
|
|
367
616
|
setLastError(error);
|
|
368
617
|
setStatus('error');
|
|
@@ -370,6 +619,7 @@ export function VerifyAIScanner({
|
|
|
370
619
|
telemetry?.track('camera_not_ready', {
|
|
371
620
|
component: 'scanner',
|
|
372
621
|
error,
|
|
622
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
373
623
|
});
|
|
374
624
|
setTimeout(() => setStatus('idle'), 2000);
|
|
375
625
|
return;
|
|
@@ -378,6 +628,17 @@ export function VerifyAIScanner({
|
|
|
378
628
|
setStatus('capturing');
|
|
379
629
|
setResult(null);
|
|
380
630
|
setLastError(null);
|
|
631
|
+
terminalResultRef.current = null;
|
|
632
|
+
terminalResultDeliveredRef.current = false;
|
|
633
|
+
if (terminalResultTimerRef.current) {
|
|
634
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
635
|
+
terminalResultTimerRef.current = null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let nativeCaptureAttempts = 0;
|
|
639
|
+
let captureRetryAttempted = false;
|
|
640
|
+
let captureRetryReady = false;
|
|
641
|
+
let lastNativeCaptureErrorMessage: string | null = null;
|
|
381
642
|
|
|
382
643
|
try {
|
|
383
644
|
// --- Capture + best-effort resize ---
|
|
@@ -399,15 +660,79 @@ export function VerifyAIScanner({
|
|
|
399
660
|
// Not installed — fall back to camera-only base64 below
|
|
400
661
|
}
|
|
401
662
|
|
|
663
|
+
const waitForCameraReady = async (timeoutMs: number): Promise<boolean> => {
|
|
664
|
+
const start = Date.now();
|
|
665
|
+
while (Date.now() - start < timeoutMs) {
|
|
666
|
+
if (cameraReadyRef.current && cameraRef.current) {
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
await sleep(100);
|
|
670
|
+
}
|
|
671
|
+
return cameraReadyRef.current && !!cameraRef.current;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const takePictureWithRetry = async (
|
|
675
|
+
options: { base64?: boolean; quality?: number; exif?: boolean },
|
|
676
|
+
requiredField: 'uri' | 'base64',
|
|
677
|
+
) => {
|
|
678
|
+
let lastError: ErrorWithDetails | null = null;
|
|
679
|
+
|
|
680
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
681
|
+
nativeCaptureAttempts = attempt;
|
|
682
|
+
try {
|
|
683
|
+
const photo = await cameraRef.current?.takePictureAsync(options);
|
|
684
|
+
if (!photo?.[requiredField]) {
|
|
685
|
+
throw createScannerError(
|
|
686
|
+
'Failed to capture photo',
|
|
687
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
688
|
+
'CameraCaptureError',
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return photo;
|
|
692
|
+
} catch (captureErr) {
|
|
693
|
+
const normalized = normalizeScannerError(captureErr);
|
|
694
|
+
lastError = normalized;
|
|
695
|
+
lastNativeCaptureErrorMessage = normalized.message;
|
|
696
|
+
|
|
697
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
698
|
+
captureRetryAttempted = true;
|
|
699
|
+
const retryAt = new Date().toISOString();
|
|
700
|
+
lastCaptureRetryAtRef.current = retryAt;
|
|
701
|
+
cameraRemountCountRef.current++;
|
|
702
|
+
setCameraReady(false);
|
|
703
|
+
cameraReadyRef.current = false;
|
|
704
|
+
setCameraKey((key) => key + 1);
|
|
705
|
+
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
706
|
+
captureRetryReady = await waitForCameraReady(2500);
|
|
707
|
+
if (captureRetryReady) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
throw normalized;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
throw lastError ?? createScannerError(
|
|
717
|
+
'Failed to capture photo',
|
|
718
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
719
|
+
'CameraCaptureError',
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
402
723
|
if (ImageManipulator) {
|
|
403
724
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
404
|
-
const photo = await
|
|
725
|
+
const photo = await takePictureWithRetry({
|
|
405
726
|
quality: 0.8,
|
|
406
727
|
exif: false,
|
|
407
|
-
});
|
|
728
|
+
}, 'uri');
|
|
408
729
|
|
|
409
730
|
if (!photo?.uri) {
|
|
410
|
-
throw
|
|
731
|
+
throw createScannerError(
|
|
732
|
+
'Failed to capture photo',
|
|
733
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
734
|
+
'CameraCaptureError',
|
|
735
|
+
);
|
|
411
736
|
}
|
|
412
737
|
|
|
413
738
|
origWidth = photo.width ?? 0;
|
|
@@ -444,14 +769,18 @@ export function VerifyAIScanner({
|
|
|
444
769
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
445
770
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
446
771
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
447
|
-
const photo = await
|
|
772
|
+
const photo = await takePictureWithRetry({
|
|
448
773
|
base64: true,
|
|
449
774
|
quality: FALLBACK_QUALITY,
|
|
450
775
|
exif: false,
|
|
451
|
-
});
|
|
776
|
+
}, 'base64');
|
|
452
777
|
|
|
453
778
|
if (!photo?.base64) {
|
|
454
|
-
throw
|
|
779
|
+
throw createScannerError(
|
|
780
|
+
'Failed to capture photo',
|
|
781
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
782
|
+
'CameraCaptureError',
|
|
783
|
+
);
|
|
455
784
|
}
|
|
456
785
|
|
|
457
786
|
origWidth = photo.width ?? 0;
|
|
@@ -464,14 +793,19 @@ export function VerifyAIScanner({
|
|
|
464
793
|
// Best-effort telemetry — never blocks capture
|
|
465
794
|
telemetry?.track('image_processed', {
|
|
466
795
|
component: 'scanner',
|
|
467
|
-
metadata: {
|
|
796
|
+
metadata: buildScannerTelemetryMetadata({
|
|
468
797
|
original_width: origWidth,
|
|
469
798
|
original_height: origHeight,
|
|
470
799
|
processed_width: processedWidth,
|
|
471
800
|
processed_height: processedHeight,
|
|
472
801
|
resized: didResize ? 1 : 0,
|
|
473
802
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
474
|
-
|
|
803
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
804
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
805
|
+
capture_retry_ready: captureRetryReady,
|
|
806
|
+
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
807
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
808
|
+
}),
|
|
475
809
|
});
|
|
476
810
|
|
|
477
811
|
setStatus('processing');
|
|
@@ -493,11 +827,11 @@ export function VerifyAIScanner({
|
|
|
493
827
|
const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
|
|
494
828
|
setResult(approvedResult);
|
|
495
829
|
setStatus('success');
|
|
496
|
-
|
|
830
|
+
scheduleTerminalResult(approvedResult);
|
|
497
831
|
} else {
|
|
498
832
|
setResult(verificationResult);
|
|
499
833
|
setStatus('error');
|
|
500
|
-
|
|
834
|
+
scheduleTerminalResult(verificationResult);
|
|
501
835
|
}
|
|
502
836
|
return;
|
|
503
837
|
}
|
|
@@ -514,13 +848,18 @@ export function VerifyAIScanner({
|
|
|
514
848
|
releaseCamera();
|
|
515
849
|
setResult(verificationResult);
|
|
516
850
|
setStatus('success');
|
|
517
|
-
|
|
851
|
+
scheduleTerminalResult(verificationResult);
|
|
518
852
|
} else {
|
|
519
853
|
// null result means queued for offline
|
|
520
854
|
setStatus('idle');
|
|
521
855
|
}
|
|
522
856
|
} catch (err) {
|
|
523
|
-
const error = (err
|
|
857
|
+
const error = normalizeScannerError(err);
|
|
858
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
859
|
+
if (terminalRequestError) {
|
|
860
|
+
releaseCamera();
|
|
861
|
+
}
|
|
862
|
+
setResult(null);
|
|
524
863
|
setLastError(error);
|
|
525
864
|
setStatus('error');
|
|
526
865
|
onError?.(error);
|
|
@@ -534,14 +873,25 @@ export function VerifyAIScanner({
|
|
|
534
873
|
isCaptureFail ? 'capture_failure'
|
|
535
874
|
: isImageFail ? 'image_manipulation_failure'
|
|
536
875
|
: 'unknown_error',
|
|
537
|
-
{
|
|
876
|
+
{
|
|
877
|
+
component: 'scanner',
|
|
878
|
+
error,
|
|
879
|
+
metadata: buildScannerTelemetryMetadata({
|
|
880
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
881
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
882
|
+
capture_retry_ready: captureRetryReady,
|
|
883
|
+
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
884
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
885
|
+
}),
|
|
886
|
+
},
|
|
538
887
|
);
|
|
539
888
|
}
|
|
540
889
|
|
|
541
|
-
|
|
542
|
-
|
|
890
|
+
if (!terminalRequestError) {
|
|
891
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
892
|
+
}
|
|
543
893
|
}
|
|
544
|
-
}, [status, exhausted, onCapture,
|
|
894
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
545
895
|
|
|
546
896
|
// Expose capture to parent via ref
|
|
547
897
|
if (captureRef) {
|
|
@@ -681,12 +1031,20 @@ export function VerifyAIScanner({
|
|
|
681
1031
|
</Text>
|
|
682
1032
|
</View>
|
|
683
1033
|
<Text style={styles.feedbackText}>{result.feedback}</Text>
|
|
1034
|
+
{overlay?.showTerminalActionButton !== false && onResult && (
|
|
1035
|
+
<TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
|
|
1036
|
+
<Text style={styles.cardButtonText}>
|
|
1037
|
+
{overlay?.terminalActionLabel || 'Continue'}
|
|
1038
|
+
</Text>
|
|
1039
|
+
</TouchableOpacity>
|
|
1040
|
+
)}
|
|
684
1041
|
</View>
|
|
685
1042
|
)}
|
|
686
1043
|
|
|
687
1044
|
{status === 'error' && (() => {
|
|
688
1045
|
let errorTitle: string;
|
|
689
1046
|
let errorMessage: string;
|
|
1047
|
+
let showCloseAction = false;
|
|
690
1048
|
|
|
691
1049
|
if (exhausted) {
|
|
692
1050
|
errorTitle = 'Attempts Exhausted';
|
|
@@ -706,6 +1064,7 @@ export function VerifyAIScanner({
|
|
|
706
1064
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
707
1065
|
errorTitle = display.title;
|
|
708
1066
|
errorMessage = display.message;
|
|
1067
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
709
1068
|
}
|
|
710
1069
|
|
|
711
1070
|
return (
|
|
@@ -718,9 +1077,12 @@ export function VerifyAIScanner({
|
|
|
718
1077
|
{errorTitle}
|
|
719
1078
|
</Text>
|
|
720
1079
|
</View>
|
|
721
|
-
<Text style={styles.feedbackText}>
|
|
722
|
-
|
|
723
|
-
|
|
1080
|
+
<Text style={styles.feedbackText}>{errorMessage}</Text>
|
|
1081
|
+
{showCloseAction && (
|
|
1082
|
+
<TouchableOpacity style={styles.cardButton} onPress={handleClose}>
|
|
1083
|
+
<Text style={styles.cardButtonText}>Close</Text>
|
|
1084
|
+
</TouchableOpacity>
|
|
1085
|
+
)}
|
|
724
1086
|
</View>
|
|
725
1087
|
);
|
|
726
1088
|
})()}
|
|
@@ -757,6 +1119,16 @@ export function VerifyAIScanner({
|
|
|
757
1119
|
</>
|
|
758
1120
|
)}
|
|
759
1121
|
</View>
|
|
1122
|
+
{showCloseButton && onClose && (
|
|
1123
|
+
<TouchableOpacity
|
|
1124
|
+
accessibilityRole="button"
|
|
1125
|
+
accessibilityLabel="Close scanner"
|
|
1126
|
+
style={styles.closeButton}
|
|
1127
|
+
onPress={handleClose}
|
|
1128
|
+
>
|
|
1129
|
+
<Text style={styles.closeButtonText}>X</Text>
|
|
1130
|
+
</TouchableOpacity>
|
|
1131
|
+
)}
|
|
760
1132
|
</View>
|
|
761
1133
|
</CameraView>
|
|
762
1134
|
</View>
|
|
@@ -968,15 +1340,45 @@ const styles = StyleSheet.create({
|
|
|
968
1340
|
resultLabelExhausted: {
|
|
969
1341
|
color: '#92400e',
|
|
970
1342
|
},
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1343
|
+
feedbackText: {
|
|
1344
|
+
color: '#4b5563',
|
|
1345
|
+
fontSize: 15,
|
|
1346
|
+
textAlign: 'center',
|
|
1347
|
+
lineHeight: 22,
|
|
1348
|
+
},
|
|
1349
|
+
cardButton: {
|
|
1350
|
+
alignSelf: 'stretch',
|
|
1351
|
+
backgroundColor: '#111827',
|
|
1352
|
+
borderRadius: 8,
|
|
1353
|
+
paddingVertical: 12,
|
|
1354
|
+
paddingHorizontal: 16,
|
|
1355
|
+
alignItems: 'center',
|
|
1356
|
+
marginTop: 8,
|
|
1357
|
+
},
|
|
1358
|
+
cardButtonText: {
|
|
1359
|
+
color: '#fff',
|
|
1360
|
+
fontSize: 16,
|
|
1361
|
+
fontWeight: '700',
|
|
1362
|
+
},
|
|
1363
|
+
closeButton: {
|
|
1364
|
+
position: 'absolute',
|
|
1365
|
+
top: 52,
|
|
1366
|
+
right: 16,
|
|
1367
|
+
width: 44,
|
|
1368
|
+
height: 44,
|
|
1369
|
+
borderRadius: 22,
|
|
1370
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
1371
|
+
justifyContent: 'center',
|
|
1372
|
+
alignItems: 'center',
|
|
1373
|
+
},
|
|
1374
|
+
closeButtonText: {
|
|
1375
|
+
color: '#fff',
|
|
1376
|
+
fontSize: 18,
|
|
1377
|
+
fontWeight: '700',
|
|
1378
|
+
},
|
|
1379
|
+
|
|
1380
|
+
// Permission screen
|
|
1381
|
+
permissionContainer: {
|
|
980
1382
|
justifyContent: 'center',
|
|
981
1383
|
alignItems: 'center',
|
|
982
1384
|
paddingHorizontal: 40,
|