@switchlabs/verify-ai-react-native 2.4.14 → 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/lib/components/VerifyAIScanner.js +173 -18
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +202 -18
- package/src/version.ts +1 -1
|
@@ -16,6 +16,9 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
|
16
16
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
17
17
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
18
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;
|
|
19
22
|
function getPolicyScannerDefaults(policy) {
|
|
20
23
|
const id = policy?.toLowerCase() ?? '';
|
|
21
24
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -59,14 +62,34 @@ function createScannerError(message, code, name) {
|
|
|
59
62
|
error.code = code;
|
|
60
63
|
return error;
|
|
61
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
|
+
}
|
|
62
76
|
function normalizeScannerError(err) {
|
|
63
77
|
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
64
|
-
if (!error.code && error
|
|
78
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
65
79
|
error.name = 'CameraCaptureError';
|
|
66
80
|
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
67
81
|
}
|
|
68
82
|
return error;
|
|
69
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
|
+
}
|
|
70
93
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
71
94
|
if (!error) {
|
|
72
95
|
return {
|
|
@@ -169,6 +192,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
169
192
|
const cameraEverReadyRef = useRef(false);
|
|
170
193
|
const cameraInitFailedRef = useRef(false);
|
|
171
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);
|
|
172
200
|
// Track dimensions for orientation detection and responsive layout
|
|
173
201
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
174
202
|
const isLandscape = windowWidth > windowHeight;
|
|
@@ -233,6 +261,40 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
233
261
|
return 0;
|
|
234
262
|
}
|
|
235
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
|
+
]);
|
|
236
298
|
// Detect orientation changes and remount camera after rotation settles.
|
|
237
299
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
238
300
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -242,9 +304,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
242
304
|
const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
|
|
243
305
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
244
306
|
if (orientationChanged && !terminated) {
|
|
307
|
+
const at = new Date().toISOString();
|
|
308
|
+
lastOrientationRemountAtRef.current = at;
|
|
309
|
+
cameraRemountCountRef.current++;
|
|
245
310
|
telemetry?.track('camera_orientation_remount', {
|
|
246
311
|
component: 'scanner',
|
|
247
312
|
metadata: {
|
|
313
|
+
...buildScannerTelemetryMetadata({
|
|
314
|
+
remount_reason: 'orientation_change',
|
|
315
|
+
remount_requested_at: at,
|
|
316
|
+
}),
|
|
248
317
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
249
318
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
250
319
|
},
|
|
@@ -258,17 +327,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
258
327
|
}, 400);
|
|
259
328
|
return () => clearTimeout(timer);
|
|
260
329
|
}
|
|
261
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
330
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
262
331
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
263
332
|
useEffect(() => {
|
|
264
333
|
if (terminated)
|
|
265
334
|
return;
|
|
266
335
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
267
336
|
if (nextState === 'active') {
|
|
337
|
+
const at = new Date().toISOString();
|
|
338
|
+
lastAppStateRemountAtRef.current = at;
|
|
339
|
+
cameraRemountCountRef.current++;
|
|
268
340
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
269
341
|
// its preview layer after returning from the notification bar or control center.
|
|
270
342
|
telemetry?.track('camera_appstate_remount', {
|
|
271
343
|
component: 'scanner',
|
|
344
|
+
metadata: buildScannerTelemetryMetadata({
|
|
345
|
+
remount_reason: 'appstate_active',
|
|
346
|
+
remount_requested_at: at,
|
|
347
|
+
}),
|
|
272
348
|
});
|
|
273
349
|
setCameraReady(false);
|
|
274
350
|
cameraReadyRef.current = false;
|
|
@@ -276,7 +352,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
276
352
|
}
|
|
277
353
|
});
|
|
278
354
|
return () => subscription.remove();
|
|
279
|
-
}, [terminated]);
|
|
355
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
280
356
|
const pausePreview = useCallback(() => {
|
|
281
357
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
282
358
|
}, []);
|
|
@@ -330,6 +406,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
330
406
|
}, [onClose, result]);
|
|
331
407
|
// Camera init callbacks
|
|
332
408
|
const onCameraReady = useCallback(() => {
|
|
409
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
333
410
|
setCameraReady(true);
|
|
334
411
|
cameraReadyRef.current = true;
|
|
335
412
|
cameraEverReadyRef.current = true;
|
|
@@ -347,15 +424,21 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
347
424
|
telemetry?.track('camera_init_failure', {
|
|
348
425
|
component: 'scanner',
|
|
349
426
|
error,
|
|
427
|
+
metadata: buildScannerTelemetryMetadata({
|
|
428
|
+
mount_error_message: event.message,
|
|
429
|
+
}),
|
|
350
430
|
});
|
|
351
|
-
}, [onError, telemetry]);
|
|
352
|
-
// 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.
|
|
353
433
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
354
434
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
355
435
|
// recreate the CameraView, which starts a fresh native session.
|
|
356
436
|
useEffect(() => {
|
|
357
437
|
if (!permission?.granted || terminated)
|
|
358
438
|
return;
|
|
439
|
+
const watchdogMs = Platform.OS === 'android'
|
|
440
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
441
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
359
442
|
const timer = setTimeout(() => {
|
|
360
443
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
361
444
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -364,18 +447,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
364
447
|
// surfacing new information. If the camera truly stays broken the
|
|
365
448
|
// user will see capture failures, which is a more meaningful signal.
|
|
366
449
|
if (!cameraEverReadyRef.current) {
|
|
450
|
+
cameraRemountCountRef.current++;
|
|
367
451
|
telemetry?.track('camera_preview_timeout', {
|
|
368
452
|
component: 'scanner',
|
|
369
|
-
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
|
+
}),
|
|
370
458
|
});
|
|
371
459
|
setCameraReady(false);
|
|
372
460
|
cameraReadyRef.current = false;
|
|
373
461
|
setCameraKey((k) => k + 1);
|
|
374
462
|
}
|
|
375
463
|
}
|
|
376
|
-
},
|
|
464
|
+
}, watchdogMs);
|
|
377
465
|
return () => clearTimeout(timer);
|
|
378
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
466
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
379
467
|
// Track permission denied
|
|
380
468
|
useEffect(() => {
|
|
381
469
|
if (permission &&
|
|
@@ -383,9 +471,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
383
471
|
permission.canAskAgain === false &&
|
|
384
472
|
!permissionDeniedTrackedRef.current) {
|
|
385
473
|
permissionDeniedTrackedRef.current = true;
|
|
386
|
-
telemetry?.track('camera_permission_denied', {
|
|
474
|
+
telemetry?.track('camera_permission_denied', {
|
|
475
|
+
component: 'scanner',
|
|
476
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
477
|
+
});
|
|
387
478
|
}
|
|
388
|
-
}, [permission, telemetry]);
|
|
479
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
389
480
|
const handleCapture = useCallback(async () => {
|
|
390
481
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
391
482
|
return;
|
|
@@ -398,6 +489,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
398
489
|
telemetry?.track('camera_not_ready', {
|
|
399
490
|
component: 'scanner',
|
|
400
491
|
error,
|
|
492
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
401
493
|
});
|
|
402
494
|
setTimeout(() => setStatus('idle'), 2000);
|
|
403
495
|
return;
|
|
@@ -411,6 +503,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
411
503
|
clearTimeout(terminalResultTimerRef.current);
|
|
412
504
|
terminalResultTimerRef.current = null;
|
|
413
505
|
}
|
|
506
|
+
let nativeCaptureAttempts = 0;
|
|
507
|
+
let captureRetryAttempted = false;
|
|
508
|
+
let captureRetryReady = false;
|
|
509
|
+
let lastNativeCaptureErrorMessage = null;
|
|
414
510
|
try {
|
|
415
511
|
// --- Capture + best-effort resize ---
|
|
416
512
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
@@ -430,12 +526,56 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
430
526
|
catch {
|
|
431
527
|
// Not installed — fall back to camera-only base64 below
|
|
432
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
|
+
};
|
|
433
573
|
if (ImageManipulator) {
|
|
434
574
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
435
|
-
const photo = await
|
|
575
|
+
const photo = await takePictureWithRetry({
|
|
436
576
|
quality: 0.8,
|
|
437
577
|
exif: false,
|
|
438
|
-
});
|
|
578
|
+
}, 'uri');
|
|
439
579
|
if (!photo?.uri) {
|
|
440
580
|
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
441
581
|
}
|
|
@@ -467,11 +607,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
467
607
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
468
608
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
469
609
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
470
|
-
const photo = await
|
|
610
|
+
const photo = await takePictureWithRetry({
|
|
471
611
|
base64: true,
|
|
472
612
|
quality: FALLBACK_QUALITY,
|
|
473
613
|
exif: false,
|
|
474
|
-
});
|
|
614
|
+
}, 'base64');
|
|
475
615
|
if (!photo?.base64) {
|
|
476
616
|
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
477
617
|
}
|
|
@@ -484,14 +624,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
484
624
|
// Best-effort telemetry — never blocks capture
|
|
485
625
|
telemetry?.track('image_processed', {
|
|
486
626
|
component: 'scanner',
|
|
487
|
-
metadata: {
|
|
627
|
+
metadata: buildScannerTelemetryMetadata({
|
|
488
628
|
original_width: origWidth,
|
|
489
629
|
original_height: origHeight,
|
|
490
630
|
processed_width: processedWidth,
|
|
491
631
|
processed_height: processedHeight,
|
|
492
632
|
resized: didResize ? 1 : 0,
|
|
493
633
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
494
|
-
|
|
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
|
+
}),
|
|
495
640
|
});
|
|
496
641
|
setStatus('processing');
|
|
497
642
|
const verificationResult = await onCapture(base64);
|
|
@@ -553,13 +698,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
553
698
|
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
554
699
|
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
555
700
|
: isImageFail ? 'image_manipulation_failure'
|
|
556
|
-
: '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
|
+
});
|
|
557
712
|
}
|
|
558
713
|
if (!terminalRequestError) {
|
|
559
714
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
560
715
|
}
|
|
561
716
|
}
|
|
562
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
717
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
563
718
|
// Expose capture to parent via ref
|
|
564
719
|
if (captureRef) {
|
|
565
720
|
captureRef.current = handleCapture;
|
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
|
@@ -35,6 +35,9 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
|
35
35
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
36
36
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
37
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;
|
|
38
41
|
|
|
39
42
|
export interface VerifyAIScannerProps {
|
|
40
43
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -74,6 +77,8 @@ type ErrorWithDetails = Error & {
|
|
|
74
77
|
};
|
|
75
78
|
};
|
|
76
79
|
|
|
80
|
+
type ScannerTelemetryMetadata = Record<string, string | number>;
|
|
81
|
+
|
|
77
82
|
function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
|
|
78
83
|
const id = policy?.toLowerCase() ?? '';
|
|
79
84
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -125,15 +130,41 @@ function createScannerError(message: string, code: string, name: string): ErrorW
|
|
|
125
130
|
return error;
|
|
126
131
|
}
|
|
127
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
|
+
|
|
128
148
|
function normalizeScannerError(err: unknown): ErrorWithDetails {
|
|
129
149
|
const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
|
|
130
|
-
if (!error.code && error
|
|
150
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
131
151
|
error.name = 'CameraCaptureError';
|
|
132
152
|
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
133
153
|
}
|
|
134
154
|
return error;
|
|
135
155
|
}
|
|
136
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
|
+
|
|
137
168
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
138
169
|
if (!error) {
|
|
139
170
|
return {
|
|
@@ -255,6 +286,11 @@ export function VerifyAIScanner({
|
|
|
255
286
|
const cameraEverReadyRef = useRef(false);
|
|
256
287
|
const cameraInitFailedRef = useRef(false);
|
|
257
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);
|
|
258
294
|
|
|
259
295
|
// Track dimensions for orientation detection and responsive layout
|
|
260
296
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -329,6 +365,43 @@ export function VerifyAIScanner({
|
|
|
329
365
|
}
|
|
330
366
|
})();
|
|
331
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
|
+
|
|
332
405
|
// Detect orientation changes and remount camera after rotation settles.
|
|
333
406
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
334
407
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -340,9 +413,16 @@ export function VerifyAIScanner({
|
|
|
340
413
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
341
414
|
|
|
342
415
|
if (orientationChanged && !terminated) {
|
|
416
|
+
const at = new Date().toISOString();
|
|
417
|
+
lastOrientationRemountAtRef.current = at;
|
|
418
|
+
cameraRemountCountRef.current++;
|
|
343
419
|
telemetry?.track('camera_orientation_remount', {
|
|
344
420
|
component: 'scanner',
|
|
345
421
|
metadata: {
|
|
422
|
+
...buildScannerTelemetryMetadata({
|
|
423
|
+
remount_reason: 'orientation_change',
|
|
424
|
+
remount_requested_at: at,
|
|
425
|
+
}),
|
|
346
426
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
347
427
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
348
428
|
},
|
|
@@ -357,7 +437,7 @@ export function VerifyAIScanner({
|
|
|
357
437
|
}, 400);
|
|
358
438
|
return () => clearTimeout(timer);
|
|
359
439
|
}
|
|
360
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
440
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
361
441
|
|
|
362
442
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
363
443
|
useEffect(() => {
|
|
@@ -365,10 +445,17 @@ export function VerifyAIScanner({
|
|
|
365
445
|
|
|
366
446
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
367
447
|
if (nextState === 'active') {
|
|
448
|
+
const at = new Date().toISOString();
|
|
449
|
+
lastAppStateRemountAtRef.current = at;
|
|
450
|
+
cameraRemountCountRef.current++;
|
|
368
451
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
369
452
|
// its preview layer after returning from the notification bar or control center.
|
|
370
453
|
telemetry?.track('camera_appstate_remount', {
|
|
371
454
|
component: 'scanner',
|
|
455
|
+
metadata: buildScannerTelemetryMetadata({
|
|
456
|
+
remount_reason: 'appstate_active',
|
|
457
|
+
remount_requested_at: at,
|
|
458
|
+
}),
|
|
372
459
|
});
|
|
373
460
|
setCameraReady(false);
|
|
374
461
|
cameraReadyRef.current = false;
|
|
@@ -377,7 +464,7 @@ export function VerifyAIScanner({
|
|
|
377
464
|
});
|
|
378
465
|
|
|
379
466
|
return () => subscription.remove();
|
|
380
|
-
}, [terminated]);
|
|
467
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
381
468
|
|
|
382
469
|
const pausePreview = useCallback(() => {
|
|
383
470
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -438,6 +525,7 @@ export function VerifyAIScanner({
|
|
|
438
525
|
|
|
439
526
|
// Camera init callbacks
|
|
440
527
|
const onCameraReady = useCallback(() => {
|
|
528
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
441
529
|
setCameraReady(true);
|
|
442
530
|
cameraReadyRef.current = true;
|
|
443
531
|
cameraEverReadyRef.current = true;
|
|
@@ -456,16 +544,23 @@ export function VerifyAIScanner({
|
|
|
456
544
|
telemetry?.track('camera_init_failure', {
|
|
457
545
|
component: 'scanner',
|
|
458
546
|
error,
|
|
547
|
+
metadata: buildScannerTelemetryMetadata({
|
|
548
|
+
mount_error_message: event.message,
|
|
549
|
+
}),
|
|
459
550
|
});
|
|
460
|
-
}, [onError, telemetry]);
|
|
551
|
+
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
461
552
|
|
|
462
|
-
// Startup watchdog — if camera hasn't fired onCameraReady
|
|
553
|
+
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
463
554
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
464
555
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
465
556
|
// recreate the CameraView, which starts a fresh native session.
|
|
466
557
|
useEffect(() => {
|
|
467
558
|
if (!permission?.granted || terminated) return;
|
|
468
559
|
|
|
560
|
+
const watchdogMs = Platform.OS === 'android'
|
|
561
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
562
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
563
|
+
|
|
469
564
|
const timer = setTimeout(() => {
|
|
470
565
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
471
566
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -474,19 +569,24 @@ export function VerifyAIScanner({
|
|
|
474
569
|
// surfacing new information. If the camera truly stays broken the
|
|
475
570
|
// user will see capture failures, which is a more meaningful signal.
|
|
476
571
|
if (!cameraEverReadyRef.current) {
|
|
572
|
+
cameraRemountCountRef.current++;
|
|
477
573
|
telemetry?.track('camera_preview_timeout', {
|
|
478
574
|
component: 'scanner',
|
|
479
|
-
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
|
+
}),
|
|
480
580
|
});
|
|
481
581
|
setCameraReady(false);
|
|
482
582
|
cameraReadyRef.current = false;
|
|
483
583
|
setCameraKey((k) => k + 1);
|
|
484
584
|
}
|
|
485
585
|
}
|
|
486
|
-
},
|
|
586
|
+
}, watchdogMs);
|
|
487
587
|
|
|
488
588
|
return () => clearTimeout(timer);
|
|
489
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
589
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
490
590
|
|
|
491
591
|
// Track permission denied
|
|
492
592
|
useEffect(() => {
|
|
@@ -497,9 +597,12 @@ export function VerifyAIScanner({
|
|
|
497
597
|
!permissionDeniedTrackedRef.current
|
|
498
598
|
) {
|
|
499
599
|
permissionDeniedTrackedRef.current = true;
|
|
500
|
-
telemetry?.track('camera_permission_denied', {
|
|
600
|
+
telemetry?.track('camera_permission_denied', {
|
|
601
|
+
component: 'scanner',
|
|
602
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
603
|
+
});
|
|
501
604
|
}
|
|
502
|
-
}, [permission, telemetry]);
|
|
605
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
503
606
|
|
|
504
607
|
const handleCapture = useCallback(async () => {
|
|
505
608
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
@@ -516,6 +619,7 @@ export function VerifyAIScanner({
|
|
|
516
619
|
telemetry?.track('camera_not_ready', {
|
|
517
620
|
component: 'scanner',
|
|
518
621
|
error,
|
|
622
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
519
623
|
});
|
|
520
624
|
setTimeout(() => setStatus('idle'), 2000);
|
|
521
625
|
return;
|
|
@@ -531,6 +635,11 @@ export function VerifyAIScanner({
|
|
|
531
635
|
terminalResultTimerRef.current = null;
|
|
532
636
|
}
|
|
533
637
|
|
|
638
|
+
let nativeCaptureAttempts = 0;
|
|
639
|
+
let captureRetryAttempted = false;
|
|
640
|
+
let captureRetryReady = false;
|
|
641
|
+
let lastNativeCaptureErrorMessage: string | null = null;
|
|
642
|
+
|
|
534
643
|
try {
|
|
535
644
|
// --- Capture + best-effort resize ---
|
|
536
645
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
@@ -551,12 +660,72 @@ export function VerifyAIScanner({
|
|
|
551
660
|
// Not installed — fall back to camera-only base64 below
|
|
552
661
|
}
|
|
553
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
|
+
|
|
554
723
|
if (ImageManipulator) {
|
|
555
724
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
556
|
-
const photo = await
|
|
725
|
+
const photo = await takePictureWithRetry({
|
|
557
726
|
quality: 0.8,
|
|
558
727
|
exif: false,
|
|
559
|
-
});
|
|
728
|
+
}, 'uri');
|
|
560
729
|
|
|
561
730
|
if (!photo?.uri) {
|
|
562
731
|
throw createScannerError(
|
|
@@ -600,11 +769,11 @@ export function VerifyAIScanner({
|
|
|
600
769
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
601
770
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
602
771
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
603
|
-
const photo = await
|
|
772
|
+
const photo = await takePictureWithRetry({
|
|
604
773
|
base64: true,
|
|
605
774
|
quality: FALLBACK_QUALITY,
|
|
606
775
|
exif: false,
|
|
607
|
-
});
|
|
776
|
+
}, 'base64');
|
|
608
777
|
|
|
609
778
|
if (!photo?.base64) {
|
|
610
779
|
throw createScannerError(
|
|
@@ -624,14 +793,19 @@ export function VerifyAIScanner({
|
|
|
624
793
|
// Best-effort telemetry — never blocks capture
|
|
625
794
|
telemetry?.track('image_processed', {
|
|
626
795
|
component: 'scanner',
|
|
627
|
-
metadata: {
|
|
796
|
+
metadata: buildScannerTelemetryMetadata({
|
|
628
797
|
original_width: origWidth,
|
|
629
798
|
original_height: origHeight,
|
|
630
799
|
processed_width: processedWidth,
|
|
631
800
|
processed_height: processedHeight,
|
|
632
801
|
resized: didResize ? 1 : 0,
|
|
633
802
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
634
|
-
|
|
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
|
+
}),
|
|
635
809
|
});
|
|
636
810
|
|
|
637
811
|
setStatus('processing');
|
|
@@ -699,7 +873,17 @@ export function VerifyAIScanner({
|
|
|
699
873
|
isCaptureFail ? 'capture_failure'
|
|
700
874
|
: isImageFail ? 'image_manipulation_failure'
|
|
701
875
|
: 'unknown_error',
|
|
702
|
-
{
|
|
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
|
+
},
|
|
703
887
|
);
|
|
704
888
|
}
|
|
705
889
|
|
|
@@ -707,7 +891,7 @@ export function VerifyAIScanner({
|
|
|
707
891
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
708
892
|
}
|
|
709
893
|
}
|
|
710
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
894
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
711
895
|
|
|
712
896
|
// Expose capture to parent via ref
|
|
713
897
|
if (captureRef) {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.15';
|