@switchlabs/verify-ai-react-native 2.4.18 → 2.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/lib/client/index.js +2 -2
- package/lib/components/VerifyAIScanner.js +205 -60
- package/lib/components/scannerOrientation.d.ts +15 -0
- package/lib/components/scannerOrientation.js +42 -0
- package/lib/hooks/useVerifyAI.js +2 -1
- package/lib/ml/modelManager.js +2 -1
- package/lib/telemetry/TelemetryReporter.js +11 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +2 -2
- package/src/components/VerifyAIScanner.tsx +228 -64
- package/src/components/scannerOrientation.ts +66 -0
- package/src/hooks/useVerifyAI.ts +2 -1
- package/src/ml/modelManager.ts +2 -1
- package/src/telemetry/TelemetryReporter.ts +11 -0
- package/src/version.ts +1 -1
package/src/client/index.ts
CHANGED
|
@@ -48,7 +48,7 @@ export class VerifyAIClient {
|
|
|
48
48
|
|
|
49
49
|
constructor(config: VerifyAIConfig) {
|
|
50
50
|
if (!config.apiKey) {
|
|
51
|
-
throw new Error(
|
|
51
|
+
throw new Error(`VerifyAI[v${SDK_VERSION}]: apiKey is required`);
|
|
52
52
|
}
|
|
53
53
|
this.apiKey = config.apiKey;
|
|
54
54
|
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
@@ -147,7 +147,7 @@ export class VerifyAIClient {
|
|
|
147
147
|
|
|
148
148
|
if (!body || typeof body !== 'object') {
|
|
149
149
|
throw this.buildRequestError(
|
|
150
|
-
|
|
150
|
+
`VerifyAI[v${SDK_VERSION}]: Invalid response payload`,
|
|
151
151
|
0,
|
|
152
152
|
context,
|
|
153
153
|
{
|
|
@@ -25,6 +25,12 @@ import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
|
25
25
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
26
26
|
import { BikeOverlay } from './BikeOverlay';
|
|
27
27
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
28
|
+
import {
|
|
29
|
+
ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
30
|
+
classifyAndroidAccelerometerOrientation,
|
|
31
|
+
getOverlayRotationDeg,
|
|
32
|
+
type ScannerPhysicalOrientation,
|
|
33
|
+
} from './scannerOrientation';
|
|
28
34
|
|
|
29
35
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
30
36
|
const FALLBACK_QUALITY = 0.65;
|
|
@@ -34,13 +40,16 @@ const MANIPULATOR_QUALITY = 0.8;
|
|
|
34
40
|
const MAX_DIMENSION = 1600;
|
|
35
41
|
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
36
42
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
43
|
+
const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
37
44
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
38
45
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
39
46
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
40
|
-
const IOS_CAMERA_STARTUP_WATCHDOG_MS =
|
|
47
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
41
48
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
49
|
+
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
42
50
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
43
51
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
52
|
+
const ANDROID_ORIENTATION_SETTLE_MS = 250;
|
|
44
53
|
|
|
45
54
|
export interface VerifyAIScannerProps {
|
|
46
55
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -199,6 +208,8 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
199
208
|
message = 'Network request failed. Check your connection and try again.';
|
|
200
209
|
} else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
201
210
|
message = 'The camera had trouble taking a photo. Please try again.';
|
|
211
|
+
} else if (code === CAMERA_INIT_ERROR_CODE) {
|
|
212
|
+
message = 'The camera could not be started. Close any other app using the camera and try again.';
|
|
202
213
|
} else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
203
214
|
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
204
215
|
} else if (status === 401) {
|
|
@@ -292,11 +303,13 @@ export function VerifyAIScanner({
|
|
|
292
303
|
const attemptCountRef = useRef(0);
|
|
293
304
|
const [exhausted, setExhausted] = useState(false);
|
|
294
305
|
const [terminated, setTerminated] = useState(false);
|
|
306
|
+
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
295
307
|
const [cameraReady, setCameraReady] = useState(false);
|
|
296
308
|
const [cameraKey, setCameraKey] = useState(0);
|
|
297
309
|
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
298
310
|
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
299
311
|
const terminalResultDeliveredRef = useRef(false);
|
|
312
|
+
const appStateRef = useRef(AppState.currentState);
|
|
300
313
|
const cameraReadyRef = useRef(false);
|
|
301
314
|
const cameraEverReadyRef = useRef(false);
|
|
302
315
|
const cameraInitFailedRef = useRef(false);
|
|
@@ -306,6 +319,11 @@ export function VerifyAIScanner({
|
|
|
306
319
|
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
307
320
|
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
308
321
|
const cameraRemountCountRef = useRef(0);
|
|
322
|
+
const startupWatchdogRemountCountRef = useRef(0);
|
|
323
|
+
const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
|
|
324
|
+
const buildScannerTelemetryMetadataRef = useRef<
|
|
325
|
+
((extra?: Record<string, string | number | boolean | null | undefined>) => ScannerTelemetryMetadata) | null
|
|
326
|
+
>(null);
|
|
309
327
|
const androidOrientationSubscriptionActiveRef = useRef(false);
|
|
310
328
|
const androidOrientationEventCountRef = useRef(0);
|
|
311
329
|
const androidOrientationChangeCountRef = useRef(0);
|
|
@@ -317,8 +335,13 @@ export function VerifyAIScanner({
|
|
|
317
335
|
const lastAndroidOrientationZRef = useRef<number | null>(null);
|
|
318
336
|
const lastAndroidOrientationDerivedRef = useRef<string | null>(null);
|
|
319
337
|
const lastAndroidOrientationIgnoredReasonRef = useRef<string | null>(null);
|
|
338
|
+
const lastAndroidOrientationDominantAxisRef = useRef<string | null>(null);
|
|
320
339
|
const lastAndroidOrientationErrorAtRef = useRef<string | null>(null);
|
|
321
340
|
const lastAndroidOrientationErrorRef = useRef<string | null>(null);
|
|
341
|
+
const pendingAndroidPhysicalOrientationRef = useRef<ScannerPhysicalOrientation | null>(null);
|
|
342
|
+
const pendingAndroidPhysicalOrientationStartedAtRef = useRef<string | null>(null);
|
|
343
|
+
const androidOrientationSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
344
|
+
const physicalOrientationRef = useRef<ScannerPhysicalOrientation>('portrait');
|
|
322
345
|
|
|
323
346
|
// Track dimensions for orientation detection and responsive layout
|
|
324
347
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
@@ -330,23 +353,11 @@ export function VerifyAIScanner({
|
|
|
330
353
|
// rotation. iOS uses expo-camera's responsive-orientation callback; Android
|
|
331
354
|
// uses the accelerometer via expo-sensors (the callback is iOS-only). Either
|
|
332
355
|
// way we rotate the overlay UI to stay readable from the user's viewpoint.
|
|
333
|
-
const [physicalOrientation, setPhysicalOrientation] =
|
|
334
|
-
'portrait'
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const overlayRotationDeg = (
|
|
338
|
-
switch (physicalOrientation) {
|
|
339
|
-
case 'landscapeLeft':
|
|
340
|
-
return 90;
|
|
341
|
-
case 'landscapeRight':
|
|
342
|
-
return -90;
|
|
343
|
-
case 'portraitUpsideDown':
|
|
344
|
-
return 180;
|
|
345
|
-
case 'portrait':
|
|
346
|
-
default:
|
|
347
|
-
return 0;
|
|
348
|
-
}
|
|
349
|
-
})();
|
|
356
|
+
const [physicalOrientation, setPhysicalOrientation] =
|
|
357
|
+
useState<ScannerPhysicalOrientation>('portrait');
|
|
358
|
+
|
|
359
|
+
physicalOrientationRef.current = physicalOrientation;
|
|
360
|
+
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
350
361
|
|
|
351
362
|
const buildScannerTelemetryMetadata = useCallback((
|
|
352
363
|
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
@@ -360,7 +371,7 @@ export function VerifyAIScanner({
|
|
|
360
371
|
camera_remount_count: cameraRemountCountRef.current,
|
|
361
372
|
terminated,
|
|
362
373
|
exhausted,
|
|
363
|
-
app_state:
|
|
374
|
+
app_state: appStateRef.current,
|
|
364
375
|
sdk_platform: Platform.OS,
|
|
365
376
|
device_model: getPlatformConstantString('Model', 'model'),
|
|
366
377
|
device_os_version: String(Platform.Version),
|
|
@@ -375,10 +386,22 @@ export function VerifyAIScanner({
|
|
|
375
386
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
376
387
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
377
388
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
389
|
+
android_native_orientation_subscription_active: 0,
|
|
390
|
+
android_native_orientation_event_count: 0,
|
|
391
|
+
android_native_orientation_change_count: 0,
|
|
392
|
+
android_native_orientation_source: 'expo_camera_internal_capture_orientation',
|
|
378
393
|
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
379
394
|
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
380
395
|
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
381
396
|
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
397
|
+
android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
398
|
+
android_accelerometer_started_at: androidOrientationStartedAtRef.current,
|
|
399
|
+
android_accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
400
|
+
android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
401
|
+
android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
402
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
403
|
+
pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
|
|
404
|
+
pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
|
|
382
405
|
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
383
406
|
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
384
407
|
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
@@ -387,6 +410,15 @@ export function VerifyAIScanner({
|
|
|
387
410
|
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
388
411
|
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
389
412
|
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
413
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
414
|
+
last_accelerometer_x: lastAndroidOrientationXRef.current,
|
|
415
|
+
last_accelerometer_y: lastAndroidOrientationYRef.current,
|
|
416
|
+
last_accelerometer_z: lastAndroidOrientationZRef.current,
|
|
417
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
418
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
419
|
+
last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
|
|
420
|
+
last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
421
|
+
last_accelerometer_error: lastAndroidOrientationErrorRef.current,
|
|
390
422
|
...extra,
|
|
391
423
|
}), [
|
|
392
424
|
cameraKey,
|
|
@@ -403,13 +435,36 @@ export function VerifyAIScanner({
|
|
|
403
435
|
windowWidth,
|
|
404
436
|
]);
|
|
405
437
|
|
|
438
|
+
telemetryRef.current = telemetry;
|
|
439
|
+
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
440
|
+
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
telemetryRef.current?.track('camera_scanner_mounted', {
|
|
443
|
+
component: 'scanner',
|
|
444
|
+
error: 'scanner_mounted',
|
|
445
|
+
metadata: buildScannerTelemetryMetadataRef.current?.({
|
|
446
|
+
scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
|
|
447
|
+
}),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return () => {
|
|
451
|
+
const activeTelemetry = telemetryRef.current;
|
|
452
|
+
activeTelemetry?.track('camera_scanner_disposed', {
|
|
453
|
+
component: 'scanner',
|
|
454
|
+
error: 'scanner_disposed',
|
|
455
|
+
metadata: buildScannerTelemetryMetadataRef.current?.(),
|
|
456
|
+
});
|
|
457
|
+
void activeTelemetry?.flush();
|
|
458
|
+
};
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
406
461
|
useEffect(() => {
|
|
407
462
|
if (Platform.OS !== 'android') return;
|
|
408
463
|
|
|
409
464
|
let subscription: { remove: () => void } | null = null;
|
|
410
465
|
let cancelled = false;
|
|
411
466
|
let noEventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
412
|
-
let lastOrientation:
|
|
467
|
+
let lastOrientation: ScannerPhysicalOrientation = physicalOrientationRef.current;
|
|
413
468
|
|
|
414
469
|
const androidMetadata = (
|
|
415
470
|
extra: Record<string, string | number | boolean | null | undefined> = {},
|
|
@@ -420,10 +475,22 @@ export function VerifyAIScanner({
|
|
|
420
475
|
device_os_version: String(Platform.Version),
|
|
421
476
|
route_name: telemetryContext?.routeName,
|
|
422
477
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
478
|
+
android_native_orientation_subscription_active: 0,
|
|
479
|
+
android_native_orientation_event_count: 0,
|
|
480
|
+
android_native_orientation_change_count: 0,
|
|
481
|
+
android_native_orientation_source: 'expo_camera_internal_capture_orientation',
|
|
423
482
|
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
424
483
|
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
425
484
|
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
426
485
|
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
486
|
+
android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
487
|
+
android_accelerometer_started_at: androidOrientationStartedAtRef.current,
|
|
488
|
+
android_accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
489
|
+
android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
490
|
+
android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
491
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
492
|
+
pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
|
|
493
|
+
pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
|
|
427
494
|
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
428
495
|
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
429
496
|
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
@@ -432,6 +499,15 @@ export function VerifyAIScanner({
|
|
|
432
499
|
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
433
500
|
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
434
501
|
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
502
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
503
|
+
last_accelerometer_x: lastAndroidOrientationXRef.current,
|
|
504
|
+
last_accelerometer_y: lastAndroidOrientationYRef.current,
|
|
505
|
+
last_accelerometer_z: lastAndroidOrientationZRef.current,
|
|
506
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
507
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
508
|
+
last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
|
|
509
|
+
last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
510
|
+
last_accelerometer_error: lastAndroidOrientationErrorRef.current,
|
|
435
511
|
...extra,
|
|
436
512
|
});
|
|
437
513
|
|
|
@@ -489,8 +565,10 @@ export function VerifyAIScanner({
|
|
|
489
565
|
androidOrientationChangeCountRef.current = 0;
|
|
490
566
|
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
491
567
|
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
568
|
+
accelerometer_start_reason: 'android_overlay_orientation_primary',
|
|
492
569
|
accelerometer_update_interval_ms: 500,
|
|
493
570
|
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
571
|
+
accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
494
572
|
});
|
|
495
573
|
|
|
496
574
|
noEventTimer = setTimeout(() => {
|
|
@@ -505,42 +583,68 @@ export function VerifyAIScanner({
|
|
|
505
583
|
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
506
584
|
const at = new Date();
|
|
507
585
|
const atIso = at.toISOString();
|
|
586
|
+
const sample = classifyAndroidAccelerometerOrientation({ x, y, z });
|
|
508
587
|
androidOrientationEventCountRef.current++;
|
|
509
588
|
lastAndroidOrientationEventAtRef.current = atIso;
|
|
510
|
-
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
511
|
-
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
512
|
-
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
589
|
+
lastAndroidOrientationXRef.current = Number(sample.x.toFixed(4));
|
|
590
|
+
lastAndroidOrientationYRef.current = Number(sample.y.toFixed(4));
|
|
591
|
+
lastAndroidOrientationZRef.current = Number(sample.z.toFixed(4));
|
|
592
|
+
lastAndroidOrientationDerivedRef.current = sample.orientation;
|
|
593
|
+
lastAndroidOrientationIgnoredReasonRef.current = sample.ignoredReason;
|
|
594
|
+
lastAndroidOrientationDominantAxisRef.current = sample.dominantAxis;
|
|
513
595
|
if (noEventTimer) {
|
|
514
596
|
clearTimeout(noEventTimer);
|
|
515
597
|
noEventTimer = null;
|
|
516
598
|
}
|
|
517
599
|
|
|
518
|
-
|
|
519
|
-
let ignoredReason: string | null = null;
|
|
520
|
-
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
521
|
-
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
522
|
-
} else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
523
|
-
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
524
|
-
} else {
|
|
525
|
-
ignoredReason = 'ambiguous_tilt';
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
lastAndroidOrientationDerivedRef.current = next;
|
|
529
|
-
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
530
|
-
|
|
600
|
+
const next = sample.orientation;
|
|
531
601
|
if (next && next !== lastOrientation) {
|
|
532
602
|
const previous = lastOrientation;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
603
|
+
if (
|
|
604
|
+
pendingAndroidPhysicalOrientationRef.current === next &&
|
|
605
|
+
androidOrientationSettleTimerRef.current
|
|
606
|
+
) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pendingAndroidPhysicalOrientationRef.current = next;
|
|
611
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = atIso;
|
|
612
|
+
if (androidOrientationSettleTimerRef.current) {
|
|
613
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
androidOrientationSettleTimerRef.current = setTimeout(() => {
|
|
617
|
+
if (cancelled || pendingAndroidPhysicalOrientationRef.current !== next) return;
|
|
618
|
+
const pendingStartedAt = pendingAndroidPhysicalOrientationStartedAtRef.current;
|
|
619
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
620
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
621
|
+
androidOrientationSettleTimerRef.current = null;
|
|
622
|
+
if (next === lastOrientation) return;
|
|
623
|
+
|
|
624
|
+
lastOrientation = next;
|
|
625
|
+
androidOrientationChangeCountRef.current++;
|
|
626
|
+
setPhysicalOrientation(next);
|
|
627
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
628
|
+
android_physical_orientation_source: 'accelerometer_fallback',
|
|
629
|
+
previous_physical_orientation: previous,
|
|
630
|
+
next_physical_orientation: next,
|
|
631
|
+
accelerometer_sampled_at: atIso,
|
|
632
|
+
accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
|
|
633
|
+
accelerometer_dominant_axis: sample.dominantAxis,
|
|
634
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
635
|
+
android_physical_orientation_pending_started_at: pendingStartedAt,
|
|
636
|
+
});
|
|
637
|
+
}, ANDROID_ORIENTATION_SETTLE_MS);
|
|
541
638
|
return;
|
|
542
639
|
}
|
|
543
640
|
|
|
641
|
+
if (!next && androidOrientationSettleTimerRef.current) {
|
|
642
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
643
|
+
androidOrientationSettleTimerRef.current = null;
|
|
644
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
645
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
646
|
+
}
|
|
647
|
+
|
|
544
648
|
const shouldTrackSample =
|
|
545
649
|
androidOrientationEventCountRef.current === 1 ||
|
|
546
650
|
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
@@ -549,7 +653,11 @@ export function VerifyAIScanner({
|
|
|
549
653
|
trackAndroidOrientationEvent(
|
|
550
654
|
'camera_android_accelerometer_sample',
|
|
551
655
|
`accelerometer_sample_${androidOrientationEventCountRef.current}`,
|
|
552
|
-
{
|
|
656
|
+
{
|
|
657
|
+
accelerometer_sampled_at: atIso,
|
|
658
|
+
accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
|
|
659
|
+
accelerometer_dominant_axis: sample.dominantAxis,
|
|
660
|
+
},
|
|
553
661
|
);
|
|
554
662
|
}
|
|
555
663
|
});
|
|
@@ -557,9 +665,23 @@ export function VerifyAIScanner({
|
|
|
557
665
|
|
|
558
666
|
return () => {
|
|
559
667
|
cancelled = true;
|
|
668
|
+
const wasActive = subscription != null || androidOrientationSubscriptionActiveRef.current;
|
|
669
|
+
if (androidOrientationSettleTimerRef.current) {
|
|
670
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
671
|
+
androidOrientationSettleTimerRef.current = null;
|
|
672
|
+
}
|
|
673
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
674
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
560
675
|
androidOrientationSubscriptionActiveRef.current = false;
|
|
561
676
|
if (noEventTimer) clearTimeout(noEventTimer);
|
|
562
677
|
subscription?.remove();
|
|
678
|
+
if (wasActive) {
|
|
679
|
+
trackAndroidOrientationEvent(
|
|
680
|
+
'camera_android_accelerometer_stopped',
|
|
681
|
+
'android_accelerometer_tracking_stopped',
|
|
682
|
+
{ accelerometer_stop_reason: 'orientation_tracking_stopped' },
|
|
683
|
+
);
|
|
684
|
+
}
|
|
563
685
|
};
|
|
564
686
|
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
565
687
|
|
|
@@ -605,23 +727,33 @@ export function VerifyAIScanner({
|
|
|
605
727
|
if (terminated) return;
|
|
606
728
|
|
|
607
729
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
// its preview layer after returning from the notification bar or control center.
|
|
614
|
-
telemetry?.track('camera_appstate_remount', {
|
|
615
|
-
component: 'scanner',
|
|
616
|
-
metadata: buildScannerTelemetryMetadata({
|
|
617
|
-
remount_reason: 'appstate_active',
|
|
618
|
-
remount_requested_at: at,
|
|
619
|
-
}),
|
|
620
|
-
});
|
|
730
|
+
const previousState = appStateRef.current;
|
|
731
|
+
appStateRef.current = nextState;
|
|
732
|
+
setCurrentAppState(nextState);
|
|
733
|
+
|
|
734
|
+
if (nextState !== 'active') {
|
|
621
735
|
setCameraReady(false);
|
|
622
736
|
cameraReadyRef.current = false;
|
|
623
|
-
|
|
737
|
+
return;
|
|
624
738
|
}
|
|
739
|
+
|
|
740
|
+
if (previousState === 'active') return;
|
|
741
|
+
|
|
742
|
+
const at = new Date().toISOString();
|
|
743
|
+
lastAppStateRemountAtRef.current = at;
|
|
744
|
+
cameraRemountCountRef.current++;
|
|
745
|
+
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
746
|
+
// its preview layer after returning from the notification bar or control center.
|
|
747
|
+
telemetry?.track('camera_appstate_remount', {
|
|
748
|
+
component: 'scanner',
|
|
749
|
+
metadata: buildScannerTelemetryMetadata({
|
|
750
|
+
remount_reason: 'appstate_active',
|
|
751
|
+
remount_requested_at: at,
|
|
752
|
+
}),
|
|
753
|
+
});
|
|
754
|
+
setCameraReady(false);
|
|
755
|
+
cameraReadyRef.current = false;
|
|
756
|
+
setCameraKey((k) => k + 1);
|
|
625
757
|
});
|
|
626
758
|
|
|
627
759
|
return () => subscription.remove();
|
|
@@ -691,10 +823,12 @@ export function VerifyAIScanner({
|
|
|
691
823
|
cameraReadyRef.current = true;
|
|
692
824
|
cameraEverReadyRef.current = true;
|
|
693
825
|
cameraInitFailedRef.current = false;
|
|
826
|
+
startupWatchdogRemountCountRef.current = 0;
|
|
694
827
|
}, []);
|
|
695
828
|
|
|
696
829
|
const onMountError = useCallback((event: { message?: string }) => {
|
|
697
830
|
const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
|
|
831
|
+
error.code = CAMERA_INIT_ERROR_CODE;
|
|
698
832
|
setResult(null);
|
|
699
833
|
setLastError(error);
|
|
700
834
|
setStatus('error');
|
|
@@ -707,6 +841,8 @@ export function VerifyAIScanner({
|
|
|
707
841
|
error,
|
|
708
842
|
metadata: buildScannerTelemetryMetadata({
|
|
709
843
|
mount_error_message: event.message,
|
|
844
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
845
|
+
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
710
846
|
}),
|
|
711
847
|
});
|
|
712
848
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
@@ -716,20 +852,31 @@ export function VerifyAIScanner({
|
|
|
716
852
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
717
853
|
// recreate the CameraView, which starts a fresh native session.
|
|
718
854
|
useEffect(() => {
|
|
719
|
-
if (!permission?.granted || terminated) return;
|
|
855
|
+
if (!permission?.granted || terminated || currentAppState !== 'active') return;
|
|
720
856
|
|
|
721
857
|
const watchdogMs = Platform.OS === 'android'
|
|
722
858
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
723
859
|
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
724
860
|
|
|
725
861
|
const timer = setTimeout(() => {
|
|
862
|
+
if (appStateRef.current !== 'active') return;
|
|
863
|
+
|
|
726
864
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
727
865
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
728
|
-
// triggered remount can legitimately take
|
|
866
|
+
// triggered remount can legitimately take several seconds without indicating a
|
|
729
867
|
// real failure, and firing the alert each time is noisy without
|
|
730
|
-
// surfacing new information.
|
|
731
|
-
//
|
|
868
|
+
// surfacing new information. First startup still gets a capped retry loop
|
|
869
|
+
// so a persistent native camera failure becomes visible to the user.
|
|
732
870
|
if (!cameraEverReadyRef.current) {
|
|
871
|
+
if (startupWatchdogRemountCountRef.current >= CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS) {
|
|
872
|
+
onMountError({
|
|
873
|
+
message: `Camera did not initialize after ${CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS} startup remount attempts`,
|
|
874
|
+
});
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
|
|
879
|
+
startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
|
|
733
880
|
cameraRemountCountRef.current++;
|
|
734
881
|
telemetry?.track('camera_preview_timeout', {
|
|
735
882
|
component: 'scanner',
|
|
@@ -737,6 +884,8 @@ export function VerifyAIScanner({
|
|
|
737
884
|
metadata: buildScannerTelemetryMetadata({
|
|
738
885
|
watchdog_ms: watchdogMs,
|
|
739
886
|
remount_reason: 'startup_watchdog',
|
|
887
|
+
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
888
|
+
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
740
889
|
}),
|
|
741
890
|
});
|
|
742
891
|
setCameraReady(false);
|
|
@@ -747,7 +896,15 @@ export function VerifyAIScanner({
|
|
|
747
896
|
}, watchdogMs);
|
|
748
897
|
|
|
749
898
|
return () => clearTimeout(timer);
|
|
750
|
-
}, [
|
|
899
|
+
}, [
|
|
900
|
+
permission?.granted,
|
|
901
|
+
terminated,
|
|
902
|
+
currentAppState,
|
|
903
|
+
cameraKey,
|
|
904
|
+
buildScannerTelemetryMetadata,
|
|
905
|
+
onMountError,
|
|
906
|
+
telemetry,
|
|
907
|
+
]);
|
|
751
908
|
|
|
752
909
|
// Track permission denied
|
|
753
910
|
useEffect(() => {
|
|
@@ -834,6 +991,13 @@ export function VerifyAIScanner({
|
|
|
834
991
|
capture_physical_orientation: capturePhysicalOrientation,
|
|
835
992
|
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
836
993
|
capture_rotation_applied: 0,
|
|
994
|
+
capture_rotation_source: Platform.OS === 'android'
|
|
995
|
+
? 'expo_camera_native_orientation_event_listener'
|
|
996
|
+
: 'expo_camera_responsive_orientation',
|
|
997
|
+
accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
998
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
999
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
1000
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
837
1001
|
}),
|
|
838
1002
|
});
|
|
839
1003
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type ScannerPhysicalOrientation =
|
|
2
|
+
| 'portrait'
|
|
3
|
+
| 'portraitUpsideDown'
|
|
4
|
+
| 'landscapeLeft'
|
|
5
|
+
| 'landscapeRight';
|
|
6
|
+
|
|
7
|
+
export type AndroidAccelerometerSample = {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
z: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AndroidAccelerometerOrientationSample = AndroidAccelerometerSample & {
|
|
14
|
+
orientation: ScannerPhysicalOrientation | null;
|
|
15
|
+
ignoredReason: string | null;
|
|
16
|
+
dominantAxis: 'x' | 'y' | 'ambiguous';
|
|
17
|
+
xyDominance: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
|
|
21
|
+
|
|
22
|
+
export function classifyAndroidAccelerometerOrientation(
|
|
23
|
+
sample: AndroidAccelerometerSample,
|
|
24
|
+
axisDominanceThreshold = ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
25
|
+
): AndroidAccelerometerOrientationSample {
|
|
26
|
+
const { x, y, z } = sample;
|
|
27
|
+
const xAbs = Math.abs(x);
|
|
28
|
+
const yAbs = Math.abs(y);
|
|
29
|
+
let orientation: ScannerPhysicalOrientation | null = null;
|
|
30
|
+
let ignoredReason: string | null = null;
|
|
31
|
+
let dominantAxis: AndroidAccelerometerOrientationSample['dominantAxis'] = 'ambiguous';
|
|
32
|
+
|
|
33
|
+
if (xAbs > yAbs + axisDominanceThreshold) {
|
|
34
|
+
dominantAxis = 'x';
|
|
35
|
+
orientation = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
36
|
+
} else if (yAbs > xAbs + axisDominanceThreshold) {
|
|
37
|
+
dominantAxis = 'y';
|
|
38
|
+
orientation = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
39
|
+
} else {
|
|
40
|
+
ignoredReason = 'ambiguous_xy';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
x,
|
|
45
|
+
y,
|
|
46
|
+
z,
|
|
47
|
+
orientation,
|
|
48
|
+
ignoredReason,
|
|
49
|
+
dominantAxis,
|
|
50
|
+
xyDominance: Math.abs(xAbs - yAbs),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getOverlayRotationDeg(orientation: ScannerPhysicalOrientation): number {
|
|
55
|
+
switch (orientation) {
|
|
56
|
+
case 'landscapeLeft':
|
|
57
|
+
return 90;
|
|
58
|
+
case 'landscapeRight':
|
|
59
|
+
return -90;
|
|
60
|
+
case 'portraitUpsideDown':
|
|
61
|
+
return 180;
|
|
62
|
+
case 'portrait':
|
|
63
|
+
default:
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/hooks/useVerifyAI.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { VerifyAIClient, VerifyAIRequestError } from '../client';
|
|
|
4
4
|
import { OfflineQueue } from '../storage/offlineQueue';
|
|
5
5
|
import { TelemetryContext } from '../telemetry/TelemetryContext';
|
|
6
6
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
7
|
+
import { SDK_VERSION } from '../version';
|
|
7
8
|
import type {
|
|
8
9
|
VerifyAIConfig,
|
|
9
10
|
VerificationRequest,
|
|
@@ -161,7 +162,7 @@ export function useVerifyAI(config: UseVerifyAIConfig): UseVerifyAIReturn {
|
|
|
161
162
|
});
|
|
162
163
|
inferenceEngineRef.current = new InferenceEngine();
|
|
163
164
|
} catch (err) {
|
|
164
|
-
console.warn(
|
|
165
|
+
console.warn(`[VerifyAI v${SDK_VERSION}] Failed to load ML modules:`, err);
|
|
165
166
|
}
|
|
166
167
|
})();
|
|
167
168
|
|
package/src/ml/modelManager.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Buffer } from 'buffer';
|
|
7
7
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
8
8
|
import type { BundleManifest, ModelArtifact } from './types';
|
|
9
|
+
import { SDK_VERSION } from '../version';
|
|
9
10
|
|
|
10
11
|
export type { BundleManifest, ModelArtifact };
|
|
11
12
|
|
|
@@ -113,7 +114,7 @@ export class ModelManager {
|
|
|
113
114
|
private async downloadArtifacts(manifest: BundleManifest, policyId: string): Promise<void> {
|
|
114
115
|
const fileSystem = await loadFileSystem();
|
|
115
116
|
if (!fileSystem || !fileSystem.documentDirectory) {
|
|
116
|
-
console.warn(
|
|
117
|
+
console.warn(`[VerifyAI v${SDK_VERSION}] expo-file-system not available, skipping download`);
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -39,6 +39,17 @@ const CRITICAL_EVENTS = new Set([
|
|
|
39
39
|
'camera_init_failure',
|
|
40
40
|
'camera_preview_timeout',
|
|
41
41
|
'camera_permission_denied',
|
|
42
|
+
'camera_scanner_mounted',
|
|
43
|
+
'camera_scanner_disposed',
|
|
44
|
+
'camera_android_native_orientation_started',
|
|
45
|
+
'camera_android_native_orientation_no_events',
|
|
46
|
+
'camera_android_native_orientation_error',
|
|
47
|
+
'camera_android_native_orientation_start_failure',
|
|
48
|
+
'camera_android_native_orientation_done',
|
|
49
|
+
'camera_android_accelerometer_started',
|
|
50
|
+
'camera_android_accelerometer_no_events',
|
|
51
|
+
'camera_android_accelerometer_error',
|
|
52
|
+
'camera_android_accelerometer_start_failure',
|
|
42
53
|
]);
|
|
43
54
|
|
|
44
55
|
export class TelemetryReporter {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.21';
|