@switchlabs/verify-ai-react-native 2.5.0 → 2.5.2
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/client/index.js +54 -7
- package/lib/components/VerifyAIScanner.js +190 -26
- package/lib/telemetry/TelemetryReporter.js +1 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +73 -7
- package/src/components/VerifyAIScanner.tsx +235 -39
- package/src/telemetry/TelemetryReporter.ts +1 -0
- package/src/types/index.ts +2 -0
- package/src/version.ts +1 -1
package/lib/client/index.js
CHANGED
|
@@ -52,6 +52,49 @@ function stringifyErrorMessage(value, fallback) {
|
|
|
52
52
|
}
|
|
53
53
|
return fallback;
|
|
54
54
|
}
|
|
55
|
+
const VERIFY_METADATA_TELEMETRY_KEYS = [
|
|
56
|
+
'rideId',
|
|
57
|
+
'vehicleType',
|
|
58
|
+
'verificationAttemptId',
|
|
59
|
+
'verificationAttempt',
|
|
60
|
+
'verificationMaxAttempts',
|
|
61
|
+
'verificationSource',
|
|
62
|
+
'torchEnabled',
|
|
63
|
+
'scannerSessionId',
|
|
64
|
+
];
|
|
65
|
+
function toTelemetryKey(key) {
|
|
66
|
+
return key
|
|
67
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
68
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
69
|
+
.replace(/^_+|_+$/g, '')
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
function compactClientTelemetryMetadata(metadata = {}) {
|
|
73
|
+
const compacted = {};
|
|
74
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
75
|
+
if (value === null || value === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
78
|
+
}
|
|
79
|
+
return compacted;
|
|
80
|
+
}
|
|
81
|
+
function buildVerifyTelemetryMetadata(requestMetadata, options) {
|
|
82
|
+
const metadata = {
|
|
83
|
+
...options?.telemetryMetadata,
|
|
84
|
+
};
|
|
85
|
+
if (options?.idempotencyKey) {
|
|
86
|
+
metadata.idempotency_key = options.idempotencyKey;
|
|
87
|
+
}
|
|
88
|
+
for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
|
|
89
|
+
const value = requestMetadata?.[key];
|
|
90
|
+
if (typeof value === 'string' ||
|
|
91
|
+
typeof value === 'number' ||
|
|
92
|
+
typeof value === 'boolean') {
|
|
93
|
+
metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return metadata;
|
|
97
|
+
}
|
|
55
98
|
export class VerifyAIClient {
|
|
56
99
|
constructor(config) {
|
|
57
100
|
if (!config.apiKey) {
|
|
@@ -101,7 +144,7 @@ export class VerifyAIClient {
|
|
|
101
144
|
const message = error instanceof Error ? error.message : stringifyErrorMessage(error, 'VerifyAI request failed');
|
|
102
145
|
return this.buildRequestError(message, 0, context);
|
|
103
146
|
}
|
|
104
|
-
async executeRequest(path, options = {}) {
|
|
147
|
+
async executeRequest(path, options = {}, telemetryMetadata = {}) {
|
|
105
148
|
const controller = new AbortController();
|
|
106
149
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
107
150
|
const method = (options.method || 'GET').toUpperCase();
|
|
@@ -156,6 +199,7 @@ export class VerifyAIClient {
|
|
|
156
199
|
? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
|
|
157
200
|
: normalized;
|
|
158
201
|
const metadata = {
|
|
202
|
+
...compactClientTelemetryMetadata(telemetryMetadata),
|
|
159
203
|
method: context.method,
|
|
160
204
|
path: context.path,
|
|
161
205
|
status: normalized.status,
|
|
@@ -178,8 +222,8 @@ export class VerifyAIClient {
|
|
|
178
222
|
clearTimeout(timer);
|
|
179
223
|
}
|
|
180
224
|
}
|
|
181
|
-
async request(path, options = {}) {
|
|
182
|
-
return this.executeRequest(path, options);
|
|
225
|
+
async request(path, options = {}, telemetryMetadata = {}) {
|
|
226
|
+
return this.executeRequest(path, options, telemetryMetadata);
|
|
183
227
|
}
|
|
184
228
|
/**
|
|
185
229
|
* Submit a photo for AI verification.
|
|
@@ -209,11 +253,12 @@ export class VerifyAIClient {
|
|
|
209
253
|
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
210
254
|
}
|
|
211
255
|
const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
|
|
256
|
+
const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
|
|
212
257
|
return this.request('/verify', {
|
|
213
258
|
method: 'POST',
|
|
214
259
|
headers,
|
|
215
260
|
body: JSON.stringify(enrichedRequest),
|
|
216
|
-
});
|
|
261
|
+
}, telemetryMetadata);
|
|
217
262
|
}
|
|
218
263
|
/**
|
|
219
264
|
* Submit a photo for AI verification using multipart/form-data.
|
|
@@ -232,7 +277,8 @@ export class VerifyAIClient {
|
|
|
232
277
|
name: 'photo.jpg',
|
|
233
278
|
});
|
|
234
279
|
formData.append('policy', request.policy);
|
|
235
|
-
|
|
280
|
+
const enrichedMetadata = enrichMetadata(request.metadata);
|
|
281
|
+
formData.append('metadata', JSON.stringify(enrichedMetadata));
|
|
236
282
|
if (request.provider) {
|
|
237
283
|
formData.append('provider', request.provider);
|
|
238
284
|
}
|
|
@@ -246,12 +292,13 @@ export class VerifyAIClient {
|
|
|
246
292
|
if (options?.idempotencyKey) {
|
|
247
293
|
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
248
294
|
}
|
|
295
|
+
const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
|
|
249
296
|
try {
|
|
250
297
|
return await this.executeRequest('/verify', {
|
|
251
298
|
method: 'POST',
|
|
252
299
|
headers,
|
|
253
300
|
body: formData,
|
|
254
|
-
});
|
|
301
|
+
}, telemetryMetadata);
|
|
255
302
|
}
|
|
256
303
|
catch (error) {
|
|
257
304
|
// 5xx during multipart can mean the server crashed before reservation
|
|
@@ -271,7 +318,7 @@ export class VerifyAIClient {
|
|
|
271
318
|
metadata: request.metadata,
|
|
272
319
|
provider: request.provider,
|
|
273
320
|
include_image_data: request.include_image_data,
|
|
274
|
-
}, options
|
|
321
|
+
}, options);
|
|
275
322
|
}
|
|
276
323
|
catch {
|
|
277
324
|
// If the retry itself fails, throw the original error
|
|
@@ -20,10 +20,13 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
20
20
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
21
21
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
22
22
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
23
|
+
const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
23
24
|
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
24
25
|
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
25
26
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
26
27
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
28
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
29
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
27
30
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
28
31
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
29
32
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -74,6 +77,9 @@ function createScannerError(message, code, name) {
|
|
|
74
77
|
function sleep(ms) {
|
|
75
78
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
79
|
}
|
|
80
|
+
function createScannerSessionId() {
|
|
81
|
+
return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
82
|
+
}
|
|
77
83
|
function isNativeCameraCaptureError(error) {
|
|
78
84
|
const message = error.message.toLowerCase();
|
|
79
85
|
return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
|
|
@@ -207,6 +213,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
207
213
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
208
214
|
const [cameraReady, setCameraReady] = useState(false);
|
|
209
215
|
const [cameraKey, setCameraKey] = useState(0);
|
|
216
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
210
217
|
const terminalResultRef = useRef(null);
|
|
211
218
|
const terminalResultTimerRef = useRef(null);
|
|
212
219
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -219,6 +226,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
219
226
|
const lastAppStateRemountAtRef = useRef(null);
|
|
220
227
|
const lastOrientationRemountAtRef = useRef(null);
|
|
221
228
|
const lastCaptureRetryAtRef = useRef(null);
|
|
229
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
230
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
231
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
232
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
233
|
+
const startupRemountTimerRef = useRef(null);
|
|
222
234
|
const cameraRemountCountRef = useRef(0);
|
|
223
235
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
224
236
|
const telemetryRef = useRef(telemetry);
|
|
@@ -253,14 +265,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
253
265
|
const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
|
|
254
266
|
physicalOrientationRef.current = physicalOrientation;
|
|
255
267
|
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
268
|
+
const scannerSessionIdRef = useRef(createScannerSessionId());
|
|
269
|
+
const captureSequenceRef = useRef(0);
|
|
270
|
+
const torchRetrySuppressedRef = useRef(false);
|
|
271
|
+
const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
|
|
256
272
|
const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
|
|
273
|
+
scanner_session_id: scannerSessionIdRef.current,
|
|
257
274
|
policy,
|
|
258
275
|
status,
|
|
259
276
|
camera_ready: cameraReadyRef.current,
|
|
260
277
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
261
278
|
camera_init_failed: cameraInitFailedRef.current,
|
|
262
279
|
camera_key: cameraKey,
|
|
280
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
263
281
|
camera_remount_count: cameraRemountCountRef.current,
|
|
282
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
283
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
264
284
|
terminated,
|
|
265
285
|
exhausted,
|
|
266
286
|
app_state: appStateRef.current,
|
|
@@ -278,6 +298,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
278
298
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
279
299
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
280
300
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
301
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
302
|
+
torch_retry_suppressed: torchRetrySuppressedRef.current,
|
|
281
303
|
android_native_orientation_subscription_active: 0,
|
|
282
304
|
android_native_orientation_event_count: 0,
|
|
283
305
|
android_native_orientation_change_count: 0,
|
|
@@ -314,7 +336,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
314
336
|
...extra,
|
|
315
337
|
}), [
|
|
316
338
|
cameraKey,
|
|
339
|
+
cameraMounted,
|
|
317
340
|
exhausted,
|
|
341
|
+
enableTorch,
|
|
318
342
|
isLandscape,
|
|
319
343
|
overlayRotationDeg,
|
|
320
344
|
physicalOrientation,
|
|
@@ -328,6 +352,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
328
352
|
]);
|
|
329
353
|
telemetryRef.current = telemetry;
|
|
330
354
|
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
355
|
+
const setTorchSuppressedForRetry = useCallback((nextSuppressed) => {
|
|
356
|
+
torchRetrySuppressedRef.current = nextSuppressed;
|
|
357
|
+
setTorchRetrySuppressed(nextSuppressed);
|
|
358
|
+
}, []);
|
|
331
359
|
useEffect(() => {
|
|
332
360
|
const reporter = telemetryRef.current;
|
|
333
361
|
if (reporter) {
|
|
@@ -358,6 +386,38 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
358
386
|
void activeTelemetry?.flush();
|
|
359
387
|
};
|
|
360
388
|
}, []);
|
|
389
|
+
const requestCameraRemount = useCallback((reason, opts = {}) => {
|
|
390
|
+
if (startupRemountTimerRef.current) {
|
|
391
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
392
|
+
startupRemountTimerRef.current = null;
|
|
393
|
+
}
|
|
394
|
+
setCameraReady(false);
|
|
395
|
+
cameraReadyRef.current = false;
|
|
396
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
397
|
+
if (opts.log) {
|
|
398
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
399
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
|
|
400
|
+
`torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`);
|
|
401
|
+
}
|
|
402
|
+
const remount = () => {
|
|
403
|
+
setCameraKey((k) => k + 1);
|
|
404
|
+
setCameraMounted(true);
|
|
405
|
+
startupRemountTimerRef.current = null;
|
|
406
|
+
};
|
|
407
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
408
|
+
setCameraMounted(false);
|
|
409
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
setCameraMounted(true);
|
|
413
|
+
remount();
|
|
414
|
+
}, [enableTorch]);
|
|
415
|
+
useEffect(() => () => {
|
|
416
|
+
if (startupRemountTimerRef.current) {
|
|
417
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
418
|
+
startupRemountTimerRef.current = null;
|
|
419
|
+
}
|
|
420
|
+
}, []);
|
|
361
421
|
useEffect(() => {
|
|
362
422
|
if (Platform.OS !== 'android')
|
|
363
423
|
return;
|
|
@@ -574,16 +634,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
574
634
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
575
635
|
},
|
|
576
636
|
});
|
|
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);
|
|
637
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
585
638
|
}
|
|
586
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
639
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
587
640
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
588
641
|
useEffect(() => {
|
|
589
642
|
if (terminated)
|
|
@@ -611,12 +664,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
611
664
|
remount_requested_at: at,
|
|
612
665
|
}),
|
|
613
666
|
});
|
|
614
|
-
|
|
615
|
-
cameraReadyRef.current = false;
|
|
616
|
-
setCameraKey((k) => k + 1);
|
|
667
|
+
requestCameraRemount('appstate_active');
|
|
617
668
|
});
|
|
618
669
|
return () => subscription.remove();
|
|
619
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
670
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
620
671
|
const pausePreview = useCallback(() => {
|
|
621
672
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
622
673
|
}, []);
|
|
@@ -670,13 +721,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
670
721
|
}, [onClose, result]);
|
|
671
722
|
// Camera init callbacks
|
|
672
723
|
const onCameraReady = useCallback(() => {
|
|
673
|
-
|
|
724
|
+
const readyAt = new Date().toISOString();
|
|
725
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
726
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
727
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
674
728
|
setCameraReady(true);
|
|
675
729
|
cameraReadyRef.current = true;
|
|
676
730
|
cameraEverReadyRef.current = true;
|
|
677
731
|
cameraInitFailedRef.current = false;
|
|
678
732
|
startupWatchdogRemountCountRef.current = 0;
|
|
679
|
-
|
|
733
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
734
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
735
|
+
console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
736
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
737
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
738
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
739
|
+
telemetry?.track('camera_ready', {
|
|
740
|
+
component: 'scanner',
|
|
741
|
+
error: 'camera_ready',
|
|
742
|
+
metadata: buildScannerTelemetryMetadata({
|
|
743
|
+
camera_ready_at: readyAt,
|
|
744
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
745
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
746
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
747
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
748
|
+
}),
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
680
752
|
const onMountError = useCallback((event) => {
|
|
681
753
|
const error = new Error(event.message || 'Camera mount error');
|
|
682
754
|
error.code = CAMERA_INIT_ERROR_CODE;
|
|
@@ -697,12 +769,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
697
769
|
}),
|
|
698
770
|
});
|
|
699
771
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
if (!permission?.granted ||
|
|
774
|
+
terminated ||
|
|
775
|
+
currentAppState !== 'active' ||
|
|
776
|
+
!cameraMounted ||
|
|
777
|
+
cameraReadyRef.current ||
|
|
778
|
+
cameraInitFailedRef.current ||
|
|
779
|
+
cameraStartupSlowTrackedRef.current) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const timer = setTimeout(() => {
|
|
783
|
+
if (appStateRef.current !== 'active' ||
|
|
784
|
+
cameraReadyRef.current ||
|
|
785
|
+
cameraInitFailedRef.current ||
|
|
786
|
+
cameraStartupSlowTrackedRef.current) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
790
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
791
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
792
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
793
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
794
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
795
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
796
|
+
telemetry?.track('camera_startup_slow', {
|
|
797
|
+
component: 'scanner',
|
|
798
|
+
error: 'camera_startup_slow',
|
|
799
|
+
metadata: buildScannerTelemetryMetadata({
|
|
800
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
801
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
802
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
803
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
804
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
805
|
+
}),
|
|
806
|
+
});
|
|
807
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
808
|
+
return () => clearTimeout(timer);
|
|
809
|
+
}, [
|
|
810
|
+
permission?.granted,
|
|
811
|
+
terminated,
|
|
812
|
+
currentAppState,
|
|
813
|
+
cameraMounted,
|
|
814
|
+
buildScannerTelemetryMetadata,
|
|
815
|
+
enableTorch,
|
|
816
|
+
telemetry,
|
|
817
|
+
]);
|
|
700
818
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
701
819
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
702
820
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
703
821
|
// recreate the CameraView, which starts a fresh native session.
|
|
704
822
|
useEffect(() => {
|
|
705
|
-
if (!permission?.granted || terminated || currentAppState !== 'active')
|
|
823
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
|
|
706
824
|
return;
|
|
707
825
|
const watchdogMs = Platform.OS === 'android'
|
|
708
826
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -734,11 +852,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
734
852
|
remount_reason: 'startup_watchdog',
|
|
735
853
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
736
854
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
855
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
856
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
737
857
|
}),
|
|
738
858
|
});
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
859
|
+
requestCameraRemount('startup_watchdog', {
|
|
860
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
861
|
+
log: true,
|
|
862
|
+
});
|
|
742
863
|
}
|
|
743
864
|
}
|
|
744
865
|
}, watchdogMs);
|
|
@@ -747,9 +868,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
747
868
|
permission?.granted,
|
|
748
869
|
terminated,
|
|
749
870
|
currentAppState,
|
|
871
|
+
cameraMounted,
|
|
750
872
|
cameraKey,
|
|
751
873
|
buildScannerTelemetryMetadata,
|
|
874
|
+
enableTorch,
|
|
752
875
|
onMountError,
|
|
876
|
+
requestCameraRemount,
|
|
753
877
|
telemetry,
|
|
754
878
|
]);
|
|
755
879
|
// Track permission denied
|
|
@@ -766,6 +890,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
766
890
|
}
|
|
767
891
|
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
768
892
|
const handleCapture = useCallback(async () => {
|
|
893
|
+
const captureSequence = captureSequenceRef.current + 1;
|
|
894
|
+
captureSequenceRef.current = captureSequence;
|
|
895
|
+
const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
|
|
769
896
|
const blockedReason = !cameraRef.current
|
|
770
897
|
? 'camera_ref_null'
|
|
771
898
|
: !cameraReadyRef.current
|
|
@@ -783,6 +910,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
783
910
|
component: 'scanner',
|
|
784
911
|
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
785
912
|
metadata: buildScannerTelemetryMetadata({
|
|
913
|
+
capture_attempt_id: captureAttemptId,
|
|
914
|
+
capture_sequence: captureSequence,
|
|
786
915
|
capture_blocked_reason: blockedReason,
|
|
787
916
|
}),
|
|
788
917
|
});
|
|
@@ -803,6 +932,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
803
932
|
return;
|
|
804
933
|
}
|
|
805
934
|
setStatus('capturing');
|
|
935
|
+
setTorchSuppressedForRetry(false);
|
|
806
936
|
setResult(null);
|
|
807
937
|
setLastError(null);
|
|
808
938
|
terminalResultRef.current = null;
|
|
@@ -814,6 +944,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
814
944
|
let nativeCaptureAttempts = 0;
|
|
815
945
|
let captureRetryAttempted = false;
|
|
816
946
|
let captureRetryReady = false;
|
|
947
|
+
let captureRetryTorchSuppressed = false;
|
|
948
|
+
let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
949
|
+
let captureRetryRemountBackoffMs = 0;
|
|
817
950
|
let lastNativeCaptureErrorMessage = null;
|
|
818
951
|
try {
|
|
819
952
|
const capturePhysicalOrientation = physicalOrientation;
|
|
@@ -822,6 +955,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
822
955
|
component: 'scanner',
|
|
823
956
|
error: 'capture_orientation_context',
|
|
824
957
|
metadata: buildScannerTelemetryMetadata({
|
|
958
|
+
capture_attempt_id: captureAttemptId,
|
|
959
|
+
capture_sequence: captureSequence,
|
|
825
960
|
capture_physical_orientation: capturePhysicalOrientation,
|
|
826
961
|
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
827
962
|
capture_rotation_applied: 0,
|
|
@@ -879,13 +1014,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
879
1014
|
lastNativeCaptureErrorMessage = normalized.message;
|
|
880
1015
|
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
881
1016
|
captureRetryAttempted = true;
|
|
1017
|
+
captureRetryTorchSuppressed = !!enableTorch;
|
|
1018
|
+
captureRetrySettleDelayMs = captureRetryTorchSuppressed
|
|
1019
|
+
? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
|
|
1020
|
+
: CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
1021
|
+
captureRetryRemountBackoffMs = captureRetryTorchSuppressed
|
|
1022
|
+
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
1023
|
+
: 0;
|
|
882
1024
|
const retryAt = new Date().toISOString();
|
|
883
1025
|
lastCaptureRetryAtRef.current = retryAt;
|
|
884
1026
|
cameraRemountCountRef.current++;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1027
|
+
if (captureRetryTorchSuppressed) {
|
|
1028
|
+
setTorchSuppressedForRetry(true);
|
|
1029
|
+
}
|
|
1030
|
+
requestCameraRemount('capture_retry', captureRetryRemountBackoffMs > 0
|
|
1031
|
+
? { backoffMs: captureRetryRemountBackoffMs }
|
|
1032
|
+
: undefined);
|
|
1033
|
+
await sleep(captureRetrySettleDelayMs);
|
|
889
1034
|
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
890
1035
|
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
891
1036
|
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
@@ -895,11 +1040,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
895
1040
|
error: normalized,
|
|
896
1041
|
errorCode: normalized.code,
|
|
897
1042
|
metadata: buildScannerTelemetryMetadata({
|
|
1043
|
+
capture_attempt_id: captureAttemptId,
|
|
1044
|
+
capture_sequence: captureSequence,
|
|
898
1045
|
native_capture_attempts: nativeCaptureAttempts,
|
|
899
1046
|
capture_retry_attempted: captureRetryAttempted,
|
|
900
1047
|
capture_retry_ready: captureRetryReady,
|
|
901
1048
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
1049
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1050
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
902
1051
|
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1052
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
903
1053
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
904
1054
|
}),
|
|
905
1055
|
});
|
|
@@ -967,6 +1117,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
967
1117
|
telemetry?.track('image_processed', {
|
|
968
1118
|
component: 'scanner',
|
|
969
1119
|
metadata: buildScannerTelemetryMetadata({
|
|
1120
|
+
capture_attempt_id: captureAttemptId,
|
|
1121
|
+
capture_sequence: captureSequence,
|
|
970
1122
|
original_width: origWidth,
|
|
971
1123
|
original_height: origHeight,
|
|
972
1124
|
processed_width: processedWidth,
|
|
@@ -976,6 +1128,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
976
1128
|
native_capture_attempts: nativeCaptureAttempts,
|
|
977
1129
|
capture_retry_attempted: captureRetryAttempted,
|
|
978
1130
|
capture_retry_ready: captureRetryReady,
|
|
1131
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1132
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1133
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
979
1134
|
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
980
1135
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
981
1136
|
}),
|
|
@@ -1044,9 +1199,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1044
1199
|
component: 'scanner',
|
|
1045
1200
|
error,
|
|
1046
1201
|
metadata: buildScannerTelemetryMetadata({
|
|
1202
|
+
capture_attempt_id: captureAttemptId,
|
|
1203
|
+
capture_sequence: captureSequence,
|
|
1047
1204
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1048
1205
|
capture_retry_attempted: captureRetryAttempted,
|
|
1049
1206
|
capture_retry_ready: captureRetryReady,
|
|
1207
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1208
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1209
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1050
1210
|
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
1051
1211
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1052
1212
|
}),
|
|
@@ -1056,7 +1216,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1056
1216
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1057
1217
|
}
|
|
1058
1218
|
}
|
|
1059
|
-
|
|
1219
|
+
finally {
|
|
1220
|
+
setTorchSuppressedForRetry(false);
|
|
1221
|
+
}
|
|
1222
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
1060
1223
|
// Expose capture to parent via ref
|
|
1061
1224
|
if (captureRef) {
|
|
1062
1225
|
captureRef.current = handleCapture;
|
|
@@ -1068,7 +1231,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1068
1231
|
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
1232
|
}
|
|
1070
1233
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1071
|
-
|
|
1234
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
|
|
1235
|
+
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
1236
|
setPhysicalOrientation(event.orientation);
|
|
1073
1237
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
1074
1238
|
styles.titleText,
|
|
@@ -1139,7 +1303,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1139
1303
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
1140
1304
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
1141
1305
|
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) }));
|
|
1306
|
+
], 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
1307
|
}
|
|
1144
1308
|
const CORNER_SIZE = 30;
|
|
1145
1309
|
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/types/index.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface VerificationListParams {
|
|
|
57
57
|
export interface VerifyOptions {
|
|
58
58
|
/** Idempotency key to prevent duplicate verifications on retry. */
|
|
59
59
|
idempotencyKey?: string;
|
|
60
|
+
/** Optional primitive metadata attached to client-side request telemetry. */
|
|
61
|
+
telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
|
|
60
62
|
}
|
|
61
63
|
export interface QueueItem {
|
|
62
64
|
id: string;
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.5.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.2";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.2';
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -78,6 +78,65 @@ interface RequestContext {
|
|
|
78
78
|
method: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
type ClientTelemetryValue = string | number | boolean | null | undefined;
|
|
82
|
+
type ClientTelemetryMetadata = Record<string, ClientTelemetryValue>;
|
|
83
|
+
|
|
84
|
+
const VERIFY_METADATA_TELEMETRY_KEYS = [
|
|
85
|
+
'rideId',
|
|
86
|
+
'vehicleType',
|
|
87
|
+
'verificationAttemptId',
|
|
88
|
+
'verificationAttempt',
|
|
89
|
+
'verificationMaxAttempts',
|
|
90
|
+
'verificationSource',
|
|
91
|
+
'torchEnabled',
|
|
92
|
+
'scannerSessionId',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function toTelemetryKey(key: string): string {
|
|
96
|
+
return key
|
|
97
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
98
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
99
|
+
.replace(/^_+|_+$/g, '')
|
|
100
|
+
.toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compactClientTelemetryMetadata(
|
|
104
|
+
metadata: ClientTelemetryMetadata = {},
|
|
105
|
+
): Record<string, string | number> {
|
|
106
|
+
const compacted: Record<string, string | number> = {};
|
|
107
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
108
|
+
if (value === null || value === undefined) continue;
|
|
109
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
110
|
+
}
|
|
111
|
+
return compacted;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildVerifyTelemetryMetadata(
|
|
115
|
+
requestMetadata: Record<string, unknown> | undefined,
|
|
116
|
+
options?: VerifyOptions,
|
|
117
|
+
): ClientTelemetryMetadata {
|
|
118
|
+
const metadata: ClientTelemetryMetadata = {
|
|
119
|
+
...options?.telemetryMetadata,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (options?.idempotencyKey) {
|
|
123
|
+
metadata.idempotency_key = options.idempotencyKey;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const key of VERIFY_METADATA_TELEMETRY_KEYS) {
|
|
127
|
+
const value = requestMetadata?.[key];
|
|
128
|
+
if (
|
|
129
|
+
typeof value === 'string' ||
|
|
130
|
+
typeof value === 'number' ||
|
|
131
|
+
typeof value === 'boolean'
|
|
132
|
+
) {
|
|
133
|
+
metadata[`verify_metadata_${toTelemetryKey(key)}`] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return metadata;
|
|
138
|
+
}
|
|
139
|
+
|
|
81
140
|
export class VerifyAIClient {
|
|
82
141
|
private apiKey: string;
|
|
83
142
|
private baseUrl: string;
|
|
@@ -147,7 +206,8 @@ export class VerifyAIClient {
|
|
|
147
206
|
|
|
148
207
|
private async executeRequest<T>(
|
|
149
208
|
path: string,
|
|
150
|
-
options: RequestInit = {}
|
|
209
|
+
options: RequestInit = {},
|
|
210
|
+
telemetryMetadata: ClientTelemetryMetadata = {},
|
|
151
211
|
): Promise<T> {
|
|
152
212
|
const controller = new AbortController();
|
|
153
213
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
@@ -218,6 +278,7 @@ export class VerifyAIClient {
|
|
|
218
278
|
? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
|
|
219
279
|
: normalized;
|
|
220
280
|
const metadata: Record<string, string | number> = {
|
|
281
|
+
...compactClientTelemetryMetadata(telemetryMetadata),
|
|
221
282
|
method: context.method,
|
|
222
283
|
path: context.path,
|
|
223
284
|
status: normalized.status,
|
|
@@ -243,9 +304,10 @@ export class VerifyAIClient {
|
|
|
243
304
|
|
|
244
305
|
private async request<T>(
|
|
245
306
|
path: string,
|
|
246
|
-
options: RequestInit = {}
|
|
307
|
+
options: RequestInit = {},
|
|
308
|
+
telemetryMetadata: ClientTelemetryMetadata = {},
|
|
247
309
|
): Promise<T> {
|
|
248
|
-
return this.executeRequest<T>(path, options);
|
|
310
|
+
return this.executeRequest<T>(path, options, telemetryMetadata);
|
|
249
311
|
}
|
|
250
312
|
|
|
251
313
|
/**
|
|
@@ -276,11 +338,12 @@ export class VerifyAIClient {
|
|
|
276
338
|
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
277
339
|
}
|
|
278
340
|
const enrichedRequest = { ...request, metadata: enrichMetadata(request.metadata) };
|
|
341
|
+
const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedRequest.metadata, options);
|
|
279
342
|
return this.request<VerificationResult>('/verify', {
|
|
280
343
|
method: 'POST',
|
|
281
344
|
headers,
|
|
282
345
|
body: JSON.stringify(enrichedRequest),
|
|
283
|
-
});
|
|
346
|
+
}, telemetryMetadata);
|
|
284
347
|
}
|
|
285
348
|
|
|
286
349
|
/**
|
|
@@ -300,7 +363,8 @@ export class VerifyAIClient {
|
|
|
300
363
|
name: 'photo.jpg',
|
|
301
364
|
} as unknown as Blob);
|
|
302
365
|
formData.append('policy', request.policy);
|
|
303
|
-
|
|
366
|
+
const enrichedMetadata = enrichMetadata(request.metadata);
|
|
367
|
+
formData.append('metadata', JSON.stringify(enrichedMetadata));
|
|
304
368
|
if (request.provider) {
|
|
305
369
|
formData.append('provider', request.provider);
|
|
306
370
|
}
|
|
@@ -316,12 +380,14 @@ export class VerifyAIClient {
|
|
|
316
380
|
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
317
381
|
}
|
|
318
382
|
|
|
383
|
+
const telemetryMetadata = buildVerifyTelemetryMetadata(enrichedMetadata, options);
|
|
384
|
+
|
|
319
385
|
try {
|
|
320
386
|
return await this.executeRequest<VerificationResult>('/verify', {
|
|
321
387
|
method: 'POST',
|
|
322
388
|
headers,
|
|
323
389
|
body: formData,
|
|
324
|
-
});
|
|
390
|
+
}, telemetryMetadata);
|
|
325
391
|
} catch (error) {
|
|
326
392
|
// 5xx during multipart can mean the server crashed before reservation
|
|
327
393
|
// (e.g. Vercel multipart-parse failure) OR after the verification was
|
|
@@ -342,7 +408,7 @@ export class VerifyAIClient {
|
|
|
342
408
|
provider: request.provider,
|
|
343
409
|
include_image_data: request.include_image_data,
|
|
344
410
|
},
|
|
345
|
-
options
|
|
411
|
+
options,
|
|
346
412
|
);
|
|
347
413
|
} catch {
|
|
348
414
|
// If the retry itself fails, throw the original error
|
|
@@ -45,10 +45,13 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
45
45
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
46
46
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
47
47
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
48
|
+
const CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS = 800;
|
|
48
49
|
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
49
50
|
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
50
51
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
51
52
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
53
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
54
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
52
55
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
53
56
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
54
57
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -151,6 +154,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
151
154
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
function createScannerSessionId(): string {
|
|
158
|
+
return `scan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
function isNativeCameraCaptureError(error: ErrorWithDetails): boolean {
|
|
155
162
|
const message = error.message.toLowerCase();
|
|
156
163
|
return (
|
|
@@ -309,6 +316,7 @@ export function VerifyAIScanner({
|
|
|
309
316
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
310
317
|
const [cameraReady, setCameraReady] = useState(false);
|
|
311
318
|
const [cameraKey, setCameraKey] = useState(0);
|
|
319
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
312
320
|
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
313
321
|
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
314
322
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -321,6 +329,11 @@ export function VerifyAIScanner({
|
|
|
321
329
|
const lastAppStateRemountAtRef = useRef<string | null>(null);
|
|
322
330
|
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
323
331
|
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
332
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
333
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
334
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
335
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
336
|
+
const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
324
337
|
const cameraRemountCountRef = useRef(0);
|
|
325
338
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
326
339
|
const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
|
|
@@ -361,17 +374,25 @@ export function VerifyAIScanner({
|
|
|
361
374
|
|
|
362
375
|
physicalOrientationRef.current = physicalOrientation;
|
|
363
376
|
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
377
|
+
const scannerSessionIdRef = useRef(createScannerSessionId());
|
|
378
|
+
const captureSequenceRef = useRef(0);
|
|
379
|
+
const torchRetrySuppressedRef = useRef(false);
|
|
380
|
+
const [torchRetrySuppressed, setTorchRetrySuppressed] = useState(false);
|
|
364
381
|
|
|
365
382
|
const buildScannerTelemetryMetadata = useCallback((
|
|
366
383
|
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
367
384
|
): ScannerTelemetryMetadata => compactTelemetryMetadata({
|
|
385
|
+
scanner_session_id: scannerSessionIdRef.current,
|
|
368
386
|
policy,
|
|
369
387
|
status,
|
|
370
388
|
camera_ready: cameraReadyRef.current,
|
|
371
389
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
372
390
|
camera_init_failed: cameraInitFailedRef.current,
|
|
373
391
|
camera_key: cameraKey,
|
|
392
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
374
393
|
camera_remount_count: cameraRemountCountRef.current,
|
|
394
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
395
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
375
396
|
terminated,
|
|
376
397
|
exhausted,
|
|
377
398
|
app_state: appStateRef.current,
|
|
@@ -389,6 +410,8 @@ export function VerifyAIScanner({
|
|
|
389
410
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
390
411
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
391
412
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
413
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
414
|
+
torch_retry_suppressed: torchRetrySuppressedRef.current,
|
|
392
415
|
android_native_orientation_subscription_active: 0,
|
|
393
416
|
android_native_orientation_event_count: 0,
|
|
394
417
|
android_native_orientation_change_count: 0,
|
|
@@ -425,7 +448,9 @@ export function VerifyAIScanner({
|
|
|
425
448
|
...extra,
|
|
426
449
|
}), [
|
|
427
450
|
cameraKey,
|
|
451
|
+
cameraMounted,
|
|
428
452
|
exhausted,
|
|
453
|
+
enableTorch,
|
|
429
454
|
isLandscape,
|
|
430
455
|
overlayRotationDeg,
|
|
431
456
|
physicalOrientation,
|
|
@@ -441,6 +466,11 @@ export function VerifyAIScanner({
|
|
|
441
466
|
telemetryRef.current = telemetry;
|
|
442
467
|
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
443
468
|
|
|
469
|
+
const setTorchSuppressedForRetry = useCallback((nextSuppressed: boolean) => {
|
|
470
|
+
torchRetrySuppressedRef.current = nextSuppressed;
|
|
471
|
+
setTorchRetrySuppressed(nextSuppressed);
|
|
472
|
+
}, []);
|
|
473
|
+
|
|
444
474
|
useEffect(() => {
|
|
445
475
|
const reporter = telemetryRef.current;
|
|
446
476
|
if (reporter) {
|
|
@@ -476,6 +506,53 @@ export function VerifyAIScanner({
|
|
|
476
506
|
};
|
|
477
507
|
}, []);
|
|
478
508
|
|
|
509
|
+
const requestCameraRemount = useCallback((
|
|
510
|
+
reason: string,
|
|
511
|
+
opts: {
|
|
512
|
+
backoffMs?: number;
|
|
513
|
+
log?: boolean;
|
|
514
|
+
} = {},
|
|
515
|
+
) => {
|
|
516
|
+
if (startupRemountTimerRef.current) {
|
|
517
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
518
|
+
startupRemountTimerRef.current = null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
setCameraReady(false);
|
|
522
|
+
cameraReadyRef.current = false;
|
|
523
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
524
|
+
|
|
525
|
+
if (opts.log) {
|
|
526
|
+
console.warn(
|
|
527
|
+
`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
528
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0} ` +
|
|
529
|
+
`torchRetrySuppressed=${torchRetrySuppressedRef.current ? 1 : 0}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const remount = () => {
|
|
534
|
+
setCameraKey((k) => k + 1);
|
|
535
|
+
setCameraMounted(true);
|
|
536
|
+
startupRemountTimerRef.current = null;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
540
|
+
setCameraMounted(false);
|
|
541
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
setCameraMounted(true);
|
|
546
|
+
remount();
|
|
547
|
+
}, [enableTorch]);
|
|
548
|
+
|
|
549
|
+
useEffect(() => () => {
|
|
550
|
+
if (startupRemountTimerRef.current) {
|
|
551
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
552
|
+
startupRemountTimerRef.current = null;
|
|
553
|
+
}
|
|
554
|
+
}, []);
|
|
555
|
+
|
|
479
556
|
useEffect(() => {
|
|
480
557
|
if (Platform.OS !== 'android') return;
|
|
481
558
|
|
|
@@ -728,17 +805,9 @@ export function VerifyAIScanner({
|
|
|
728
805
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
729
806
|
},
|
|
730
807
|
});
|
|
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);
|
|
808
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
740
809
|
}
|
|
741
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
810
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
742
811
|
|
|
743
812
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
744
813
|
useEffect(() => {
|
|
@@ -769,13 +838,11 @@ export function VerifyAIScanner({
|
|
|
769
838
|
remount_requested_at: at,
|
|
770
839
|
}),
|
|
771
840
|
});
|
|
772
|
-
|
|
773
|
-
cameraReadyRef.current = false;
|
|
774
|
-
setCameraKey((k) => k + 1);
|
|
841
|
+
requestCameraRemount('appstate_active');
|
|
775
842
|
});
|
|
776
843
|
|
|
777
844
|
return () => subscription.remove();
|
|
778
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
845
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
779
846
|
|
|
780
847
|
const pausePreview = useCallback(() => {
|
|
781
848
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -836,13 +903,36 @@ export function VerifyAIScanner({
|
|
|
836
903
|
|
|
837
904
|
// Camera init callbacks
|
|
838
905
|
const onCameraReady = useCallback(() => {
|
|
839
|
-
|
|
906
|
+
const readyAt = new Date().toISOString();
|
|
907
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
908
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
909
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
840
910
|
setCameraReady(true);
|
|
841
911
|
cameraReadyRef.current = true;
|
|
842
912
|
cameraEverReadyRef.current = true;
|
|
843
913
|
cameraInitFailedRef.current = false;
|
|
844
914
|
startupWatchdogRemountCountRef.current = 0;
|
|
845
|
-
|
|
915
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
916
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
917
|
+
console.log(
|
|
918
|
+
`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
919
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
920
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
921
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
922
|
+
);
|
|
923
|
+
telemetry?.track('camera_ready', {
|
|
924
|
+
component: 'scanner',
|
|
925
|
+
error: 'camera_ready',
|
|
926
|
+
metadata: buildScannerTelemetryMetadata({
|
|
927
|
+
camera_ready_at: readyAt,
|
|
928
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
929
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
930
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
931
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
932
|
+
}),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
846
936
|
|
|
847
937
|
const onMountError = useCallback((event: { message?: string }) => {
|
|
848
938
|
const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
|
|
@@ -865,12 +955,68 @@ export function VerifyAIScanner({
|
|
|
865
955
|
});
|
|
866
956
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
867
957
|
|
|
958
|
+
useEffect(() => {
|
|
959
|
+
if (
|
|
960
|
+
!permission?.granted ||
|
|
961
|
+
terminated ||
|
|
962
|
+
currentAppState !== 'active' ||
|
|
963
|
+
!cameraMounted ||
|
|
964
|
+
cameraReadyRef.current ||
|
|
965
|
+
cameraInitFailedRef.current ||
|
|
966
|
+
cameraStartupSlowTrackedRef.current
|
|
967
|
+
) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const timer = setTimeout(() => {
|
|
972
|
+
if (
|
|
973
|
+
appStateRef.current !== 'active' ||
|
|
974
|
+
cameraReadyRef.current ||
|
|
975
|
+
cameraInitFailedRef.current ||
|
|
976
|
+
cameraStartupSlowTrackedRef.current
|
|
977
|
+
) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
982
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
983
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
984
|
+
console.warn(
|
|
985
|
+
`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
986
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
987
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
988
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
989
|
+
);
|
|
990
|
+
telemetry?.track('camera_startup_slow', {
|
|
991
|
+
component: 'scanner',
|
|
992
|
+
error: 'camera_startup_slow',
|
|
993
|
+
metadata: buildScannerTelemetryMetadata({
|
|
994
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
995
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
996
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
997
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
998
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
999
|
+
}),
|
|
1000
|
+
});
|
|
1001
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
1002
|
+
|
|
1003
|
+
return () => clearTimeout(timer);
|
|
1004
|
+
}, [
|
|
1005
|
+
permission?.granted,
|
|
1006
|
+
terminated,
|
|
1007
|
+
currentAppState,
|
|
1008
|
+
cameraMounted,
|
|
1009
|
+
buildScannerTelemetryMetadata,
|
|
1010
|
+
enableTorch,
|
|
1011
|
+
telemetry,
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
868
1014
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
869
1015
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
870
1016
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
871
1017
|
// recreate the CameraView, which starts a fresh native session.
|
|
872
1018
|
useEffect(() => {
|
|
873
|
-
if (!permission?.granted || terminated || currentAppState !== 'active') return;
|
|
1019
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
|
|
874
1020
|
|
|
875
1021
|
const watchdogMs = Platform.OS === 'android'
|
|
876
1022
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -904,11 +1050,14 @@ export function VerifyAIScanner({
|
|
|
904
1050
|
remount_reason: 'startup_watchdog',
|
|
905
1051
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
906
1052
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
1053
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1054
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
907
1055
|
}),
|
|
908
1056
|
});
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1057
|
+
requestCameraRemount('startup_watchdog', {
|
|
1058
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1059
|
+
log: true,
|
|
1060
|
+
});
|
|
912
1061
|
}
|
|
913
1062
|
}
|
|
914
1063
|
}, watchdogMs);
|
|
@@ -918,9 +1067,12 @@ export function VerifyAIScanner({
|
|
|
918
1067
|
permission?.granted,
|
|
919
1068
|
terminated,
|
|
920
1069
|
currentAppState,
|
|
1070
|
+
cameraMounted,
|
|
921
1071
|
cameraKey,
|
|
922
1072
|
buildScannerTelemetryMetadata,
|
|
1073
|
+
enableTorch,
|
|
923
1074
|
onMountError,
|
|
1075
|
+
requestCameraRemount,
|
|
924
1076
|
telemetry,
|
|
925
1077
|
]);
|
|
926
1078
|
|
|
@@ -941,6 +1093,9 @@ export function VerifyAIScanner({
|
|
|
941
1093
|
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
942
1094
|
|
|
943
1095
|
const handleCapture = useCallback(async () => {
|
|
1096
|
+
const captureSequence = captureSequenceRef.current + 1;
|
|
1097
|
+
captureSequenceRef.current = captureSequence;
|
|
1098
|
+
const captureAttemptId = `${scannerSessionIdRef.current}_cap_${captureSequence}`;
|
|
944
1099
|
const blockedReason =
|
|
945
1100
|
!cameraRef.current
|
|
946
1101
|
? 'camera_ref_null'
|
|
@@ -960,6 +1115,8 @@ export function VerifyAIScanner({
|
|
|
960
1115
|
component: 'scanner',
|
|
961
1116
|
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
962
1117
|
metadata: buildScannerTelemetryMetadata({
|
|
1118
|
+
capture_attempt_id: captureAttemptId,
|
|
1119
|
+
capture_sequence: captureSequence,
|
|
963
1120
|
capture_blocked_reason: blockedReason,
|
|
964
1121
|
}),
|
|
965
1122
|
});
|
|
@@ -985,6 +1142,7 @@ export function VerifyAIScanner({
|
|
|
985
1142
|
}
|
|
986
1143
|
|
|
987
1144
|
setStatus('capturing');
|
|
1145
|
+
setTorchSuppressedForRetry(false);
|
|
988
1146
|
setResult(null);
|
|
989
1147
|
setLastError(null);
|
|
990
1148
|
terminalResultRef.current = null;
|
|
@@ -997,6 +1155,9 @@ export function VerifyAIScanner({
|
|
|
997
1155
|
let nativeCaptureAttempts = 0;
|
|
998
1156
|
let captureRetryAttempted = false;
|
|
999
1157
|
let captureRetryReady = false;
|
|
1158
|
+
let captureRetryTorchSuppressed = false;
|
|
1159
|
+
let captureRetrySettleDelayMs = CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
1160
|
+
let captureRetryRemountBackoffMs = 0;
|
|
1000
1161
|
let lastNativeCaptureErrorMessage: string | null = null;
|
|
1001
1162
|
|
|
1002
1163
|
try {
|
|
@@ -1006,6 +1167,8 @@ export function VerifyAIScanner({
|
|
|
1006
1167
|
component: 'scanner',
|
|
1007
1168
|
error: 'capture_orientation_context',
|
|
1008
1169
|
metadata: buildScannerTelemetryMetadata({
|
|
1170
|
+
capture_attempt_id: captureAttemptId,
|
|
1171
|
+
capture_sequence: captureSequence,
|
|
1009
1172
|
capture_physical_orientation: capturePhysicalOrientation,
|
|
1010
1173
|
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
1011
1174
|
capture_rotation_applied: 0,
|
|
@@ -1074,13 +1237,26 @@ export function VerifyAIScanner({
|
|
|
1074
1237
|
|
|
1075
1238
|
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
1076
1239
|
captureRetryAttempted = true;
|
|
1240
|
+
captureRetryTorchSuppressed = !!enableTorch;
|
|
1241
|
+
captureRetrySettleDelayMs = captureRetryTorchSuppressed
|
|
1242
|
+
? CAMERA_CAPTURE_RETRY_TORCH_SETTLE_MS
|
|
1243
|
+
: CAMERA_CAPTURE_RETRY_DELAY_MS;
|
|
1244
|
+
captureRetryRemountBackoffMs = captureRetryTorchSuppressed
|
|
1245
|
+
? CAMERA_CAPTURE_RETRY_DELAY_MS
|
|
1246
|
+
: 0;
|
|
1077
1247
|
const retryAt = new Date().toISOString();
|
|
1078
1248
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1079
1249
|
cameraRemountCountRef.current++;
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1250
|
+
if (captureRetryTorchSuppressed) {
|
|
1251
|
+
setTorchSuppressedForRetry(true);
|
|
1252
|
+
}
|
|
1253
|
+
requestCameraRemount(
|
|
1254
|
+
'capture_retry',
|
|
1255
|
+
captureRetryRemountBackoffMs > 0
|
|
1256
|
+
? { backoffMs: captureRetryRemountBackoffMs }
|
|
1257
|
+
: undefined,
|
|
1258
|
+
);
|
|
1259
|
+
await sleep(captureRetrySettleDelayMs);
|
|
1084
1260
|
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1085
1261
|
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
1086
1262
|
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
@@ -1090,11 +1266,16 @@ export function VerifyAIScanner({
|
|
|
1090
1266
|
error: normalized,
|
|
1091
1267
|
errorCode: normalized.code,
|
|
1092
1268
|
metadata: buildScannerTelemetryMetadata({
|
|
1269
|
+
capture_attempt_id: captureAttemptId,
|
|
1270
|
+
capture_sequence: captureSequence,
|
|
1093
1271
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1094
1272
|
capture_retry_attempted: captureRetryAttempted,
|
|
1095
1273
|
capture_retry_ready: captureRetryReady,
|
|
1096
1274
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
1275
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1276
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1097
1277
|
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1278
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1098
1279
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1099
1280
|
}),
|
|
1100
1281
|
});
|
|
@@ -1188,6 +1369,8 @@ export function VerifyAIScanner({
|
|
|
1188
1369
|
telemetry?.track('image_processed', {
|
|
1189
1370
|
component: 'scanner',
|
|
1190
1371
|
metadata: buildScannerTelemetryMetadata({
|
|
1372
|
+
capture_attempt_id: captureAttemptId,
|
|
1373
|
+
capture_sequence: captureSequence,
|
|
1191
1374
|
original_width: origWidth,
|
|
1192
1375
|
original_height: origHeight,
|
|
1193
1376
|
processed_width: processedWidth,
|
|
@@ -1197,6 +1380,9 @@ export function VerifyAIScanner({
|
|
|
1197
1380
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1198
1381
|
capture_retry_attempted: captureRetryAttempted,
|
|
1199
1382
|
capture_retry_ready: captureRetryReady,
|
|
1383
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1384
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1385
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1200
1386
|
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
1201
1387
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1202
1388
|
}),
|
|
@@ -1271,9 +1457,14 @@ export function VerifyAIScanner({
|
|
|
1271
1457
|
component: 'scanner',
|
|
1272
1458
|
error,
|
|
1273
1459
|
metadata: buildScannerTelemetryMetadata({
|
|
1460
|
+
capture_attempt_id: captureAttemptId,
|
|
1461
|
+
capture_sequence: captureSequence,
|
|
1274
1462
|
native_capture_attempts: nativeCaptureAttempts,
|
|
1275
1463
|
capture_retry_attempted: captureRetryAttempted,
|
|
1276
1464
|
capture_retry_ready: captureRetryReady,
|
|
1465
|
+
capture_retry_torch_suppressed: captureRetryTorchSuppressed,
|
|
1466
|
+
capture_retry_settle_delay_ms: captureRetrySettleDelayMs,
|
|
1467
|
+
capture_retry_remount_backoff_ms: captureRetryRemountBackoffMs,
|
|
1277
1468
|
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
1278
1469
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1279
1470
|
}),
|
|
@@ -1284,8 +1475,10 @@ export function VerifyAIScanner({
|
|
|
1284
1475
|
if (!terminalRequestError) {
|
|
1285
1476
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1286
1477
|
}
|
|
1478
|
+
} finally {
|
|
1479
|
+
setTorchSuppressedForRetry(false);
|
|
1287
1480
|
}
|
|
1288
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1481
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg, enableTorch, setTorchSuppressedForRetry]);
|
|
1289
1482
|
|
|
1290
1483
|
// Expose capture to parent via ref
|
|
1291
1484
|
if (captureRef) {
|
|
@@ -1308,22 +1501,24 @@ export function VerifyAIScanner({
|
|
|
1308
1501
|
}
|
|
1309
1502
|
|
|
1310
1503
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1504
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch && !torchRetrySuppressed;
|
|
1311
1505
|
|
|
1312
1506
|
return (
|
|
1313
1507
|
<View style={[styles.container, style]}>
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1508
|
+
{cameraMounted && (
|
|
1509
|
+
<CameraView
|
|
1510
|
+
key={cameraKey}
|
|
1511
|
+
ref={cameraRef}
|
|
1512
|
+
style={styles.camera}
|
|
1513
|
+
facing="back"
|
|
1514
|
+
enableTorch={shouldEnableTorch}
|
|
1515
|
+
onCameraReady={onCameraReady}
|
|
1516
|
+
onMountError={onMountError}
|
|
1517
|
+
responsiveOrientationWhenOrientationLocked
|
|
1518
|
+
onResponsiveOrientationChanged={(event) => {
|
|
1519
|
+
setPhysicalOrientation(event.orientation);
|
|
1520
|
+
}}
|
|
1521
|
+
>
|
|
1327
1522
|
{/* Overlay */}
|
|
1328
1523
|
<View style={styles.overlay}>
|
|
1329
1524
|
{overlay?.title && (
|
|
@@ -1527,7 +1722,8 @@ export function VerifyAIScanner({
|
|
|
1527
1722
|
</TouchableOpacity>
|
|
1528
1723
|
)}
|
|
1529
1724
|
</View>
|
|
1530
|
-
|
|
1725
|
+
</CameraView>
|
|
1726
|
+
)}
|
|
1531
1727
|
</View>
|
|
1532
1728
|
);
|
|
1533
1729
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface VerificationListParams {
|
|
|
64
64
|
export interface VerifyOptions {
|
|
65
65
|
/** Idempotency key to prevent duplicate verifications on retry. */
|
|
66
66
|
idempotencyKey?: string;
|
|
67
|
+
/** Optional primitive metadata attached to client-side request telemetry. */
|
|
68
|
+
telemetryMetadata?: Record<string, string | number | boolean | null | undefined>;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
export interface QueueItem {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.5.
|
|
1
|
+
export const SDK_VERSION = '2.5.2';
|