@switchlabs/verify-ai-react-native 2.5.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/VerifyAIScanner.js +133 -25
- package/lib/telemetry/TelemetryReporter.js +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +174 -38
- package/src/telemetry/TelemetryReporter.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -24,6 +24,8 @@ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
|
24
24
|
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
25
25
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
26
26
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
27
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
28
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
27
29
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
28
30
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
29
31
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -207,6 +209,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
207
209
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
208
210
|
const [cameraReady, setCameraReady] = useState(false);
|
|
209
211
|
const [cameraKey, setCameraKey] = useState(0);
|
|
212
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
210
213
|
const terminalResultRef = useRef(null);
|
|
211
214
|
const terminalResultTimerRef = useRef(null);
|
|
212
215
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -219,6 +222,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
219
222
|
const lastAppStateRemountAtRef = useRef(null);
|
|
220
223
|
const lastOrientationRemountAtRef = useRef(null);
|
|
221
224
|
const lastCaptureRetryAtRef = useRef(null);
|
|
225
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
226
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
227
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
228
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
229
|
+
const startupRemountTimerRef = useRef(null);
|
|
222
230
|
const cameraRemountCountRef = useRef(0);
|
|
223
231
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
224
232
|
const telemetryRef = useRef(telemetry);
|
|
@@ -260,7 +268,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
260
268
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
261
269
|
camera_init_failed: cameraInitFailedRef.current,
|
|
262
270
|
camera_key: cameraKey,
|
|
271
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
263
272
|
camera_remount_count: cameraRemountCountRef.current,
|
|
273
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
274
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
264
275
|
terminated,
|
|
265
276
|
exhausted,
|
|
266
277
|
app_state: appStateRef.current,
|
|
@@ -278,6 +289,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
278
289
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
279
290
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
280
291
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
292
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
281
293
|
android_native_orientation_subscription_active: 0,
|
|
282
294
|
android_native_orientation_event_count: 0,
|
|
283
295
|
android_native_orientation_change_count: 0,
|
|
@@ -314,7 +326,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
314
326
|
...extra,
|
|
315
327
|
}), [
|
|
316
328
|
cameraKey,
|
|
329
|
+
cameraMounted,
|
|
317
330
|
exhausted,
|
|
331
|
+
enableTorch,
|
|
318
332
|
isLandscape,
|
|
319
333
|
overlayRotationDeg,
|
|
320
334
|
physicalOrientation,
|
|
@@ -358,6 +372,37 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
358
372
|
void activeTelemetry?.flush();
|
|
359
373
|
};
|
|
360
374
|
}, []);
|
|
375
|
+
const requestCameraRemount = useCallback((reason, opts = {}) => {
|
|
376
|
+
if (startupRemountTimerRef.current) {
|
|
377
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
378
|
+
startupRemountTimerRef.current = null;
|
|
379
|
+
}
|
|
380
|
+
setCameraReady(false);
|
|
381
|
+
cameraReadyRef.current = false;
|
|
382
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
383
|
+
if (opts.log) {
|
|
384
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
385
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
|
|
386
|
+
}
|
|
387
|
+
const remount = () => {
|
|
388
|
+
setCameraKey((k) => k + 1);
|
|
389
|
+
setCameraMounted(true);
|
|
390
|
+
startupRemountTimerRef.current = null;
|
|
391
|
+
};
|
|
392
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
393
|
+
setCameraMounted(false);
|
|
394
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
setCameraMounted(true);
|
|
398
|
+
remount();
|
|
399
|
+
}, [enableTorch]);
|
|
400
|
+
useEffect(() => () => {
|
|
401
|
+
if (startupRemountTimerRef.current) {
|
|
402
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
403
|
+
startupRemountTimerRef.current = null;
|
|
404
|
+
}
|
|
405
|
+
}, []);
|
|
361
406
|
useEffect(() => {
|
|
362
407
|
if (Platform.OS !== 'android')
|
|
363
408
|
return;
|
|
@@ -574,16 +619,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
574
619
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
575
620
|
},
|
|
576
621
|
});
|
|
577
|
-
|
|
578
|
-
cameraReadyRef.current = false;
|
|
579
|
-
// Delay remount so iOS rotation animation completes before the new
|
|
580
|
-
// AVCaptureVideoPreviewLayer initializes with the final frame bounds.
|
|
581
|
-
const timer = setTimeout(() => {
|
|
582
|
-
setCameraKey((k) => k + 1);
|
|
583
|
-
}, 400);
|
|
584
|
-
return () => clearTimeout(timer);
|
|
622
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
585
623
|
}
|
|
586
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
624
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
587
625
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
588
626
|
useEffect(() => {
|
|
589
627
|
if (terminated)
|
|
@@ -611,12 +649,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
611
649
|
remount_requested_at: at,
|
|
612
650
|
}),
|
|
613
651
|
});
|
|
614
|
-
|
|
615
|
-
cameraReadyRef.current = false;
|
|
616
|
-
setCameraKey((k) => k + 1);
|
|
652
|
+
requestCameraRemount('appstate_active');
|
|
617
653
|
});
|
|
618
654
|
return () => subscription.remove();
|
|
619
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
655
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
620
656
|
const pausePreview = useCallback(() => {
|
|
621
657
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
622
658
|
}, []);
|
|
@@ -670,13 +706,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
670
706
|
}, [onClose, result]);
|
|
671
707
|
// Camera init callbacks
|
|
672
708
|
const onCameraReady = useCallback(() => {
|
|
673
|
-
|
|
709
|
+
const readyAt = new Date().toISOString();
|
|
710
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
711
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
712
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
674
713
|
setCameraReady(true);
|
|
675
714
|
cameraReadyRef.current = true;
|
|
676
715
|
cameraEverReadyRef.current = true;
|
|
677
716
|
cameraInitFailedRef.current = false;
|
|
678
717
|
startupWatchdogRemountCountRef.current = 0;
|
|
679
|
-
|
|
718
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
719
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
720
|
+
console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
721
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
722
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
723
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
724
|
+
telemetry?.track('camera_ready', {
|
|
725
|
+
component: 'scanner',
|
|
726
|
+
error: 'camera_ready',
|
|
727
|
+
metadata: buildScannerTelemetryMetadata({
|
|
728
|
+
camera_ready_at: readyAt,
|
|
729
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
730
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
731
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
732
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
680
737
|
const onMountError = useCallback((event) => {
|
|
681
738
|
const error = new Error(event.message || 'Camera mount error');
|
|
682
739
|
error.code = CAMERA_INIT_ERROR_CODE;
|
|
@@ -697,12 +754,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
697
754
|
}),
|
|
698
755
|
});
|
|
699
756
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
757
|
+
useEffect(() => {
|
|
758
|
+
if (!permission?.granted ||
|
|
759
|
+
terminated ||
|
|
760
|
+
currentAppState !== 'active' ||
|
|
761
|
+
!cameraMounted ||
|
|
762
|
+
cameraReadyRef.current ||
|
|
763
|
+
cameraInitFailedRef.current ||
|
|
764
|
+
cameraStartupSlowTrackedRef.current) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
if (appStateRef.current !== 'active' ||
|
|
769
|
+
cameraReadyRef.current ||
|
|
770
|
+
cameraInitFailedRef.current ||
|
|
771
|
+
cameraStartupSlowTrackedRef.current) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
775
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
776
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
777
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
778
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
779
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
780
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
781
|
+
telemetry?.track('camera_startup_slow', {
|
|
782
|
+
component: 'scanner',
|
|
783
|
+
error: 'camera_startup_slow',
|
|
784
|
+
metadata: buildScannerTelemetryMetadata({
|
|
785
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
786
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
787
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
788
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
789
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
793
|
+
return () => clearTimeout(timer);
|
|
794
|
+
}, [
|
|
795
|
+
permission?.granted,
|
|
796
|
+
terminated,
|
|
797
|
+
currentAppState,
|
|
798
|
+
cameraMounted,
|
|
799
|
+
buildScannerTelemetryMetadata,
|
|
800
|
+
enableTorch,
|
|
801
|
+
telemetry,
|
|
802
|
+
]);
|
|
700
803
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
701
804
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
702
805
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
703
806
|
// recreate the CameraView, which starts a fresh native session.
|
|
704
807
|
useEffect(() => {
|
|
705
|
-
if (!permission?.granted || terminated || currentAppState !== 'active')
|
|
808
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
|
|
706
809
|
return;
|
|
707
810
|
const watchdogMs = Platform.OS === 'android'
|
|
708
811
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -734,11 +837,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
734
837
|
remount_reason: 'startup_watchdog',
|
|
735
838
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
736
839
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
840
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
841
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
737
842
|
}),
|
|
738
843
|
});
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
844
|
+
requestCameraRemount('startup_watchdog', {
|
|
845
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
846
|
+
log: true,
|
|
847
|
+
});
|
|
742
848
|
}
|
|
743
849
|
}
|
|
744
850
|
}, watchdogMs);
|
|
@@ -747,9 +853,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
747
853
|
permission?.granted,
|
|
748
854
|
terminated,
|
|
749
855
|
currentAppState,
|
|
856
|
+
cameraMounted,
|
|
750
857
|
cameraKey,
|
|
751
858
|
buildScannerTelemetryMetadata,
|
|
859
|
+
enableTorch,
|
|
752
860
|
onMountError,
|
|
861
|
+
requestCameraRemount,
|
|
753
862
|
telemetry,
|
|
754
863
|
]);
|
|
755
864
|
// Track permission denied
|
|
@@ -882,9 +991,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
882
991
|
const retryAt = new Date().toISOString();
|
|
883
992
|
lastCaptureRetryAtRef.current = retryAt;
|
|
884
993
|
cameraRemountCountRef.current++;
|
|
885
|
-
|
|
886
|
-
cameraReadyRef.current = false;
|
|
887
|
-
setCameraKey((key) => key + 1);
|
|
994
|
+
requestCameraRemount('capture_retry');
|
|
888
995
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
889
996
|
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
890
997
|
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
@@ -1056,7 +1163,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1056
1163
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1057
1164
|
}
|
|
1058
1165
|
}
|
|
1059
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1166
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1060
1167
|
// Expose capture to parent via ref
|
|
1061
1168
|
if (captureRef) {
|
|
1062
1169
|
captureRef.current = handleCapture;
|
|
@@ -1068,7 +1175,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1068
1175
|
return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
|
|
1069
1176
|
}
|
|
1070
1177
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1071
|
-
|
|
1178
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
|
|
1179
|
+
return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
|
|
1072
1180
|
setPhysicalOrientation(event.orientation);
|
|
1073
1181
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
1074
1182
|
styles.titleText,
|
|
@@ -1139,7 +1247,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1139
1247
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
1140
1248
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
1141
1249
|
styles.captureButtonDisabled,
|
|
1142
|
-
], 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) }));
|
|
1250
|
+
], 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)) }));
|
|
1143
1251
|
}
|
|
1144
1252
|
const CORNER_SIZE = 30;
|
|
1145
1253
|
const CORNER_THICKNESS = 3;
|
|
@@ -13,6 +13,7 @@ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
|
13
13
|
const CRITICAL_EVENTS = new Set([
|
|
14
14
|
'camera_init_failure',
|
|
15
15
|
'camera_preview_timeout',
|
|
16
|
+
'camera_startup_slow',
|
|
16
17
|
'camera_permission_denied',
|
|
17
18
|
'camera_scanner_mounted',
|
|
18
19
|
'camera_scanner_disposed',
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.1";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.1';
|
package/package.json
CHANGED
|
@@ -49,6 +49,8 @@ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
|
49
49
|
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
50
50
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
51
51
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
52
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
53
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
52
54
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
53
55
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
54
56
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -309,6 +311,7 @@ export function VerifyAIScanner({
|
|
|
309
311
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
310
312
|
const [cameraReady, setCameraReady] = useState(false);
|
|
311
313
|
const [cameraKey, setCameraKey] = useState(0);
|
|
314
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
312
315
|
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
313
316
|
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
314
317
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -321,6 +324,11 @@ export function VerifyAIScanner({
|
|
|
321
324
|
const lastAppStateRemountAtRef = useRef<string | null>(null);
|
|
322
325
|
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
323
326
|
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
327
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
328
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
329
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
330
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
331
|
+
const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
324
332
|
const cameraRemountCountRef = useRef(0);
|
|
325
333
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
326
334
|
const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
|
|
@@ -371,7 +379,10 @@ export function VerifyAIScanner({
|
|
|
371
379
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
372
380
|
camera_init_failed: cameraInitFailedRef.current,
|
|
373
381
|
camera_key: cameraKey,
|
|
382
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
374
383
|
camera_remount_count: cameraRemountCountRef.current,
|
|
384
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
385
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
375
386
|
terminated,
|
|
376
387
|
exhausted,
|
|
377
388
|
app_state: appStateRef.current,
|
|
@@ -389,6 +400,7 @@ export function VerifyAIScanner({
|
|
|
389
400
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
390
401
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
391
402
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
403
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
392
404
|
android_native_orientation_subscription_active: 0,
|
|
393
405
|
android_native_orientation_event_count: 0,
|
|
394
406
|
android_native_orientation_change_count: 0,
|
|
@@ -425,7 +437,9 @@ export function VerifyAIScanner({
|
|
|
425
437
|
...extra,
|
|
426
438
|
}), [
|
|
427
439
|
cameraKey,
|
|
440
|
+
cameraMounted,
|
|
428
441
|
exhausted,
|
|
442
|
+
enableTorch,
|
|
429
443
|
isLandscape,
|
|
430
444
|
overlayRotationDeg,
|
|
431
445
|
physicalOrientation,
|
|
@@ -476,6 +490,52 @@ export function VerifyAIScanner({
|
|
|
476
490
|
};
|
|
477
491
|
}, []);
|
|
478
492
|
|
|
493
|
+
const requestCameraRemount = useCallback((
|
|
494
|
+
reason: string,
|
|
495
|
+
opts: {
|
|
496
|
+
backoffMs?: number;
|
|
497
|
+
log?: boolean;
|
|
498
|
+
} = {},
|
|
499
|
+
) => {
|
|
500
|
+
if (startupRemountTimerRef.current) {
|
|
501
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
502
|
+
startupRemountTimerRef.current = null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setCameraReady(false);
|
|
506
|
+
cameraReadyRef.current = false;
|
|
507
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
508
|
+
|
|
509
|
+
if (opts.log) {
|
|
510
|
+
console.warn(
|
|
511
|
+
`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
512
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const remount = () => {
|
|
517
|
+
setCameraKey((k) => k + 1);
|
|
518
|
+
setCameraMounted(true);
|
|
519
|
+
startupRemountTimerRef.current = null;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
523
|
+
setCameraMounted(false);
|
|
524
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
setCameraMounted(true);
|
|
529
|
+
remount();
|
|
530
|
+
}, [enableTorch]);
|
|
531
|
+
|
|
532
|
+
useEffect(() => () => {
|
|
533
|
+
if (startupRemountTimerRef.current) {
|
|
534
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
535
|
+
startupRemountTimerRef.current = null;
|
|
536
|
+
}
|
|
537
|
+
}, []);
|
|
538
|
+
|
|
479
539
|
useEffect(() => {
|
|
480
540
|
if (Platform.OS !== 'android') return;
|
|
481
541
|
|
|
@@ -728,17 +788,9 @@ export function VerifyAIScanner({
|
|
|
728
788
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
729
789
|
},
|
|
730
790
|
});
|
|
731
|
-
|
|
732
|
-
cameraReadyRef.current = false;
|
|
733
|
-
|
|
734
|
-
// Delay remount so iOS rotation animation completes before the new
|
|
735
|
-
// AVCaptureVideoPreviewLayer initializes with the final frame bounds.
|
|
736
|
-
const timer = setTimeout(() => {
|
|
737
|
-
setCameraKey((k) => k + 1);
|
|
738
|
-
}, 400);
|
|
739
|
-
return () => clearTimeout(timer);
|
|
791
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
740
792
|
}
|
|
741
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
793
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
742
794
|
|
|
743
795
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
744
796
|
useEffect(() => {
|
|
@@ -769,13 +821,11 @@ export function VerifyAIScanner({
|
|
|
769
821
|
remount_requested_at: at,
|
|
770
822
|
}),
|
|
771
823
|
});
|
|
772
|
-
|
|
773
|
-
cameraReadyRef.current = false;
|
|
774
|
-
setCameraKey((k) => k + 1);
|
|
824
|
+
requestCameraRemount('appstate_active');
|
|
775
825
|
});
|
|
776
826
|
|
|
777
827
|
return () => subscription.remove();
|
|
778
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
828
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
779
829
|
|
|
780
830
|
const pausePreview = useCallback(() => {
|
|
781
831
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -836,13 +886,36 @@ export function VerifyAIScanner({
|
|
|
836
886
|
|
|
837
887
|
// Camera init callbacks
|
|
838
888
|
const onCameraReady = useCallback(() => {
|
|
839
|
-
|
|
889
|
+
const readyAt = new Date().toISOString();
|
|
890
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
891
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
892
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
840
893
|
setCameraReady(true);
|
|
841
894
|
cameraReadyRef.current = true;
|
|
842
895
|
cameraEverReadyRef.current = true;
|
|
843
896
|
cameraInitFailedRef.current = false;
|
|
844
897
|
startupWatchdogRemountCountRef.current = 0;
|
|
845
|
-
|
|
898
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
899
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
900
|
+
console.log(
|
|
901
|
+
`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
902
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
903
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
904
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
905
|
+
);
|
|
906
|
+
telemetry?.track('camera_ready', {
|
|
907
|
+
component: 'scanner',
|
|
908
|
+
error: 'camera_ready',
|
|
909
|
+
metadata: buildScannerTelemetryMetadata({
|
|
910
|
+
camera_ready_at: readyAt,
|
|
911
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
912
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
913
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
914
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
915
|
+
}),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
846
919
|
|
|
847
920
|
const onMountError = useCallback((event: { message?: string }) => {
|
|
848
921
|
const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
|
|
@@ -865,12 +938,68 @@ export function VerifyAIScanner({
|
|
|
865
938
|
});
|
|
866
939
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
867
940
|
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
if (
|
|
943
|
+
!permission?.granted ||
|
|
944
|
+
terminated ||
|
|
945
|
+
currentAppState !== 'active' ||
|
|
946
|
+
!cameraMounted ||
|
|
947
|
+
cameraReadyRef.current ||
|
|
948
|
+
cameraInitFailedRef.current ||
|
|
949
|
+
cameraStartupSlowTrackedRef.current
|
|
950
|
+
) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const timer = setTimeout(() => {
|
|
955
|
+
if (
|
|
956
|
+
appStateRef.current !== 'active' ||
|
|
957
|
+
cameraReadyRef.current ||
|
|
958
|
+
cameraInitFailedRef.current ||
|
|
959
|
+
cameraStartupSlowTrackedRef.current
|
|
960
|
+
) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
965
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
966
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
967
|
+
console.warn(
|
|
968
|
+
`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
969
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
970
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
971
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
972
|
+
);
|
|
973
|
+
telemetry?.track('camera_startup_slow', {
|
|
974
|
+
component: 'scanner',
|
|
975
|
+
error: 'camera_startup_slow',
|
|
976
|
+
metadata: buildScannerTelemetryMetadata({
|
|
977
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
978
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
979
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
980
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
981
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
982
|
+
}),
|
|
983
|
+
});
|
|
984
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
985
|
+
|
|
986
|
+
return () => clearTimeout(timer);
|
|
987
|
+
}, [
|
|
988
|
+
permission?.granted,
|
|
989
|
+
terminated,
|
|
990
|
+
currentAppState,
|
|
991
|
+
cameraMounted,
|
|
992
|
+
buildScannerTelemetryMetadata,
|
|
993
|
+
enableTorch,
|
|
994
|
+
telemetry,
|
|
995
|
+
]);
|
|
996
|
+
|
|
868
997
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
869
998
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
870
999
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
871
1000
|
// recreate the CameraView, which starts a fresh native session.
|
|
872
1001
|
useEffect(() => {
|
|
873
|
-
if (!permission?.granted || terminated || currentAppState !== 'active') return;
|
|
1002
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
|
|
874
1003
|
|
|
875
1004
|
const watchdogMs = Platform.OS === 'android'
|
|
876
1005
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -904,11 +1033,14 @@ export function VerifyAIScanner({
|
|
|
904
1033
|
remount_reason: 'startup_watchdog',
|
|
905
1034
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
906
1035
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
1036
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1037
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
907
1038
|
}),
|
|
908
1039
|
});
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1040
|
+
requestCameraRemount('startup_watchdog', {
|
|
1041
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1042
|
+
log: true,
|
|
1043
|
+
});
|
|
912
1044
|
}
|
|
913
1045
|
}
|
|
914
1046
|
}, watchdogMs);
|
|
@@ -918,9 +1050,12 @@ export function VerifyAIScanner({
|
|
|
918
1050
|
permission?.granted,
|
|
919
1051
|
terminated,
|
|
920
1052
|
currentAppState,
|
|
1053
|
+
cameraMounted,
|
|
921
1054
|
cameraKey,
|
|
922
1055
|
buildScannerTelemetryMetadata,
|
|
1056
|
+
enableTorch,
|
|
923
1057
|
onMountError,
|
|
1058
|
+
requestCameraRemount,
|
|
924
1059
|
telemetry,
|
|
925
1060
|
]);
|
|
926
1061
|
|
|
@@ -1077,9 +1212,7 @@ export function VerifyAIScanner({
|
|
|
1077
1212
|
const retryAt = new Date().toISOString();
|
|
1078
1213
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1079
1214
|
cameraRemountCountRef.current++;
|
|
1080
|
-
|
|
1081
|
-
cameraReadyRef.current = false;
|
|
1082
|
-
setCameraKey((key) => key + 1);
|
|
1215
|
+
requestCameraRemount('capture_retry');
|
|
1083
1216
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
1084
1217
|
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1085
1218
|
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
@@ -1285,7 +1418,7 @@ export function VerifyAIScanner({
|
|
|
1285
1418
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1286
1419
|
}
|
|
1287
1420
|
}
|
|
1288
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1421
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1289
1422
|
|
|
1290
1423
|
// Expose capture to parent via ref
|
|
1291
1424
|
if (captureRef) {
|
|
@@ -1308,22 +1441,24 @@ export function VerifyAIScanner({
|
|
|
1308
1441
|
}
|
|
1309
1442
|
|
|
1310
1443
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1444
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
|
|
1311
1445
|
|
|
1312
1446
|
return (
|
|
1313
1447
|
<View style={[styles.container, style]}>
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1448
|
+
{cameraMounted && (
|
|
1449
|
+
<CameraView
|
|
1450
|
+
key={cameraKey}
|
|
1451
|
+
ref={cameraRef}
|
|
1452
|
+
style={styles.camera}
|
|
1453
|
+
facing="back"
|
|
1454
|
+
enableTorch={shouldEnableTorch}
|
|
1455
|
+
onCameraReady={onCameraReady}
|
|
1456
|
+
onMountError={onMountError}
|
|
1457
|
+
responsiveOrientationWhenOrientationLocked
|
|
1458
|
+
onResponsiveOrientationChanged={(event) => {
|
|
1459
|
+
setPhysicalOrientation(event.orientation);
|
|
1460
|
+
}}
|
|
1461
|
+
>
|
|
1327
1462
|
{/* Overlay */}
|
|
1328
1463
|
<View style={styles.overlay}>
|
|
1329
1464
|
{overlay?.title && (
|
|
@@ -1527,7 +1662,8 @@ export function VerifyAIScanner({
|
|
|
1527
1662
|
</TouchableOpacity>
|
|
1528
1663
|
)}
|
|
1529
1664
|
</View>
|
|
1530
|
-
|
|
1665
|
+
</CameraView>
|
|
1666
|
+
)}
|
|
1531
1667
|
</View>
|
|
1532
1668
|
);
|
|
1533
1669
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.1';
|