@switchlabs/verify-ai-react-native 2.5.6 → 2.5.8
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 +27 -1
- package/lib/client/index.js +34 -10
- package/lib/components/VerifyAIScanner.js +90 -22
- package/lib/components/cameraCaptureRetry.d.ts +9 -0
- package/lib/components/cameraCaptureRetry.js +21 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +15 -6
- package/src/client/index.ts +34 -11
- package/src/components/VerifyAIScanner.tsx +88 -23
- package/src/components/cameraCaptureRetry.ts +35 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# @switchlabs/verify-ai-react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send a photo and a policy, get back a pass/fail verdict with a confidence
|
|
4
|
+
value, the criteria that failed, and human-readable reasoning. Built for
|
|
5
|
+
end-of-ride parking compliance, vehicle and equipment condition and damage
|
|
6
|
+
grading, proof of delivery, and rental return checks.
|
|
7
|
+
|
|
8
|
+
The SDK wraps live camera capture (no gallery-picker path in the scanner
|
|
9
|
+
flow), on-device pre-screening, an offline queue, and the REST API.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
const result = await verify({ image: base64, policy: 'scooter_parking' });
|
|
13
|
+
if (result && !result.is_compliant) {
|
|
14
|
+
console.log(result.violation_reasons, result.feedback);
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Try it before you install anything:** upload a photo at
|
|
19
|
+
[verify.switchlabs.dev/demo](https://verify.switchlabs.dev/demo). No account,
|
|
20
|
+
no card.
|
|
21
|
+
|
|
22
|
+
**Get a key:** [free sandbox](https://verify.switchlabs.dev/login?mode=signup)
|
|
23
|
+
with $5 of credit and no credit card. Keys start with `vai_` and are sent in an
|
|
24
|
+
`X-API-Key` header.
|
|
25
|
+
|
|
26
|
+
**Pricing:** $0.008 per standard verification for the first 100,000 in a
|
|
27
|
+
calendar month, $0.006 from 100,001 to 1,000,000, and $0.005 above 1,000,000.
|
|
28
|
+
No platform fee, no monthly minimum, no annual contract.
|
|
29
|
+
[Full pricing](https://verify.switchlabs.dev/pricing).
|
|
4
30
|
|
|
5
31
|
## Install
|
|
6
32
|
|
package/lib/client/index.js
CHANGED
|
@@ -2,6 +2,13 @@ import { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
|
2
2
|
import { SDK_VERSION } from '../version';
|
|
3
3
|
const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
|
|
4
4
|
const DEFAULT_TIMEOUT = 60000;
|
|
5
|
+
const MAX_IN_PROGRESS_RETRIES = 10;
|
|
6
|
+
function retryAfterMs(value) {
|
|
7
|
+
if (value !== null && /^\d+$/.test(value.trim()))
|
|
8
|
+
return Number(value) * 1000;
|
|
9
|
+
const date = value ? Date.parse(value) : NaN;
|
|
10
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 1000;
|
|
11
|
+
}
|
|
5
12
|
const VEHICLE_TYPE_LABELS = {
|
|
6
13
|
scooter: 'Scooter',
|
|
7
14
|
'e-bike': 'E-Bike',
|
|
@@ -150,17 +157,34 @@ export class VerifyAIClient {
|
|
|
150
157
|
const method = (options.method || 'GET').toUpperCase();
|
|
151
158
|
const url = `${this.baseUrl}${path}`;
|
|
152
159
|
const context = { path, url, method };
|
|
160
|
+
const deadline = Date.now() + this.timeout;
|
|
153
161
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...options
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
let response;
|
|
163
|
+
let body;
|
|
164
|
+
let retries = 0;
|
|
165
|
+
while (true) {
|
|
166
|
+
response = await fetch(url, {
|
|
167
|
+
...options,
|
|
168
|
+
signal: controller.signal,
|
|
169
|
+
headers: {
|
|
170
|
+
'X-API-Key': this.apiKey,
|
|
171
|
+
...options.headers,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const rawBody = await response.text();
|
|
175
|
+
body = this.parseResponseBody(rawBody);
|
|
176
|
+
const code = body && typeof body === 'object' ? body.code : undefined;
|
|
177
|
+
const key = new Headers(options.headers).get('Idempotency-Key');
|
|
178
|
+
const delay = retryAfterMs(response.headers.get('Retry-After'));
|
|
179
|
+
// Only replay an identical, explicitly keyed verification reservation.
|
|
180
|
+
// Keep one timeout budget and report errors only after retries exhaust.
|
|
181
|
+
if (path !== '/verify' || method !== 'POST' || !key || response.status !== 409 ||
|
|
182
|
+
code !== 'idempotency_in_progress' || retries >= MAX_IN_PROGRESS_RETRIES ||
|
|
183
|
+
delay >= deadline - Date.now() || controller.signal.aborted)
|
|
184
|
+
break;
|
|
185
|
+
retries += 1;
|
|
186
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
187
|
+
}
|
|
164
188
|
if (!response.ok) {
|
|
165
189
|
const errorBody = (body && typeof body === 'object' ? body : null);
|
|
166
190
|
throw this.buildRequestError(stringifyErrorMessage(errorBody?.error, `Request failed with status ${response.status}`), response.status, context, {
|
|
@@ -8,7 +8,7 @@ import { SDK_VERSION } from '../version';
|
|
|
8
8
|
import { BikeOverlay } from './BikeOverlay';
|
|
9
9
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
10
10
|
import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
|
|
11
|
-
import { CAMERA_CAPTURE_RETRY_DELAY_MS, getCameraCaptureRetryPlan, } from './cameraCaptureRetry';
|
|
11
|
+
import { CAMERA_CAPTURE_RETRY_DELAY_MS, getCameraCaptureRetryPlan, waitForCameraCaptureRetry, } from './cameraCaptureRetry';
|
|
12
12
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
13
13
|
const FALLBACK_QUALITY = 0.65;
|
|
14
14
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
@@ -79,9 +79,6 @@ function createScannerError(message, code, name) {
|
|
|
79
79
|
error.code = code;
|
|
80
80
|
return error;
|
|
81
81
|
}
|
|
82
|
-
function sleep(ms) {
|
|
83
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
-
}
|
|
85
82
|
function createScannerSessionId() {
|
|
86
83
|
return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
87
84
|
}
|
|
@@ -224,6 +221,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
224
221
|
const terminalResultDeliveredRef = useRef(false);
|
|
225
222
|
const appStateRef = useRef(AppState.currentState);
|
|
226
223
|
const cameraReadyRef = useRef(false);
|
|
224
|
+
const cameraGenerationRef = useRef(0);
|
|
225
|
+
const scannerMountedRef = useRef(true);
|
|
226
|
+
const terminatedRef = useRef(false);
|
|
227
|
+
const renderedCameraGeneration = cameraGenerationRef.current;
|
|
227
228
|
const cameraEverReadyRef = useRef(false);
|
|
228
229
|
const cameraInitFailedRef = useRef(false);
|
|
229
230
|
const permissionDeniedTrackedRef = useRef(false);
|
|
@@ -365,6 +366,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
365
366
|
setTorchRetrySuppressed(nextSuppressed);
|
|
366
367
|
}, []);
|
|
367
368
|
useEffect(() => {
|
|
369
|
+
scannerMountedRef.current = true;
|
|
368
370
|
const reporter = telemetryRef.current;
|
|
369
371
|
if (reporter) {
|
|
370
372
|
console.log(`VerifyAI[${SDK_VERSION}]: scanner telemetry attached ` +
|
|
@@ -385,6 +387,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
385
387
|
}),
|
|
386
388
|
});
|
|
387
389
|
return () => {
|
|
390
|
+
scannerMountedRef.current = false;
|
|
391
|
+
cameraReadyRef.current = false;
|
|
392
|
+
if (startupRemountTimerRef.current)
|
|
393
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
388
394
|
const activeTelemetry = telemetryRef.current;
|
|
389
395
|
activeTelemetry?.track('camera_scanner_disposed', {
|
|
390
396
|
component: 'scanner',
|
|
@@ -401,6 +407,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
401
407
|
}
|
|
402
408
|
setCameraReady(false);
|
|
403
409
|
cameraReadyRef.current = false;
|
|
410
|
+
const generation = ++cameraGenerationRef.current;
|
|
404
411
|
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
405
412
|
if (opts.log) {
|
|
406
413
|
console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
@@ -408,6 +415,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
408
415
|
`torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`);
|
|
409
416
|
}
|
|
410
417
|
const remount = () => {
|
|
418
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || cameraGenerationRef.current !== generation)
|
|
419
|
+
return;
|
|
411
420
|
setCameraKey((k) => k + 1);
|
|
412
421
|
setCameraMounted(true);
|
|
413
422
|
startupRemountTimerRef.current = null;
|
|
@@ -688,6 +697,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
688
697
|
}, []);
|
|
689
698
|
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
690
699
|
const releaseCamera = useCallback(() => {
|
|
700
|
+
terminatedRef.current = true;
|
|
701
|
+
cameraReadyRef.current = false;
|
|
702
|
+
cameraGenerationRef.current++;
|
|
691
703
|
setTerminated(true);
|
|
692
704
|
pausePreview();
|
|
693
705
|
}, [pausePreview]);
|
|
@@ -735,7 +747,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
735
747
|
onClose?.(terminalResultRef.current ?? result);
|
|
736
748
|
}, [onClose, result]);
|
|
737
749
|
// Camera init callbacks
|
|
738
|
-
const onCameraReady = useCallback(() => {
|
|
750
|
+
const onCameraReady = useCallback((generation) => {
|
|
751
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || generation !== cameraGenerationRef.current)
|
|
752
|
+
return;
|
|
739
753
|
const readyAt = new Date().toISOString();
|
|
740
754
|
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
741
755
|
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
@@ -968,6 +982,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
968
982
|
setTimeout(() => setStatus('idle'), 2000);
|
|
969
983
|
return;
|
|
970
984
|
}
|
|
985
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
986
|
+
captureIntentPendingRef.current = false;
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
971
989
|
setStatus('capturing');
|
|
972
990
|
setTorchSuppressedForRetry(false);
|
|
973
991
|
setResult(null);
|
|
@@ -978,7 +996,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
978
996
|
clearTimeout(terminalResultTimerRef.current);
|
|
979
997
|
terminalResultTimerRef.current = null;
|
|
980
998
|
}
|
|
999
|
+
let captureCancelled = false;
|
|
981
1000
|
let nativeCaptureAttempts = 0;
|
|
1001
|
+
let nativeCaptureElapsedMs = null;
|
|
1002
|
+
let nativeCaptureReadyAgeMs = null;
|
|
1003
|
+
let firstNativeCaptureElapsedMs = null;
|
|
1004
|
+
let firstNativeCaptureReadyAgeMs = null;
|
|
982
1005
|
let captureRetryAttempted = false;
|
|
983
1006
|
let captureRetryReady = false;
|
|
984
1007
|
let captureRetryTorchSuppressed = false;
|
|
@@ -1025,39 +1048,59 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1025
1048
|
catch {
|
|
1026
1049
|
// Not installed — fall back to camera-only base64 below
|
|
1027
1050
|
}
|
|
1028
|
-
const waitForCameraReady = async (timeoutMs) => {
|
|
1029
|
-
const start = Date.now();
|
|
1030
|
-
while (Date.now() - start < timeoutMs) {
|
|
1031
|
-
if (cameraReadyRef.current && cameraRef.current) {
|
|
1032
|
-
return true;
|
|
1033
|
-
}
|
|
1034
|
-
await sleep(100);
|
|
1035
|
-
}
|
|
1036
|
-
return cameraReadyRef.current && !!cameraRef.current;
|
|
1037
|
-
};
|
|
1038
1051
|
const takePictureWithRetry = async (options, requiredField) => {
|
|
1039
1052
|
let lastError = null;
|
|
1053
|
+
let preparedRetryCamera = null;
|
|
1054
|
+
let preparedRetryGeneration = null;
|
|
1040
1055
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1056
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
1057
|
+
captureCancelled = true;
|
|
1058
|
+
throw createScannerError('Camera capture cancelled', CAMERA_NOT_READY_ERROR_CODE, 'CameraCaptureCancelled');
|
|
1059
|
+
}
|
|
1060
|
+
const attemptCamera = cameraRef.current;
|
|
1061
|
+
if (!attemptCamera || !cameraReadyRef.current ||
|
|
1062
|
+
(attempt === 2 && (attemptCamera !== preparedRetryCamera || cameraGenerationRef.current !== preparedRetryGeneration))) {
|
|
1063
|
+
throw lastError ?? createScannerError('Camera is not ready for capture', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReady');
|
|
1064
|
+
}
|
|
1065
|
+
const attemptStartedAt = Date.now();
|
|
1066
|
+
const attemptGeneration = cameraGenerationRef.current;
|
|
1067
|
+
nativeCaptureReadyAgeMs = lastCameraReadyAtRef.current ? attemptStartedAt - Date.parse(lastCameraReadyAtRef.current) : null;
|
|
1041
1068
|
nativeCaptureAttempts = attempt;
|
|
1042
1069
|
try {
|
|
1043
|
-
const photo = await
|
|
1070
|
+
const photo = await attemptCamera?.takePictureAsync(options);
|
|
1071
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' ||
|
|
1072
|
+
cameraGenerationRef.current !== attemptGeneration || cameraRef.current !== attemptCamera) {
|
|
1073
|
+
captureCancelled = true;
|
|
1074
|
+
throw createScannerError('Camera capture cancelled', CAMERA_NOT_READY_ERROR_CODE, 'CameraCaptureCancelled');
|
|
1075
|
+
}
|
|
1076
|
+
nativeCaptureElapsedMs = Date.now() - attemptStartedAt;
|
|
1077
|
+
if (attempt === 1) {
|
|
1078
|
+
firstNativeCaptureElapsedMs = nativeCaptureElapsedMs;
|
|
1079
|
+
firstNativeCaptureReadyAgeMs = nativeCaptureReadyAgeMs;
|
|
1080
|
+
}
|
|
1044
1081
|
if (!photo?.[requiredField]) {
|
|
1045
1082
|
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
1046
1083
|
}
|
|
1047
1084
|
return photo;
|
|
1048
1085
|
}
|
|
1049
1086
|
catch (captureErr) {
|
|
1087
|
+
nativeCaptureElapsedMs = Date.now() - attemptStartedAt;
|
|
1088
|
+
if (attempt === 1) {
|
|
1089
|
+
firstNativeCaptureElapsedMs = nativeCaptureElapsedMs;
|
|
1090
|
+
firstNativeCaptureReadyAgeMs = nativeCaptureReadyAgeMs;
|
|
1091
|
+
}
|
|
1050
1092
|
const normalized = normalizeScannerError(captureErr);
|
|
1051
1093
|
lastError = normalized;
|
|
1052
1094
|
lastNativeCaptureErrorMessage = normalized.message;
|
|
1053
|
-
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !
|
|
1095
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && scannerMountedRef.current && !terminatedRef.current && appStateRef.current === 'active') {
|
|
1054
1096
|
captureRetryAttempted = true;
|
|
1055
1097
|
const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
|
|
1056
1098
|
captureRetryTorchSuppressed = retryPlan.suppressTorch;
|
|
1057
1099
|
captureRetrySettleDelayMs = retryPlan.settleDelayMs;
|
|
1058
1100
|
captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
|
|
1059
1101
|
captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
|
|
1060
|
-
const
|
|
1102
|
+
const retryRequestedAt = Date.now();
|
|
1103
|
+
const retryAt = new Date(retryRequestedAt).toISOString();
|
|
1061
1104
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1062
1105
|
cameraRemountCountRef.current++;
|
|
1063
1106
|
if (captureRetryTorchSuppressed) {
|
|
@@ -1066,8 +1109,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1066
1109
|
requestCameraRemount('capture_retry', captureRetryRemountBackoffMs > 0
|
|
1067
1110
|
? { backoffMs: captureRetryRemountBackoffMs }
|
|
1068
1111
|
: undefined);
|
|
1069
|
-
|
|
1070
|
-
|
|
1112
|
+
const retryGeneration = cameraGenerationRef.current;
|
|
1113
|
+
const retryCamera = await waitForCameraCaptureRetry(() => ({ camera: cameraRef.current, generation: cameraGenerationRef.current,
|
|
1114
|
+
ready: cameraReadyRef.current, active: scannerMountedRef.current && !terminatedRef.current && appStateRef.current === 'active' }), attemptCamera, retryGeneration, retryRequestedAt, retryPlan);
|
|
1115
|
+
captureRetryReady = !!retryCamera;
|
|
1116
|
+
preparedRetryCamera = retryCamera;
|
|
1117
|
+
preparedRetryGeneration = cameraGenerationRef.current;
|
|
1118
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || cameraGenerationRef.current !== retryGeneration) {
|
|
1119
|
+
captureCancelled = true;
|
|
1120
|
+
throw normalized;
|
|
1121
|
+
}
|
|
1071
1122
|
telemetry?.track('camera_capture_retry', {
|
|
1072
1123
|
component: 'scanner',
|
|
1073
1124
|
error: normalized,
|
|
@@ -1084,6 +1135,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1084
1135
|
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1085
1136
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1086
1137
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1138
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1139
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1087
1140
|
}),
|
|
1088
1141
|
});
|
|
1089
1142
|
if (captureRetryReady) {
|
|
@@ -1159,6 +1212,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1159
1212
|
resized: didResize ? 1 : 0,
|
|
1160
1213
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
1161
1214
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1215
|
+
native_capture_elapsed_ms: nativeCaptureElapsedMs,
|
|
1216
|
+
native_capture_ready_age_ms: nativeCaptureReadyAgeMs,
|
|
1217
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1218
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1162
1219
|
capture_retry_attempted: captureRetryAttempted,
|
|
1163
1220
|
capture_retry_ready: captureRetryReady,
|
|
1164
1221
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
@@ -1213,6 +1270,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1213
1270
|
}
|
|
1214
1271
|
}
|
|
1215
1272
|
catch (err) {
|
|
1273
|
+
if (captureCancelled || !scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
1274
|
+
captureCancelled = true;
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1216
1277
|
const error = normalizeScannerError(err);
|
|
1217
1278
|
const terminalRequestError = isTerminalRequestError(error);
|
|
1218
1279
|
if (terminalRequestError) {
|
|
@@ -1236,6 +1297,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1236
1297
|
capture_attempt_id: captureAttemptId,
|
|
1237
1298
|
capture_sequence: captureSequence,
|
|
1238
1299
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1300
|
+
native_capture_elapsed_ms: nativeCaptureElapsedMs,
|
|
1301
|
+
native_capture_ready_age_ms: nativeCaptureReadyAgeMs,
|
|
1302
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1303
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1239
1304
|
capture_retry_attempted: captureRetryAttempted,
|
|
1240
1305
|
capture_retry_ready: captureRetryReady,
|
|
1241
1306
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
@@ -1253,7 +1318,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1253
1318
|
}
|
|
1254
1319
|
finally {
|
|
1255
1320
|
captureIntentPendingRef.current = false;
|
|
1256
|
-
|
|
1321
|
+
if (scannerMountedRef.current)
|
|
1322
|
+
setTorchSuppressedForRetry(false);
|
|
1323
|
+
if (captureCancelled && scannerMountedRef.current && !terminatedRef.current)
|
|
1324
|
+
setStatus('idle');
|
|
1257
1325
|
}
|
|
1258
1326
|
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
1259
1327
|
// Expose capture to parent via ref
|
|
@@ -1268,7 +1336,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1268
1336
|
}
|
|
1269
1337
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1270
1338
|
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
|
|
1271
|
-
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) => {
|
|
1339
|
+
return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: () => onCameraReady(renderedCameraGeneration), onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
|
|
1272
1340
|
setPhysicalOrientation(event.orientation);
|
|
1273
1341
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
1274
1342
|
styles.titleText,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export declare const CAMERA_CAPTURE_RETRY_POST_READY_MS = 350;
|
|
2
3
|
export declare const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
4
|
export declare const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
5
|
export declare const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
@@ -9,4 +10,12 @@ export interface CameraCaptureRetryPlan {
|
|
|
9
10
|
settleDelayMs: number;
|
|
10
11
|
suppressTorch: boolean;
|
|
11
12
|
}
|
|
13
|
+
export interface CameraCaptureRetryState<T> {
|
|
14
|
+
camera: T | null;
|
|
15
|
+
generation: number;
|
|
16
|
+
ready: boolean;
|
|
17
|
+
active: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Wait for a fresh camera, preserving the existing total-delay/deadline policy. */
|
|
20
|
+
export declare function waitForCameraCaptureRetry<T>(readState: () => CameraCaptureRetryState<T>, previousCamera: T | null, generation: number, requestedAt: number, plan: CameraCaptureRetryPlan, sleep?: (ms: number) => Promise<void>, now?: () => number): Promise<T | null>;
|
|
12
21
|
export declare function getCameraCaptureRetryPlan(platform: CameraCaptureRetryPlatform, torchRequested: boolean): CameraCaptureRetryPlan;
|
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export const CAMERA_CAPTURE_RETRY_POST_READY_MS = 350;
|
|
2
3
|
export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
4
|
export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
5
|
export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
6
|
+
/** Wait for a fresh camera, preserving the existing total-delay/deadline policy. */
|
|
7
|
+
export async function waitForCameraCaptureRetry(readState, previousCamera, generation, requestedAt, plan, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), now = Date.now) {
|
|
8
|
+
// Previously readiness polling began only after the total settle delay.
|
|
9
|
+
const deadline = requestedAt + plan.settleDelayMs + plan.readyTimeoutMs;
|
|
10
|
+
let state = readState();
|
|
11
|
+
while (state.active && state.generation === generation &&
|
|
12
|
+
!(state.ready && state.camera && state.camera !== previousCamera)) {
|
|
13
|
+
if (now() >= deadline)
|
|
14
|
+
return null;
|
|
15
|
+
await sleep(Math.min(100, deadline - now()));
|
|
16
|
+
state = readState();
|
|
17
|
+
}
|
|
18
|
+
if (!state.active || state.generation !== generation || !state.ready || !state.camera)
|
|
19
|
+
return null;
|
|
20
|
+
const camera = state.camera;
|
|
21
|
+
const captureAt = Math.max(requestedAt + plan.settleDelayMs, now() + CAMERA_CAPTURE_RETRY_POST_READY_MS);
|
|
22
|
+
await sleep(Math.max(0, captureAt - now()));
|
|
23
|
+
state = readState();
|
|
24
|
+
return state.active && state.generation === generation && state.ready && state.camera === camera ? camera : null;
|
|
25
|
+
}
|
|
5
26
|
export function getCameraCaptureRetryPlan(platform, torchRequested) {
|
|
6
27
|
const suppressTorch = torchRequested;
|
|
7
28
|
return {
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.8";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.8';
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchlabs/verify-ai-react-native",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.8",
|
|
4
4
|
"description": "React Native SDK for Verify AI - photo verification with AI vision processing",
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"url": "
|
|
8
|
-
"directory": "packages/verify-ai-react-native"
|
|
5
|
+
"homepage": "https://verify.switchlabs.dev/docs/sdks/react-native",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://verify.switchlabs.dev/contact"
|
|
9
8
|
},
|
|
10
9
|
"main": "./lib/index.js",
|
|
11
10
|
"types": "./lib/index.d.ts",
|
|
@@ -35,7 +34,12 @@
|
|
|
35
34
|
"photo-verification",
|
|
36
35
|
"ai-vision",
|
|
37
36
|
"parking-compliance",
|
|
38
|
-
"damage-detection"
|
|
37
|
+
"damage-detection",
|
|
38
|
+
"proof-of-delivery",
|
|
39
|
+
"image-verification",
|
|
40
|
+
"vehicle-inspection",
|
|
41
|
+
"expo",
|
|
42
|
+
"photo-verification-api"
|
|
39
43
|
],
|
|
40
44
|
"author": "Switch Labs",
|
|
41
45
|
"license": "MIT",
|
|
@@ -86,5 +90,10 @@
|
|
|
86
90
|
"react-native-fast-tflite": "^1.6.1",
|
|
87
91
|
"typescript": "^5.5.0",
|
|
88
92
|
"vitest": "^4.1.0"
|
|
93
|
+
},
|
|
94
|
+
"repository": {
|
|
95
|
+
"type": "git",
|
|
96
|
+
"url": "git+https://github.com/levyelectric/verify.git",
|
|
97
|
+
"directory": "packages/verify-ai-react-native"
|
|
89
98
|
}
|
|
90
99
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -14,6 +14,13 @@ import { SDK_VERSION } from '../version';
|
|
|
14
14
|
|
|
15
15
|
const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
|
|
16
16
|
const DEFAULT_TIMEOUT = 60000;
|
|
17
|
+
const MAX_IN_PROGRESS_RETRIES = 10;
|
|
18
|
+
|
|
19
|
+
function retryAfterMs(value: string | null): number {
|
|
20
|
+
if (value !== null && /^\d+$/.test(value.trim())) return Number(value) * 1000;
|
|
21
|
+
const date = value ? Date.parse(value) : NaN;
|
|
22
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 1000;
|
|
23
|
+
}
|
|
17
24
|
|
|
18
25
|
const VEHICLE_TYPE_LABELS: Record<string, string> = {
|
|
19
26
|
scooter: 'Scooter',
|
|
@@ -214,19 +221,35 @@ export class VerifyAIClient {
|
|
|
214
221
|
const method = (options.method || 'GET').toUpperCase();
|
|
215
222
|
const url = `${this.baseUrl}${path}`;
|
|
216
223
|
const context: RequestContext = { path, url, method };
|
|
224
|
+
const deadline = Date.now() + this.timeout;
|
|
217
225
|
|
|
218
226
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
...options
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
let response: Response;
|
|
228
|
+
let body: unknown;
|
|
229
|
+
let retries = 0;
|
|
230
|
+
while (true) {
|
|
231
|
+
response = await fetch(url, {
|
|
232
|
+
...options,
|
|
233
|
+
signal: controller.signal,
|
|
234
|
+
headers: {
|
|
235
|
+
'X-API-Key': this.apiKey,
|
|
236
|
+
...options.headers,
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const rawBody = await response.text();
|
|
241
|
+
body = this.parseResponseBody(rawBody);
|
|
242
|
+
const code = body && typeof body === 'object' ? (body as VerifyAIError).code : undefined;
|
|
243
|
+
const key = new Headers(options.headers).get('Idempotency-Key');
|
|
244
|
+
const delay = retryAfterMs(response.headers.get('Retry-After'));
|
|
245
|
+
// Only replay an identical, explicitly keyed verification reservation.
|
|
246
|
+
// Keep one timeout budget and report errors only after retries exhaust.
|
|
247
|
+
if (path !== '/verify' || method !== 'POST' || !key || response.status !== 409 ||
|
|
248
|
+
code !== 'idempotency_in_progress' || retries >= MAX_IN_PROGRESS_RETRIES ||
|
|
249
|
+
delay >= deadline - Date.now() || controller.signal.aborted) break;
|
|
250
|
+
retries += 1;
|
|
251
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
252
|
+
}
|
|
230
253
|
|
|
231
254
|
if (!response.ok) {
|
|
232
255
|
const errorBody = (body && typeof body === 'object' ? body : null) as VerifyAIError | null;
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
37
37
|
getCameraCaptureRetryPlan,
|
|
38
|
+
waitForCameraCaptureRetry,
|
|
38
39
|
} from './cameraCaptureRetry';
|
|
39
40
|
|
|
40
41
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
@@ -158,10 +159,6 @@ function createScannerError(message: string, code: string, name: string): ErrorW
|
|
|
158
159
|
return error;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
function sleep(ms: number): Promise<void> {
|
|
162
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
162
|
function createScannerSessionId(): string {
|
|
166
163
|
return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
167
164
|
}
|
|
@@ -330,6 +327,10 @@ export function VerifyAIScanner({
|
|
|
330
327
|
const terminalResultDeliveredRef = useRef(false);
|
|
331
328
|
const appStateRef = useRef(AppState.currentState);
|
|
332
329
|
const cameraReadyRef = useRef(false);
|
|
330
|
+
const cameraGenerationRef = useRef(0);
|
|
331
|
+
const scannerMountedRef = useRef(true);
|
|
332
|
+
const terminatedRef = useRef(false);
|
|
333
|
+
const renderedCameraGeneration = cameraGenerationRef.current;
|
|
333
334
|
const cameraEverReadyRef = useRef(false);
|
|
334
335
|
const cameraInitFailedRef = useRef(false);
|
|
335
336
|
const permissionDeniedTrackedRef = useRef(false);
|
|
@@ -483,6 +484,7 @@ export function VerifyAIScanner({
|
|
|
483
484
|
}, []);
|
|
484
485
|
|
|
485
486
|
useEffect(() => {
|
|
487
|
+
scannerMountedRef.current = true;
|
|
486
488
|
const reporter = telemetryRef.current;
|
|
487
489
|
if (reporter) {
|
|
488
490
|
console.log(
|
|
@@ -507,6 +509,9 @@ export function VerifyAIScanner({
|
|
|
507
509
|
});
|
|
508
510
|
|
|
509
511
|
return () => {
|
|
512
|
+
scannerMountedRef.current = false;
|
|
513
|
+
cameraReadyRef.current = false;
|
|
514
|
+
if (startupRemountTimerRef.current) clearTimeout(startupRemountTimerRef.current);
|
|
510
515
|
const activeTelemetry = telemetryRef.current;
|
|
511
516
|
activeTelemetry?.track('camera_scanner_disposed', {
|
|
512
517
|
component: 'scanner',
|
|
@@ -531,6 +536,7 @@ export function VerifyAIScanner({
|
|
|
531
536
|
|
|
532
537
|
setCameraReady(false);
|
|
533
538
|
cameraReadyRef.current = false;
|
|
539
|
+
const generation = ++cameraGenerationRef.current;
|
|
534
540
|
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
535
541
|
|
|
536
542
|
if (opts.log) {
|
|
@@ -542,6 +548,7 @@ export function VerifyAIScanner({
|
|
|
542
548
|
}
|
|
543
549
|
|
|
544
550
|
const remount = () => {
|
|
551
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || cameraGenerationRef.current !== generation) return;
|
|
545
552
|
setCameraKey((k) => k + 1);
|
|
546
553
|
setCameraMounted(true);
|
|
547
554
|
startupRemountTimerRef.current = null;
|
|
@@ -868,6 +875,9 @@ export function VerifyAIScanner({
|
|
|
868
875
|
|
|
869
876
|
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
870
877
|
const releaseCamera = useCallback(() => {
|
|
878
|
+
terminatedRef.current = true;
|
|
879
|
+
cameraReadyRef.current = false;
|
|
880
|
+
cameraGenerationRef.current++;
|
|
871
881
|
setTerminated(true);
|
|
872
882
|
pausePreview();
|
|
873
883
|
}, [pausePreview]);
|
|
@@ -920,7 +930,8 @@ export function VerifyAIScanner({
|
|
|
920
930
|
}, [onClose, result]);
|
|
921
931
|
|
|
922
932
|
// Camera init callbacks
|
|
923
|
-
const onCameraReady = useCallback(() => {
|
|
933
|
+
const onCameraReady = useCallback((generation: number) => {
|
|
934
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || generation !== cameraGenerationRef.current) return;
|
|
924
935
|
const readyAt = new Date().toISOString();
|
|
925
936
|
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
926
937
|
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
@@ -1184,6 +1195,10 @@ export function VerifyAIScanner({
|
|
|
1184
1195
|
return;
|
|
1185
1196
|
}
|
|
1186
1197
|
|
|
1198
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
1199
|
+
captureIntentPendingRef.current = false;
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1187
1202
|
setStatus('capturing');
|
|
1188
1203
|
setTorchSuppressedForRetry(false);
|
|
1189
1204
|
setResult(null);
|
|
@@ -1195,7 +1210,12 @@ export function VerifyAIScanner({
|
|
|
1195
1210
|
terminalResultTimerRef.current = null;
|
|
1196
1211
|
}
|
|
1197
1212
|
|
|
1213
|
+
let captureCancelled = false;
|
|
1198
1214
|
let nativeCaptureAttempts = 0;
|
|
1215
|
+
let nativeCaptureElapsedMs: number | null = null;
|
|
1216
|
+
let nativeCaptureReadyAgeMs: number | null = null;
|
|
1217
|
+
let firstNativeCaptureElapsedMs: number | null = null;
|
|
1218
|
+
let firstNativeCaptureReadyAgeMs: number | null = null;
|
|
1199
1219
|
let captureRetryAttempted = false;
|
|
1200
1220
|
let captureRetryReady = false;
|
|
1201
1221
|
let captureRetryTorchSuppressed = false;
|
|
@@ -1245,27 +1265,40 @@ export function VerifyAIScanner({
|
|
|
1245
1265
|
// Not installed — fall back to camera-only base64 below
|
|
1246
1266
|
}
|
|
1247
1267
|
|
|
1248
|
-
const waitForCameraReady = async (timeoutMs: number): Promise<boolean> => {
|
|
1249
|
-
const start = Date.now();
|
|
1250
|
-
while (Date.now() - start < timeoutMs) {
|
|
1251
|
-
if (cameraReadyRef.current && cameraRef.current) {
|
|
1252
|
-
return true;
|
|
1253
|
-
}
|
|
1254
|
-
await sleep(100);
|
|
1255
|
-
}
|
|
1256
|
-
return cameraReadyRef.current && !!cameraRef.current;
|
|
1257
|
-
};
|
|
1258
|
-
|
|
1259
1268
|
const takePictureWithRetry = async (
|
|
1260
1269
|
options: { base64?: boolean; quality?: number; exif?: boolean },
|
|
1261
1270
|
requiredField: 'uri' | 'base64',
|
|
1262
1271
|
) => {
|
|
1263
1272
|
let lastError: ErrorWithDetails | null = null;
|
|
1273
|
+
let preparedRetryCamera: CameraView | null = null;
|
|
1274
|
+
let preparedRetryGeneration: number | null = null;
|
|
1264
1275
|
|
|
1265
1276
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1277
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
1278
|
+
captureCancelled = true;
|
|
1279
|
+
throw createScannerError('Camera capture cancelled', CAMERA_NOT_READY_ERROR_CODE, 'CameraCaptureCancelled');
|
|
1280
|
+
}
|
|
1281
|
+
const attemptCamera = cameraRef.current;
|
|
1282
|
+
if (!attemptCamera || !cameraReadyRef.current ||
|
|
1283
|
+
(attempt === 2 && (attemptCamera !== preparedRetryCamera || cameraGenerationRef.current !== preparedRetryGeneration))) {
|
|
1284
|
+
throw lastError ?? createScannerError('Camera is not ready for capture', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReady');
|
|
1285
|
+
}
|
|
1286
|
+
const attemptStartedAt = Date.now();
|
|
1287
|
+
const attemptGeneration = cameraGenerationRef.current;
|
|
1288
|
+
nativeCaptureReadyAgeMs = lastCameraReadyAtRef.current ? attemptStartedAt - Date.parse(lastCameraReadyAtRef.current) : null;
|
|
1266
1289
|
nativeCaptureAttempts = attempt;
|
|
1267
1290
|
try {
|
|
1268
|
-
const photo = await
|
|
1291
|
+
const photo = await attemptCamera?.takePictureAsync(options);
|
|
1292
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' ||
|
|
1293
|
+
cameraGenerationRef.current !== attemptGeneration || cameraRef.current !== attemptCamera) {
|
|
1294
|
+
captureCancelled = true;
|
|
1295
|
+
throw createScannerError('Camera capture cancelled', CAMERA_NOT_READY_ERROR_CODE, 'CameraCaptureCancelled');
|
|
1296
|
+
}
|
|
1297
|
+
nativeCaptureElapsedMs = Date.now() - attemptStartedAt;
|
|
1298
|
+
if (attempt === 1) {
|
|
1299
|
+
firstNativeCaptureElapsedMs = nativeCaptureElapsedMs;
|
|
1300
|
+
firstNativeCaptureReadyAgeMs = nativeCaptureReadyAgeMs;
|
|
1301
|
+
}
|
|
1269
1302
|
if (!photo?.[requiredField]) {
|
|
1270
1303
|
throw createScannerError(
|
|
1271
1304
|
'Failed to capture photo',
|
|
@@ -1275,18 +1308,24 @@ export function VerifyAIScanner({
|
|
|
1275
1308
|
}
|
|
1276
1309
|
return photo;
|
|
1277
1310
|
} catch (captureErr) {
|
|
1311
|
+
nativeCaptureElapsedMs = Date.now() - attemptStartedAt;
|
|
1312
|
+
if (attempt === 1) {
|
|
1313
|
+
firstNativeCaptureElapsedMs = nativeCaptureElapsedMs;
|
|
1314
|
+
firstNativeCaptureReadyAgeMs = nativeCaptureReadyAgeMs;
|
|
1315
|
+
}
|
|
1278
1316
|
const normalized = normalizeScannerError(captureErr);
|
|
1279
1317
|
lastError = normalized;
|
|
1280
1318
|
lastNativeCaptureErrorMessage = normalized.message;
|
|
1281
1319
|
|
|
1282
|
-
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !
|
|
1320
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && scannerMountedRef.current && !terminatedRef.current && appStateRef.current === 'active') {
|
|
1283
1321
|
captureRetryAttempted = true;
|
|
1284
1322
|
const retryPlan = getCameraCaptureRetryPlan(Platform.OS, !!enableTorch);
|
|
1285
1323
|
captureRetryTorchSuppressed = retryPlan.suppressTorch;
|
|
1286
1324
|
captureRetrySettleDelayMs = retryPlan.settleDelayMs;
|
|
1287
1325
|
captureRetryRemountBackoffMs = retryPlan.remountBackoffMs;
|
|
1288
1326
|
captureRetryReadyTimeoutMs = retryPlan.readyTimeoutMs;
|
|
1289
|
-
const
|
|
1327
|
+
const retryRequestedAt = Date.now();
|
|
1328
|
+
const retryAt = new Date(retryRequestedAt).toISOString();
|
|
1290
1329
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1291
1330
|
cameraRemountCountRef.current++;
|
|
1292
1331
|
if (captureRetryTorchSuppressed) {
|
|
@@ -1298,8 +1337,19 @@ export function VerifyAIScanner({
|
|
|
1298
1337
|
? { backoffMs: captureRetryRemountBackoffMs }
|
|
1299
1338
|
: undefined,
|
|
1300
1339
|
);
|
|
1301
|
-
|
|
1302
|
-
|
|
1340
|
+
const retryGeneration = cameraGenerationRef.current;
|
|
1341
|
+
const retryCamera: CameraView | null = await waitForCameraCaptureRetry<CameraView>(
|
|
1342
|
+
() => ({camera: cameraRef.current, generation: cameraGenerationRef.current,
|
|
1343
|
+
ready: cameraReadyRef.current, active: scannerMountedRef.current && !terminatedRef.current && appStateRef.current === 'active'}),
|
|
1344
|
+
attemptCamera, retryGeneration, retryRequestedAt, retryPlan,
|
|
1345
|
+
);
|
|
1346
|
+
captureRetryReady = !!retryCamera;
|
|
1347
|
+
preparedRetryCamera = retryCamera;
|
|
1348
|
+
preparedRetryGeneration = cameraGenerationRef.current;
|
|
1349
|
+
if (!scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active' || cameraGenerationRef.current !== retryGeneration) {
|
|
1350
|
+
captureCancelled = true;
|
|
1351
|
+
throw normalized;
|
|
1352
|
+
}
|
|
1303
1353
|
telemetry?.track('camera_capture_retry', {
|
|
1304
1354
|
component: 'scanner',
|
|
1305
1355
|
error: normalized,
|
|
@@ -1316,6 +1366,8 @@ export function VerifyAIScanner({
|
|
|
1316
1366
|
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1317
1367
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1318
1368
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1369
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1370
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1319
1371
|
}),
|
|
1320
1372
|
});
|
|
1321
1373
|
if (captureRetryReady) {
|
|
@@ -1417,6 +1469,10 @@ export function VerifyAIScanner({
|
|
|
1417
1469
|
resized: didResize ? 1 : 0,
|
|
1418
1470
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
1419
1471
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1472
|
+
native_capture_elapsed_ms: nativeCaptureElapsedMs,
|
|
1473
|
+
native_capture_ready_age_ms: nativeCaptureReadyAgeMs,
|
|
1474
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1475
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1420
1476
|
capture_retry_attempted: captureRetryAttempted,
|
|
1421
1477
|
capture_retry_ready: captureRetryReady,
|
|
1422
1478
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
@@ -1474,6 +1530,10 @@ export function VerifyAIScanner({
|
|
|
1474
1530
|
setStatus('idle');
|
|
1475
1531
|
}
|
|
1476
1532
|
} catch (err) {
|
|
1533
|
+
if (captureCancelled || !scannerMountedRef.current || terminatedRef.current || appStateRef.current !== 'active') {
|
|
1534
|
+
captureCancelled = true;
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1477
1537
|
const error = normalizeScannerError(err);
|
|
1478
1538
|
const terminalRequestError = isTerminalRequestError(error);
|
|
1479
1539
|
if (terminalRequestError) {
|
|
@@ -1500,6 +1560,10 @@ export function VerifyAIScanner({
|
|
|
1500
1560
|
capture_attempt_id: captureAttemptId,
|
|
1501
1561
|
capture_sequence: captureSequence,
|
|
1502
1562
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1563
|
+
native_capture_elapsed_ms: nativeCaptureElapsedMs,
|
|
1564
|
+
native_capture_ready_age_ms: nativeCaptureReadyAgeMs,
|
|
1565
|
+
first_native_capture_elapsed_ms: firstNativeCaptureElapsedMs,
|
|
1566
|
+
first_native_capture_ready_age_ms: firstNativeCaptureReadyAgeMs,
|
|
1503
1567
|
capture_retry_attempted: captureRetryAttempted,
|
|
1504
1568
|
capture_retry_ready: captureRetryReady,
|
|
1505
1569
|
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
@@ -1518,7 +1582,8 @@ export function VerifyAIScanner({
|
|
|
1518
1582
|
}
|
|
1519
1583
|
} finally {
|
|
1520
1584
|
captureIntentPendingRef.current = false;
|
|
1521
|
-
setTorchSuppressedForRetry(false);
|
|
1585
|
+
if (scannerMountedRef.current) setTorchSuppressedForRetry(false);
|
|
1586
|
+
if (captureCancelled && scannerMountedRef.current && !terminatedRef.current) setStatus('idle');
|
|
1522
1587
|
}
|
|
1523
1588
|
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
1524
1589
|
|
|
@@ -1554,7 +1619,7 @@ export function VerifyAIScanner({
|
|
|
1554
1619
|
style={styles.camera}
|
|
1555
1620
|
facing="back"
|
|
1556
1621
|
enableTorch={shouldEnableTorch}
|
|
1557
|
-
onCameraReady={onCameraReady}
|
|
1622
|
+
onCameraReady={() => onCameraReady(renderedCameraGeneration)}
|
|
1558
1623
|
onMountError={onMountError}
|
|
1559
1624
|
responsiveOrientationWhenOrientationLocked
|
|
1560
1625
|
onResponsiveOrientationChanged={(event) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
2
|
+
export const CAMERA_CAPTURE_RETRY_POST_READY_MS = 350;
|
|
2
3
|
export const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
3
4
|
export const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
4
5
|
export const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
@@ -12,6 +13,40 @@ export interface CameraCaptureRetryPlan {
|
|
|
12
13
|
suppressTorch: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface CameraCaptureRetryState<T> {
|
|
17
|
+
camera: T | null;
|
|
18
|
+
generation: number;
|
|
19
|
+
ready: boolean;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Wait for a fresh camera, preserving the existing total-delay/deadline policy. */
|
|
24
|
+
export async function waitForCameraCaptureRetry<T>(
|
|
25
|
+
readState: () => CameraCaptureRetryState<T>,
|
|
26
|
+
previousCamera: T | null,
|
|
27
|
+
generation: number,
|
|
28
|
+
requestedAt: number,
|
|
29
|
+
plan: CameraCaptureRetryPlan,
|
|
30
|
+
sleep: (ms: number) => Promise<void> = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
31
|
+
now: () => number = Date.now,
|
|
32
|
+
): Promise<T | null> {
|
|
33
|
+
// Previously readiness polling began only after the total settle delay.
|
|
34
|
+
const deadline = requestedAt + plan.settleDelayMs + plan.readyTimeoutMs;
|
|
35
|
+
let state = readState();
|
|
36
|
+
while (state.active && state.generation === generation &&
|
|
37
|
+
!(state.ready && state.camera && state.camera !== previousCamera)) {
|
|
38
|
+
if (now() >= deadline) return null;
|
|
39
|
+
await sleep(Math.min(100, deadline - now()));
|
|
40
|
+
state = readState();
|
|
41
|
+
}
|
|
42
|
+
if (!state.active || state.generation !== generation || !state.ready || !state.camera) return null;
|
|
43
|
+
const camera = state.camera;
|
|
44
|
+
const captureAt = Math.max(requestedAt + plan.settleDelayMs, now() + CAMERA_CAPTURE_RETRY_POST_READY_MS);
|
|
45
|
+
await sleep(Math.max(0, captureAt - now()));
|
|
46
|
+
state = readState();
|
|
47
|
+
return state.active && state.generation === generation && state.ready && state.camera === camera ? camera : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
15
50
|
export function getCameraCaptureRetryPlan(
|
|
16
51
|
platform: CameraCaptureRetryPlatform,
|
|
17
52
|
torchRequested: boolean,
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.8';
|