@switchlabs/verify-ai-react-native 2.4.15 → 2.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/VerifyAIScanner.d.ts +4 -2
- package/lib/components/VerifyAIScanner.js +220 -48
- package/lib/index.d.ts +1 -1
- package/lib/types/index.d.ts +12 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +255 -51
- package/src/index.ts +1 -0
- package/src/types/index.ts +13 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type ViewStyle } from 'react-native';
|
|
3
|
-
import type { VerificationResult, ScannerOverlayConfig } from '../types';
|
|
3
|
+
import type { VerificationResult, ScannerOverlayConfig, ScannerTelemetryContext } from '../types';
|
|
4
4
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
5
5
|
export interface VerifyAIScannerProps {
|
|
6
6
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -27,6 +27,8 @@ export interface VerifyAIScannerProps {
|
|
|
27
27
|
enableTorch?: boolean;
|
|
28
28
|
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
29
29
|
telemetry?: TelemetryReporter | null;
|
|
30
|
+
/** Optional host-app context attached to scanner telemetry. */
|
|
31
|
+
telemetryContext?: ScannerTelemetryContext;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* Camera scanner component for capturing verification photos.
|
|
@@ -49,4 +51,4 @@ export interface VerifyAIScannerProps {
|
|
|
49
51
|
* />
|
|
50
52
|
* ```
|
|
51
53
|
*/
|
|
52
|
-
export declare function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton, showCloseButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
54
|
+
export declare function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton, showCloseButton, captureRef, enableTorch, telemetry: telemetryProp, telemetryContext, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -19,6 +19,8 @@ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
|
19
19
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
20
20
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
21
21
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
22
|
+
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
23
|
+
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
22
24
|
function getPolicyScannerDefaults(policy) {
|
|
23
25
|
const id = policy?.toLowerCase() ?? '';
|
|
24
26
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -90,6 +92,15 @@ function compactTelemetryMetadata(metadata) {
|
|
|
90
92
|
}
|
|
91
93
|
return compacted;
|
|
92
94
|
}
|
|
95
|
+
function getPlatformConstantString(...keys) {
|
|
96
|
+
const constants = Platform.constants;
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
const value = constants[key];
|
|
99
|
+
if (typeof value === 'string' && value.trim())
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
93
104
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
94
105
|
if (!error) {
|
|
95
106
|
return {
|
|
@@ -171,7 +182,7 @@ function isTerminalRequestError(error) {
|
|
|
171
182
|
* />
|
|
172
183
|
* ```
|
|
173
184
|
*/
|
|
174
|
-
export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
185
|
+
export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, telemetryContext, }) {
|
|
175
186
|
const contextTelemetry = useTelemetry();
|
|
176
187
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
177
188
|
const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
|
|
@@ -197,6 +208,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
197
208
|
const lastOrientationRemountAtRef = useRef(null);
|
|
198
209
|
const lastCaptureRetryAtRef = useRef(null);
|
|
199
210
|
const cameraRemountCountRef = useRef(0);
|
|
211
|
+
const androidOrientationSubscriptionActiveRef = useRef(false);
|
|
212
|
+
const androidOrientationEventCountRef = useRef(0);
|
|
213
|
+
const androidOrientationChangeCountRef = useRef(0);
|
|
214
|
+
const androidOrientationStartedAtRef = useRef(null);
|
|
215
|
+
const lastAndroidOrientationEventAtRef = useRef(null);
|
|
216
|
+
const lastAndroidOrientationTelemetryAtRef = useRef(0);
|
|
217
|
+
const lastAndroidOrientationXRef = useRef(null);
|
|
218
|
+
const lastAndroidOrientationYRef = useRef(null);
|
|
219
|
+
const lastAndroidOrientationZRef = useRef(null);
|
|
220
|
+
const lastAndroidOrientationDerivedRef = useRef(null);
|
|
221
|
+
const lastAndroidOrientationIgnoredReasonRef = useRef(null);
|
|
222
|
+
const lastAndroidOrientationErrorAtRef = useRef(null);
|
|
223
|
+
const lastAndroidOrientationErrorRef = useRef(null);
|
|
200
224
|
// Track dimensions for orientation detection and responsive layout
|
|
201
225
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
202
226
|
const isLandscape = windowWidth > windowHeight;
|
|
@@ -207,47 +231,6 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
207
231
|
// uses the accelerometer via expo-sensors (the callback is iOS-only). Either
|
|
208
232
|
// way we rotate the overlay UI to stay readable from the user's viewpoint.
|
|
209
233
|
const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
|
|
210
|
-
useEffect(() => {
|
|
211
|
-
if (Platform.OS !== 'android')
|
|
212
|
-
return;
|
|
213
|
-
let subscription = null;
|
|
214
|
-
let cancelled = false;
|
|
215
|
-
let lastOrientation = 'portrait';
|
|
216
|
-
(async () => {
|
|
217
|
-
let Accelerometer = null;
|
|
218
|
-
try {
|
|
219
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
220
|
-
const mod = require('expo-sensors');
|
|
221
|
-
Accelerometer = mod?.Accelerometer ?? null;
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (cancelled || !Accelerometer)
|
|
227
|
-
return;
|
|
228
|
-
Accelerometer.setUpdateInterval(500);
|
|
229
|
-
subscription = Accelerometer.addListener(({ x, y }) => {
|
|
230
|
-
let next;
|
|
231
|
-
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
232
|
-
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
233
|
-
}
|
|
234
|
-
else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
235
|
-
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
return; // ambiguous tilt — ignore
|
|
239
|
-
}
|
|
240
|
-
if (next !== lastOrientation) {
|
|
241
|
-
lastOrientation = next;
|
|
242
|
-
setPhysicalOrientation(next);
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
})();
|
|
246
|
-
return () => {
|
|
247
|
-
cancelled = true;
|
|
248
|
-
subscription?.remove();
|
|
249
|
-
};
|
|
250
|
-
}, []);
|
|
251
234
|
const overlayRotationDeg = (() => {
|
|
252
235
|
switch (physicalOrientation) {
|
|
253
236
|
case 'landscapeLeft':
|
|
@@ -273,6 +256,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
273
256
|
exhausted,
|
|
274
257
|
app_state: AppState.currentState,
|
|
275
258
|
sdk_platform: Platform.OS,
|
|
259
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
260
|
+
device_os_version: String(Platform.Version),
|
|
261
|
+
route_name: telemetryContext?.routeName,
|
|
262
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
276
263
|
window_width: windowWidth,
|
|
277
264
|
window_height: windowHeight,
|
|
278
265
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -282,6 +269,18 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
282
269
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
283
270
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
284
271
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
272
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
273
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
274
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
275
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
276
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
277
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
278
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
279
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
280
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
281
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
282
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
283
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
285
284
|
...extra,
|
|
286
285
|
}), [
|
|
287
286
|
cameraKey,
|
|
@@ -291,10 +290,136 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
291
290
|
physicalOrientation,
|
|
292
291
|
policy,
|
|
293
292
|
status,
|
|
293
|
+
telemetryContext?.isPortraitLocked,
|
|
294
|
+
telemetryContext?.routeName,
|
|
294
295
|
terminated,
|
|
295
296
|
windowHeight,
|
|
296
297
|
windowWidth,
|
|
297
298
|
]);
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (Platform.OS !== 'android')
|
|
301
|
+
return;
|
|
302
|
+
let subscription = null;
|
|
303
|
+
let cancelled = false;
|
|
304
|
+
let noEventTimer = null;
|
|
305
|
+
let lastOrientation = 'portrait';
|
|
306
|
+
const androidMetadata = (extra = {}) => compactTelemetryMetadata({
|
|
307
|
+
policy,
|
|
308
|
+
sdk_platform: Platform.OS,
|
|
309
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
310
|
+
device_os_version: String(Platform.Version),
|
|
311
|
+
route_name: telemetryContext?.routeName,
|
|
312
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
313
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
314
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
315
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
316
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
317
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
318
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
319
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
320
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
321
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
322
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
323
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
324
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
325
|
+
...extra,
|
|
326
|
+
});
|
|
327
|
+
const trackAndroidOrientationEvent = (eventType, error, metadata = {}) => {
|
|
328
|
+
telemetry?.track(eventType, {
|
|
329
|
+
component: 'scanner',
|
|
330
|
+
error,
|
|
331
|
+
metadata: androidMetadata(metadata),
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
(async () => {
|
|
335
|
+
let Accelerometer = null;
|
|
336
|
+
try {
|
|
337
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
338
|
+
const mod = require('expo-sensors');
|
|
339
|
+
Accelerometer = mod?.Accelerometer ?? null;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
const error = normalizeScannerError(err);
|
|
343
|
+
lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
|
|
344
|
+
lastAndroidOrientationErrorRef.current = error.message;
|
|
345
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (cancelled)
|
|
349
|
+
return;
|
|
350
|
+
if (!Accelerometer) {
|
|
351
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', 'expo-sensors Accelerometer is unavailable');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const startedAt = new Date().toISOString();
|
|
355
|
+
androidOrientationStartedAtRef.current = startedAt;
|
|
356
|
+
androidOrientationSubscriptionActiveRef.current = true;
|
|
357
|
+
androidOrientationEventCountRef.current = 0;
|
|
358
|
+
androidOrientationChangeCountRef.current = 0;
|
|
359
|
+
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
360
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
361
|
+
accelerometer_update_interval_ms: 500,
|
|
362
|
+
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
363
|
+
});
|
|
364
|
+
noEventTimer = setTimeout(() => {
|
|
365
|
+
if (cancelled || androidOrientationEventCountRef.current > 0)
|
|
366
|
+
return;
|
|
367
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_no_events', `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`);
|
|
368
|
+
}, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
|
|
369
|
+
Accelerometer.setUpdateInterval(500);
|
|
370
|
+
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
371
|
+
const at = new Date();
|
|
372
|
+
const atIso = at.toISOString();
|
|
373
|
+
androidOrientationEventCountRef.current++;
|
|
374
|
+
lastAndroidOrientationEventAtRef.current = atIso;
|
|
375
|
+
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
376
|
+
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
377
|
+
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
378
|
+
if (noEventTimer) {
|
|
379
|
+
clearTimeout(noEventTimer);
|
|
380
|
+
noEventTimer = null;
|
|
381
|
+
}
|
|
382
|
+
let next = null;
|
|
383
|
+
let ignoredReason = null;
|
|
384
|
+
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
385
|
+
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
386
|
+
}
|
|
387
|
+
else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
388
|
+
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
ignoredReason = 'ambiguous_tilt';
|
|
392
|
+
}
|
|
393
|
+
lastAndroidOrientationDerivedRef.current = next;
|
|
394
|
+
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
395
|
+
if (next && next !== lastOrientation) {
|
|
396
|
+
const previous = lastOrientation;
|
|
397
|
+
lastOrientation = next;
|
|
398
|
+
androidOrientationChangeCountRef.current++;
|
|
399
|
+
setPhysicalOrientation(next);
|
|
400
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
401
|
+
previous_physical_orientation: previous,
|
|
402
|
+
next_physical_orientation: next,
|
|
403
|
+
accelerometer_sampled_at: atIso,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
|
|
408
|
+
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
409
|
+
if (shouldTrackSample) {
|
|
410
|
+
lastAndroidOrientationTelemetryAtRef.current = at.getTime();
|
|
411
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
})();
|
|
415
|
+
return () => {
|
|
416
|
+
cancelled = true;
|
|
417
|
+
androidOrientationSubscriptionActiveRef.current = false;
|
|
418
|
+
if (noEventTimer)
|
|
419
|
+
clearTimeout(noEventTimer);
|
|
420
|
+
subscription?.remove();
|
|
421
|
+
};
|
|
422
|
+
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
298
423
|
// Detect orientation changes and remount camera after rotation settles.
|
|
299
424
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
300
425
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -478,9 +603,29 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
478
603
|
}
|
|
479
604
|
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
480
605
|
const handleCapture = useCallback(async () => {
|
|
481
|
-
|
|
606
|
+
const blockedReason = !cameraRef.current
|
|
607
|
+
? 'camera_ref_null'
|
|
608
|
+
: !cameraReadyRef.current
|
|
609
|
+
? 'camera_not_ready'
|
|
610
|
+
: status === 'capturing'
|
|
611
|
+
? 'already_capturing'
|
|
612
|
+
: status === 'processing'
|
|
613
|
+
? 'already_processing'
|
|
614
|
+
: terminated
|
|
615
|
+
? 'terminated'
|
|
616
|
+
: exhausted
|
|
617
|
+
? 'exhausted'
|
|
618
|
+
: null;
|
|
619
|
+
telemetry?.track('camera_capture_request', {
|
|
620
|
+
component: 'scanner',
|
|
621
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
622
|
+
metadata: buildScannerTelemetryMetadata({
|
|
623
|
+
capture_blocked_reason: blockedReason,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
if (blockedReason && blockedReason !== 'camera_not_ready')
|
|
482
627
|
return;
|
|
483
|
-
if (
|
|
628
|
+
if (blockedReason === 'camera_not_ready') {
|
|
484
629
|
const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
|
|
485
630
|
setResult(null);
|
|
486
631
|
setLastError(error);
|
|
@@ -508,6 +653,17 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
508
653
|
let captureRetryReady = false;
|
|
509
654
|
let lastNativeCaptureErrorMessage = null;
|
|
510
655
|
try {
|
|
656
|
+
const capturePhysicalOrientation = physicalOrientation;
|
|
657
|
+
const captureOverlayRotationDeg = overlayRotationDeg;
|
|
658
|
+
telemetry?.track('camera_capture_orientation_context', {
|
|
659
|
+
component: 'scanner',
|
|
660
|
+
error: 'capture_orientation_context',
|
|
661
|
+
metadata: buildScannerTelemetryMetadata({
|
|
662
|
+
capture_physical_orientation: capturePhysicalOrientation,
|
|
663
|
+
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
664
|
+
capture_rotation_applied: 0,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
511
667
|
// --- Capture + best-effort resize ---
|
|
512
668
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
513
669
|
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
@@ -561,6 +717,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
561
717
|
setCameraKey((key) => key + 1);
|
|
562
718
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
563
719
|
captureRetryReady = await waitForCameraReady(2500);
|
|
720
|
+
telemetry?.track('camera_capture_retry', {
|
|
721
|
+
component: 'scanner',
|
|
722
|
+
error: normalized,
|
|
723
|
+
errorCode: normalized.code,
|
|
724
|
+
metadata: buildScannerTelemetryMetadata({
|
|
725
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
726
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
727
|
+
capture_retry_ready: captureRetryReady,
|
|
728
|
+
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
729
|
+
capture_retry_ready_timeout_ms: 2500,
|
|
730
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
731
|
+
}),
|
|
732
|
+
});
|
|
564
733
|
if (captureRetryReady) {
|
|
565
734
|
continue;
|
|
566
735
|
}
|
|
@@ -714,7 +883,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
714
883
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
715
884
|
}
|
|
716
885
|
}
|
|
717
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
886
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
718
887
|
// Expose capture to parent via ref
|
|
719
888
|
if (captureRef) {
|
|
720
889
|
captureRef.current = handleCapture;
|
|
@@ -730,6 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
730
899
|
setPhysicalOrientation(event.orientation);
|
|
731
900
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
732
901
|
styles.titleText,
|
|
902
|
+
overlay.theme?.titleStyle,
|
|
733
903
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
734
904
|
], children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: [styles.guideContainer, isLandscape && styles.guideContainerLandscape], children: [_jsxs(View, { style: [
|
|
735
905
|
styles.guideFrame,
|
|
@@ -739,8 +909,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
739
909
|
: undefined,
|
|
740
910
|
], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] })] }), overlay.guideCaption && (_jsx(Text, { style: [
|
|
741
911
|
styles.guideCaptionText,
|
|
912
|
+
overlay.theme?.feedbackStyle,
|
|
742
913
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
743
|
-
], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
|
|
914
|
+
], children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: [styles.statusText, overlay?.theme?.feedbackStyle], children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: [styles.bottomArea, isLandscape && styles.bottomAreaLandscape], children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
|
|
744
915
|
styles.resultIconCircle,
|
|
745
916
|
exhausted
|
|
746
917
|
? styles.resultIconExhausted
|
|
@@ -758,7 +929,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
758
929
|
? (overlay?.exhaustedMessage || 'Submitted for review')
|
|
759
930
|
: result.is_compliant
|
|
760
931
|
? (overlay?.successMessage || 'Verified')
|
|
761
|
-
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
|
|
932
|
+
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
|
|
762
933
|
let errorTitle;
|
|
763
934
|
let errorMessage;
|
|
764
935
|
let showCloseAction = false;
|
|
@@ -785,9 +956,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
785
956
|
errorMessage = display.message;
|
|
786
957
|
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
787
958
|
}
|
|
788
|
-
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
|
|
959
|
+
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: [styles.feedbackText, overlay?.theme?.feedbackStyle], children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
|
|
789
960
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
|
|
790
961
|
styles.instructionsText,
|
|
962
|
+
overlay.theme?.feedbackStyle,
|
|
791
963
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
792
964
|
], children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
|
|
793
965
|
styles.captureButton,
|
package/lib/index.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export { TelemetryReporter } from './telemetry/TelemetryReporter';
|
|
|
5
5
|
export { TelemetryContext } from './telemetry/TelemetryContext';
|
|
6
6
|
export { OfflineQueue } from './storage/offlineQueue';
|
|
7
7
|
export type { BundleManifest, ModelArtifact, FeatureVector, Detection as MLDetection, PolicyAST, PolicyResult, RuleResult, } from './ml/types';
|
|
8
|
-
export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, PolicyConfigResponse, } from './types';
|
|
8
|
+
export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, ScannerTelemetryContext, PolicyConfigResponse, } from './types';
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
+
import type { TextStyle } from 'react-native';
|
|
2
3
|
export interface VerifyAIConfig {
|
|
3
4
|
apiKey: string;
|
|
4
5
|
baseUrl?: string;
|
|
@@ -76,6 +77,13 @@ export interface VerifyAIError {
|
|
|
76
77
|
method?: string;
|
|
77
78
|
}
|
|
78
79
|
export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
|
|
80
|
+
/** Optional host-app context attached to scanner telemetry. */
|
|
81
|
+
export interface ScannerTelemetryContext {
|
|
82
|
+
/** Route or screen name from the host app. */
|
|
83
|
+
routeName?: string;
|
|
84
|
+
/** Whether the host app keeps this screen portrait-locked. */
|
|
85
|
+
isPortraitLocked?: boolean;
|
|
86
|
+
}
|
|
79
87
|
/**
|
|
80
88
|
* Theme customization for the scanner overlay.
|
|
81
89
|
*
|
|
@@ -92,6 +100,10 @@ export interface ScannerTheme {
|
|
|
92
100
|
captureButtonColor?: string;
|
|
93
101
|
/** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
|
|
94
102
|
cornerColor?: string;
|
|
103
|
+
/** Text style override for the scanner title. */
|
|
104
|
+
titleStyle?: TextStyle;
|
|
105
|
+
/** Text style override for feedback/instruction text. */
|
|
106
|
+
feedbackStyle?: TextStyle;
|
|
95
107
|
}
|
|
96
108
|
export interface ScannerOverlayConfig {
|
|
97
109
|
title?: string;
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.4.
|
|
1
|
+
export declare const SDK_VERSION = "2.4.16";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.16';
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
VerificationResult,
|
|
19
19
|
ScannerStatus,
|
|
20
20
|
ScannerOverlayConfig,
|
|
21
|
+
ScannerTelemetryContext,
|
|
21
22
|
} from '../types';
|
|
22
23
|
import { VerifyAIRequestError } from '../client';
|
|
23
24
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
@@ -38,6 +39,8 @@ const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
|
38
39
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
39
40
|
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
40
41
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
42
|
+
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
43
|
+
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
41
44
|
|
|
42
45
|
export interface VerifyAIScannerProps {
|
|
43
46
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -64,6 +67,8 @@ export interface VerifyAIScannerProps {
|
|
|
64
67
|
enableTorch?: boolean;
|
|
65
68
|
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
66
69
|
telemetry?: TelemetryReporter | null;
|
|
70
|
+
/** Optional host-app context attached to scanner telemetry. */
|
|
71
|
+
telemetryContext?: ScannerTelemetryContext;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
type ErrorWithDetails = Error & {
|
|
@@ -165,6 +170,15 @@ function compactTelemetryMetadata(
|
|
|
165
170
|
return compacted;
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
function getPlatformConstantString(...keys: string[]): string | null {
|
|
174
|
+
const constants = Platform.constants as Record<string, unknown>;
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const value = constants[key];
|
|
177
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
169
183
|
if (!error) {
|
|
170
184
|
return {
|
|
@@ -261,6 +275,7 @@ export function VerifyAIScanner({
|
|
|
261
275
|
captureRef,
|
|
262
276
|
enableTorch,
|
|
263
277
|
telemetry: telemetryProp,
|
|
278
|
+
telemetryContext,
|
|
264
279
|
}: VerifyAIScannerProps) {
|
|
265
280
|
const contextTelemetry = useTelemetry();
|
|
266
281
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
@@ -291,6 +306,19 @@ export function VerifyAIScanner({
|
|
|
291
306
|
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
292
307
|
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
293
308
|
const cameraRemountCountRef = useRef(0);
|
|
309
|
+
const androidOrientationSubscriptionActiveRef = useRef(false);
|
|
310
|
+
const androidOrientationEventCountRef = useRef(0);
|
|
311
|
+
const androidOrientationChangeCountRef = useRef(0);
|
|
312
|
+
const androidOrientationStartedAtRef = useRef<string | null>(null);
|
|
313
|
+
const lastAndroidOrientationEventAtRef = useRef<string | null>(null);
|
|
314
|
+
const lastAndroidOrientationTelemetryAtRef = useRef(0);
|
|
315
|
+
const lastAndroidOrientationXRef = useRef<number | null>(null);
|
|
316
|
+
const lastAndroidOrientationYRef = useRef<number | null>(null);
|
|
317
|
+
const lastAndroidOrientationZRef = useRef<number | null>(null);
|
|
318
|
+
const lastAndroidOrientationDerivedRef = useRef<string | null>(null);
|
|
319
|
+
const lastAndroidOrientationIgnoredReasonRef = useRef<string | null>(null);
|
|
320
|
+
const lastAndroidOrientationErrorAtRef = useRef<string | null>(null);
|
|
321
|
+
const lastAndroidOrientationErrorRef = useRef<string | null>(null);
|
|
294
322
|
|
|
295
323
|
// Track dimensions for orientation detection and responsive layout
|
|
296
324
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -306,51 +334,6 @@ export function VerifyAIScanner({
|
|
|
306
334
|
'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight'
|
|
307
335
|
>('portrait');
|
|
308
336
|
|
|
309
|
-
useEffect(() => {
|
|
310
|
-
if (Platform.OS !== 'android') return;
|
|
311
|
-
let subscription: { remove: () => void } | null = null;
|
|
312
|
-
let cancelled = false;
|
|
313
|
-
let lastOrientation: typeof physicalOrientation = 'portrait';
|
|
314
|
-
|
|
315
|
-
(async () => {
|
|
316
|
-
let Accelerometer: {
|
|
317
|
-
setUpdateInterval: (ms: number) => void;
|
|
318
|
-
addListener: (
|
|
319
|
-
cb: (data: { x: number; y: number; z: number }) => void,
|
|
320
|
-
) => { remove: () => void };
|
|
321
|
-
} | null = null;
|
|
322
|
-
try {
|
|
323
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
324
|
-
const mod = require('expo-sensors');
|
|
325
|
-
Accelerometer = mod?.Accelerometer ?? null;
|
|
326
|
-
} catch {
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
if (cancelled || !Accelerometer) return;
|
|
330
|
-
|
|
331
|
-
Accelerometer.setUpdateInterval(500);
|
|
332
|
-
subscription = Accelerometer.addListener(({ x, y }) => {
|
|
333
|
-
let next: typeof physicalOrientation;
|
|
334
|
-
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
335
|
-
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
336
|
-
} else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
337
|
-
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
338
|
-
} else {
|
|
339
|
-
return; // ambiguous tilt — ignore
|
|
340
|
-
}
|
|
341
|
-
if (next !== lastOrientation) {
|
|
342
|
-
lastOrientation = next;
|
|
343
|
-
setPhysicalOrientation(next);
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
})();
|
|
347
|
-
|
|
348
|
-
return () => {
|
|
349
|
-
cancelled = true;
|
|
350
|
-
subscription?.remove();
|
|
351
|
-
};
|
|
352
|
-
}, []);
|
|
353
|
-
|
|
354
337
|
const overlayRotationDeg = (() => {
|
|
355
338
|
switch (physicalOrientation) {
|
|
356
339
|
case 'landscapeLeft':
|
|
@@ -379,6 +362,10 @@ export function VerifyAIScanner({
|
|
|
379
362
|
exhausted,
|
|
380
363
|
app_state: AppState.currentState,
|
|
381
364
|
sdk_platform: Platform.OS,
|
|
365
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
366
|
+
device_os_version: String(Platform.Version),
|
|
367
|
+
route_name: telemetryContext?.routeName,
|
|
368
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
382
369
|
window_width: windowWidth,
|
|
383
370
|
window_height: windowHeight,
|
|
384
371
|
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
@@ -388,6 +375,18 @@ export function VerifyAIScanner({
|
|
|
388
375
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
389
376
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
390
377
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
378
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
379
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
380
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
381
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
382
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
383
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
384
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
385
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
386
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
387
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
388
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
389
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
391
390
|
...extra,
|
|
392
391
|
}), [
|
|
393
392
|
cameraKey,
|
|
@@ -397,11 +396,165 @@ export function VerifyAIScanner({
|
|
|
397
396
|
physicalOrientation,
|
|
398
397
|
policy,
|
|
399
398
|
status,
|
|
399
|
+
telemetryContext?.isPortraitLocked,
|
|
400
|
+
telemetryContext?.routeName,
|
|
400
401
|
terminated,
|
|
401
402
|
windowHeight,
|
|
402
403
|
windowWidth,
|
|
403
404
|
]);
|
|
404
405
|
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (Platform.OS !== 'android') return;
|
|
408
|
+
|
|
409
|
+
let subscription: { remove: () => void } | null = null;
|
|
410
|
+
let cancelled = false;
|
|
411
|
+
let noEventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
412
|
+
let lastOrientation: typeof physicalOrientation = 'portrait';
|
|
413
|
+
|
|
414
|
+
const androidMetadata = (
|
|
415
|
+
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
416
|
+
) => compactTelemetryMetadata({
|
|
417
|
+
policy,
|
|
418
|
+
sdk_platform: Platform.OS,
|
|
419
|
+
device_model: getPlatformConstantString('Model', 'model'),
|
|
420
|
+
device_os_version: String(Platform.Version),
|
|
421
|
+
route_name: telemetryContext?.routeName,
|
|
422
|
+
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
423
|
+
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
424
|
+
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
425
|
+
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
426
|
+
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
427
|
+
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
428
|
+
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
429
|
+
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
430
|
+
last_android_orientation_z: lastAndroidOrientationZRef.current,
|
|
431
|
+
last_android_orientation_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
432
|
+
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
433
|
+
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
434
|
+
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
435
|
+
...extra,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const trackAndroidOrientationEvent = (
|
|
439
|
+
eventType: string,
|
|
440
|
+
error: unknown,
|
|
441
|
+
metadata: Record<string, string | number | boolean | null | undefined> = {},
|
|
442
|
+
) => {
|
|
443
|
+
telemetry?.track(eventType, {
|
|
444
|
+
component: 'scanner',
|
|
445
|
+
error,
|
|
446
|
+
metadata: androidMetadata(metadata),
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
(async () => {
|
|
451
|
+
let Accelerometer: {
|
|
452
|
+
setUpdateInterval: (ms: number) => void;
|
|
453
|
+
addListener: (
|
|
454
|
+
cb: (data: { x: number; y: number; z: number }) => void,
|
|
455
|
+
) => { remove: () => void };
|
|
456
|
+
} | null = null;
|
|
457
|
+
try {
|
|
458
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
459
|
+
const mod = require('expo-sensors');
|
|
460
|
+
Accelerometer = mod?.Accelerometer ?? null;
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const error = normalizeScannerError(err);
|
|
463
|
+
lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
|
|
464
|
+
lastAndroidOrientationErrorRef.current = error.message;
|
|
465
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (cancelled) return;
|
|
469
|
+
if (!Accelerometer) {
|
|
470
|
+
trackAndroidOrientationEvent(
|
|
471
|
+
'camera_android_accelerometer_start_failure',
|
|
472
|
+
'expo-sensors Accelerometer is unavailable',
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const startedAt = new Date().toISOString();
|
|
478
|
+
androidOrientationStartedAtRef.current = startedAt;
|
|
479
|
+
androidOrientationSubscriptionActiveRef.current = true;
|
|
480
|
+
androidOrientationEventCountRef.current = 0;
|
|
481
|
+
androidOrientationChangeCountRef.current = 0;
|
|
482
|
+
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
483
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
484
|
+
accelerometer_update_interval_ms: 500,
|
|
485
|
+
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
noEventTimer = setTimeout(() => {
|
|
489
|
+
if (cancelled || androidOrientationEventCountRef.current > 0) return;
|
|
490
|
+
trackAndroidOrientationEvent(
|
|
491
|
+
'camera_android_accelerometer_no_events',
|
|
492
|
+
`No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`,
|
|
493
|
+
);
|
|
494
|
+
}, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
|
|
495
|
+
|
|
496
|
+
Accelerometer.setUpdateInterval(500);
|
|
497
|
+
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
498
|
+
const at = new Date();
|
|
499
|
+
const atIso = at.toISOString();
|
|
500
|
+
androidOrientationEventCountRef.current++;
|
|
501
|
+
lastAndroidOrientationEventAtRef.current = atIso;
|
|
502
|
+
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
503
|
+
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
504
|
+
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
505
|
+
if (noEventTimer) {
|
|
506
|
+
clearTimeout(noEventTimer);
|
|
507
|
+
noEventTimer = null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let next: typeof physicalOrientation | null = null;
|
|
511
|
+
let ignoredReason: string | null = null;
|
|
512
|
+
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
513
|
+
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
514
|
+
} else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
515
|
+
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
516
|
+
} else {
|
|
517
|
+
ignoredReason = 'ambiguous_tilt';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
lastAndroidOrientationDerivedRef.current = next;
|
|
521
|
+
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
522
|
+
|
|
523
|
+
if (next && next !== lastOrientation) {
|
|
524
|
+
const previous = lastOrientation;
|
|
525
|
+
lastOrientation = next;
|
|
526
|
+
androidOrientationChangeCountRef.current++;
|
|
527
|
+
setPhysicalOrientation(next);
|
|
528
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
529
|
+
previous_physical_orientation: previous,
|
|
530
|
+
next_physical_orientation: next,
|
|
531
|
+
accelerometer_sampled_at: atIso,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const shouldTrackSample =
|
|
537
|
+
androidOrientationEventCountRef.current === 1 ||
|
|
538
|
+
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
539
|
+
if (shouldTrackSample) {
|
|
540
|
+
lastAndroidOrientationTelemetryAtRef.current = at.getTime();
|
|
541
|
+
trackAndroidOrientationEvent(
|
|
542
|
+
'camera_android_accelerometer_sample',
|
|
543
|
+
`accelerometer_sample_${androidOrientationEventCountRef.current}`,
|
|
544
|
+
{ accelerometer_sampled_at: atIso },
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
})();
|
|
549
|
+
|
|
550
|
+
return () => {
|
|
551
|
+
cancelled = true;
|
|
552
|
+
androidOrientationSubscriptionActiveRef.current = false;
|
|
553
|
+
if (noEventTimer) clearTimeout(noEventTimer);
|
|
554
|
+
subscription?.remove();
|
|
555
|
+
};
|
|
556
|
+
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
557
|
+
|
|
405
558
|
// Detect orientation changes and remount camera after rotation settles.
|
|
406
559
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
407
560
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -605,8 +758,31 @@ export function VerifyAIScanner({
|
|
|
605
758
|
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
606
759
|
|
|
607
760
|
const handleCapture = useCallback(async () => {
|
|
608
|
-
|
|
609
|
-
|
|
761
|
+
const blockedReason =
|
|
762
|
+
!cameraRef.current
|
|
763
|
+
? 'camera_ref_null'
|
|
764
|
+
: !cameraReadyRef.current
|
|
765
|
+
? 'camera_not_ready'
|
|
766
|
+
: status === 'capturing'
|
|
767
|
+
? 'already_capturing'
|
|
768
|
+
: status === 'processing'
|
|
769
|
+
? 'already_processing'
|
|
770
|
+
: terminated
|
|
771
|
+
? 'terminated'
|
|
772
|
+
: exhausted
|
|
773
|
+
? 'exhausted'
|
|
774
|
+
: null;
|
|
775
|
+
|
|
776
|
+
telemetry?.track('camera_capture_request', {
|
|
777
|
+
component: 'scanner',
|
|
778
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
779
|
+
metadata: buildScannerTelemetryMetadata({
|
|
780
|
+
capture_blocked_reason: blockedReason,
|
|
781
|
+
}),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (blockedReason && blockedReason !== 'camera_not_ready') return;
|
|
785
|
+
if (blockedReason === 'camera_not_ready') {
|
|
610
786
|
const error = createScannerError(
|
|
611
787
|
'Camera is not ready yet. Please wait a moment and try again.',
|
|
612
788
|
CAMERA_NOT_READY_ERROR_CODE,
|
|
@@ -641,6 +817,18 @@ export function VerifyAIScanner({
|
|
|
641
817
|
let lastNativeCaptureErrorMessage: string | null = null;
|
|
642
818
|
|
|
643
819
|
try {
|
|
820
|
+
const capturePhysicalOrientation = physicalOrientation;
|
|
821
|
+
const captureOverlayRotationDeg = overlayRotationDeg;
|
|
822
|
+
telemetry?.track('camera_capture_orientation_context', {
|
|
823
|
+
component: 'scanner',
|
|
824
|
+
error: 'capture_orientation_context',
|
|
825
|
+
metadata: buildScannerTelemetryMetadata({
|
|
826
|
+
capture_physical_orientation: capturePhysicalOrientation,
|
|
827
|
+
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
828
|
+
capture_rotation_applied: 0,
|
|
829
|
+
}),
|
|
830
|
+
});
|
|
831
|
+
|
|
644
832
|
// --- Capture + best-effort resize ---
|
|
645
833
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
646
834
|
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
@@ -704,6 +892,19 @@ export function VerifyAIScanner({
|
|
|
704
892
|
setCameraKey((key) => key + 1);
|
|
705
893
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
706
894
|
captureRetryReady = await waitForCameraReady(2500);
|
|
895
|
+
telemetry?.track('camera_capture_retry', {
|
|
896
|
+
component: 'scanner',
|
|
897
|
+
error: normalized,
|
|
898
|
+
errorCode: normalized.code,
|
|
899
|
+
metadata: buildScannerTelemetryMetadata({
|
|
900
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
901
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
902
|
+
capture_retry_ready: captureRetryReady,
|
|
903
|
+
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
904
|
+
capture_retry_ready_timeout_ms: 2500,
|
|
905
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
906
|
+
}),
|
|
907
|
+
});
|
|
707
908
|
if (captureRetryReady) {
|
|
708
909
|
continue;
|
|
709
910
|
}
|
|
@@ -891,7 +1092,7 @@ export function VerifyAIScanner({
|
|
|
891
1092
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
892
1093
|
}
|
|
893
1094
|
}
|
|
894
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated]);
|
|
1095
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
895
1096
|
|
|
896
1097
|
// Expose capture to parent via ref
|
|
897
1098
|
if (captureRef) {
|
|
@@ -937,6 +1138,7 @@ export function VerifyAIScanner({
|
|
|
937
1138
|
<Text
|
|
938
1139
|
style={[
|
|
939
1140
|
styles.titleText,
|
|
1141
|
+
overlay.theme?.titleStyle,
|
|
940
1142
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
941
1143
|
]}
|
|
942
1144
|
>
|
|
@@ -972,6 +1174,7 @@ export function VerifyAIScanner({
|
|
|
972
1174
|
<Text
|
|
973
1175
|
style={[
|
|
974
1176
|
styles.guideCaptionText,
|
|
1177
|
+
overlay.theme?.feedbackStyle,
|
|
975
1178
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
976
1179
|
]}
|
|
977
1180
|
>
|
|
@@ -985,7 +1188,7 @@ export function VerifyAIScanner({
|
|
|
985
1188
|
{status === 'processing' && (
|
|
986
1189
|
<View style={styles.processingOverlay}>
|
|
987
1190
|
<ActivityIndicator size="large" color="#fff" />
|
|
988
|
-
<Text style={styles.statusText}>
|
|
1191
|
+
<Text style={[styles.statusText, overlay?.theme?.feedbackStyle]}>
|
|
989
1192
|
{overlay?.processingMessage || 'Analyzing photo...'}
|
|
990
1193
|
</Text>
|
|
991
1194
|
</View>
|
|
@@ -1030,7 +1233,7 @@ export function VerifyAIScanner({
|
|
|
1030
1233
|
: (overlay?.failureMessage || 'Not Verified')}
|
|
1031
1234
|
</Text>
|
|
1032
1235
|
</View>
|
|
1033
|
-
<Text style={styles.feedbackText}>{result.feedback}</Text>
|
|
1236
|
+
<Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{result.feedback}</Text>
|
|
1034
1237
|
{overlay?.showTerminalActionButton !== false && onResult && (
|
|
1035
1238
|
<TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
|
|
1036
1239
|
<Text style={styles.cardButtonText}>
|
|
@@ -1077,7 +1280,7 @@ export function VerifyAIScanner({
|
|
|
1077
1280
|
{errorTitle}
|
|
1078
1281
|
</Text>
|
|
1079
1282
|
</View>
|
|
1080
|
-
<Text style={styles.feedbackText}>{errorMessage}</Text>
|
|
1283
|
+
<Text style={[styles.feedbackText, overlay?.theme?.feedbackStyle]}>{errorMessage}</Text>
|
|
1081
1284
|
{showCloseAction && (
|
|
1082
1285
|
<TouchableOpacity style={styles.cardButton} onPress={handleClose}>
|
|
1083
1286
|
<Text style={styles.cardButtonText}>Close</Text>
|
|
@@ -1093,6 +1296,7 @@ export function VerifyAIScanner({
|
|
|
1093
1296
|
<Text
|
|
1094
1297
|
style={[
|
|
1095
1298
|
styles.instructionsText,
|
|
1299
|
+
overlay.theme?.feedbackStyle,
|
|
1096
1300
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
1097
1301
|
]}
|
|
1098
1302
|
>
|
package/src/index.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
+
import type { TextStyle } from 'react-native';
|
|
2
3
|
|
|
3
4
|
export interface VerifyAIConfig {
|
|
4
5
|
apiKey: string;
|
|
@@ -87,6 +88,14 @@ export interface VerifyAIError {
|
|
|
87
88
|
|
|
88
89
|
export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
|
|
89
90
|
|
|
91
|
+
/** Optional host-app context attached to scanner telemetry. */
|
|
92
|
+
export interface ScannerTelemetryContext {
|
|
93
|
+
/** Route or screen name from the host app. */
|
|
94
|
+
routeName?: string;
|
|
95
|
+
/** Whether the host app keeps this screen portrait-locked. */
|
|
96
|
+
isPortraitLocked?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
90
99
|
/**
|
|
91
100
|
* Theme customization for the scanner overlay.
|
|
92
101
|
*
|
|
@@ -103,6 +112,10 @@ export interface ScannerTheme {
|
|
|
103
112
|
captureButtonColor?: string;
|
|
104
113
|
/** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
|
|
105
114
|
cornerColor?: string;
|
|
115
|
+
/** Text style override for the scanner title. */
|
|
116
|
+
titleStyle?: TextStyle;
|
|
117
|
+
/** Text style override for feedback/instruction text. */
|
|
118
|
+
feedbackStyle?: TextStyle;
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
export interface ScannerOverlayConfig {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.16';
|