@switchlabs/verify-ai-react-native 2.4.23 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,12 @@ 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 IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
24
- const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
23
+ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
24
+ const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
25
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
26
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
27
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
28
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
25
29
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
26
30
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
27
31
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -205,6 +209,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
205
209
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
206
210
  const [cameraReady, setCameraReady] = useState(false);
207
211
  const [cameraKey, setCameraKey] = useState(0);
212
+ const [cameraMounted, setCameraMounted] = useState(true);
208
213
  const terminalResultRef = useRef(null);
209
214
  const terminalResultTimerRef = useRef(null);
210
215
  const terminalResultDeliveredRef = useRef(false);
@@ -217,6 +222,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
217
222
  const lastAppStateRemountAtRef = useRef(null);
218
223
  const lastOrientationRemountAtRef = useRef(null);
219
224
  const lastCaptureRetryAtRef = useRef(null);
225
+ const cameraStartupStartedAtRef = useRef(Date.now());
226
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
227
+ const cameraStartupSlowTrackedRef = useRef(false);
228
+ const cameraStartupReadyTrackedRef = useRef(false);
229
+ const startupRemountTimerRef = useRef(null);
220
230
  const cameraRemountCountRef = useRef(0);
221
231
  const startupWatchdogRemountCountRef = useRef(0);
222
232
  const telemetryRef = useRef(telemetry);
@@ -258,7 +268,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
258
268
  camera_ever_ready: cameraEverReadyRef.current,
259
269
  camera_init_failed: cameraInitFailedRef.current,
260
270
  camera_key: cameraKey,
271
+ camera_mounted: cameraMounted ? 1 : 0,
261
272
  camera_remount_count: cameraRemountCountRef.current,
273
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
274
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
262
275
  terminated,
263
276
  exhausted,
264
277
  app_state: appStateRef.current,
@@ -276,6 +289,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
276
289
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
277
290
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
278
291
  last_capture_retry_at: lastCaptureRetryAtRef.current,
292
+ requested_torch_enabled: enableTorch ? 1 : 0,
279
293
  android_native_orientation_subscription_active: 0,
280
294
  android_native_orientation_event_count: 0,
281
295
  android_native_orientation_change_count: 0,
@@ -312,7 +326,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
312
326
  ...extra,
313
327
  }), [
314
328
  cameraKey,
329
+ cameraMounted,
315
330
  exhausted,
331
+ enableTorch,
316
332
  isLandscape,
317
333
  overlayRotationDeg,
318
334
  physicalOrientation,
@@ -356,6 +372,37 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
356
372
  void activeTelemetry?.flush();
357
373
  };
358
374
  }, []);
375
+ const requestCameraRemount = useCallback((reason, opts = {}) => {
376
+ if (startupRemountTimerRef.current) {
377
+ clearTimeout(startupRemountTimerRef.current);
378
+ startupRemountTimerRef.current = null;
379
+ }
380
+ setCameraReady(false);
381
+ cameraReadyRef.current = false;
382
+ cameraStartupAttemptStartedAtRef.current = Date.now();
383
+ if (opts.log) {
384
+ console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
385
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
386
+ }
387
+ const remount = () => {
388
+ setCameraKey((k) => k + 1);
389
+ setCameraMounted(true);
390
+ startupRemountTimerRef.current = null;
391
+ };
392
+ if ((opts.backoffMs ?? 0) > 0) {
393
+ setCameraMounted(false);
394
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
395
+ return;
396
+ }
397
+ setCameraMounted(true);
398
+ remount();
399
+ }, [enableTorch]);
400
+ useEffect(() => () => {
401
+ if (startupRemountTimerRef.current) {
402
+ clearTimeout(startupRemountTimerRef.current);
403
+ startupRemountTimerRef.current = null;
404
+ }
405
+ }, []);
359
406
  useEffect(() => {
360
407
  if (Platform.OS !== 'android')
361
408
  return;
@@ -572,16 +619,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
572
619
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
573
620
  },
574
621
  });
575
- setCameraReady(false);
576
- cameraReadyRef.current = false;
577
- // Delay remount so iOS rotation animation completes before the new
578
- // AVCaptureVideoPreviewLayer initializes with the final frame bounds.
579
- const timer = setTimeout(() => {
580
- setCameraKey((k) => k + 1);
581
- }, 400);
582
- return () => clearTimeout(timer);
622
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
583
623
  }
584
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
624
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
585
625
  // Resume camera when app returns from background/inactive (e.g. notification bar)
586
626
  useEffect(() => {
587
627
  if (terminated)
@@ -609,12 +649,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
609
649
  remount_requested_at: at,
610
650
  }),
611
651
  });
612
- setCameraReady(false);
613
- cameraReadyRef.current = false;
614
- setCameraKey((k) => k + 1);
652
+ requestCameraRemount('appstate_active');
615
653
  });
616
654
  return () => subscription.remove();
617
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
655
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
618
656
  const pausePreview = useCallback(() => {
619
657
  cameraRef.current?.pausePreview?.().catch(() => { });
620
658
  }, []);
@@ -668,13 +706,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
668
706
  }, [onClose, result]);
669
707
  // Camera init callbacks
670
708
  const onCameraReady = useCallback(() => {
671
- lastCameraReadyAtRef.current = new Date().toISOString();
709
+ const readyAt = new Date().toISOString();
710
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
711
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
712
+ lastCameraReadyAtRef.current = readyAt;
672
713
  setCameraReady(true);
673
714
  cameraReadyRef.current = true;
674
715
  cameraEverReadyRef.current = true;
675
716
  cameraInitFailedRef.current = false;
676
717
  startupWatchdogRemountCountRef.current = 0;
677
- }, []);
718
+ if (!cameraStartupReadyTrackedRef.current) {
719
+ cameraStartupReadyTrackedRef.current = true;
720
+ console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
721
+ `startupTotalMs=${startupTotalElapsedMs} ` +
722
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
723
+ `torchRequested=${enableTorch ? 1 : 0}`);
724
+ telemetry?.track('camera_ready', {
725
+ component: 'scanner',
726
+ error: 'camera_ready',
727
+ metadata: buildScannerTelemetryMetadata({
728
+ camera_ready_at: readyAt,
729
+ startup_total_elapsed_ms: startupTotalElapsedMs,
730
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
731
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
732
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
733
+ }),
734
+ });
735
+ }
736
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
678
737
  const onMountError = useCallback((event) => {
679
738
  const error = new Error(event.message || 'Camera mount error');
680
739
  error.code = CAMERA_INIT_ERROR_CODE;
@@ -695,12 +754,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
695
754
  }),
696
755
  });
697
756
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
757
+ useEffect(() => {
758
+ if (!permission?.granted ||
759
+ terminated ||
760
+ currentAppState !== 'active' ||
761
+ !cameraMounted ||
762
+ cameraReadyRef.current ||
763
+ cameraInitFailedRef.current ||
764
+ cameraStartupSlowTrackedRef.current) {
765
+ return;
766
+ }
767
+ const timer = setTimeout(() => {
768
+ if (appStateRef.current !== 'active' ||
769
+ cameraReadyRef.current ||
770
+ cameraInitFailedRef.current ||
771
+ cameraStartupSlowTrackedRef.current) {
772
+ return;
773
+ }
774
+ cameraStartupSlowTrackedRef.current = true;
775
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
776
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
777
+ console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
778
+ `startupTotalMs=${startupTotalElapsedMs} ` +
779
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
780
+ `torchRequested=${enableTorch ? 1 : 0}`);
781
+ telemetry?.track('camera_startup_slow', {
782
+ component: 'scanner',
783
+ error: 'camera_startup_slow',
784
+ metadata: buildScannerTelemetryMetadata({
785
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
786
+ startup_total_elapsed_ms: startupTotalElapsedMs,
787
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
788
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
789
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
790
+ }),
791
+ });
792
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
793
+ return () => clearTimeout(timer);
794
+ }, [
795
+ permission?.granted,
796
+ terminated,
797
+ currentAppState,
798
+ cameraMounted,
799
+ buildScannerTelemetryMetadata,
800
+ enableTorch,
801
+ telemetry,
802
+ ]);
698
803
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
699
804
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
700
805
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
701
806
  // recreate the CameraView, which starts a fresh native session.
702
807
  useEffect(() => {
703
- if (!permission?.granted || terminated || currentAppState !== 'active')
808
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
704
809
  return;
705
810
  const watchdogMs = Platform.OS === 'android'
706
811
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -732,11 +837,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
732
837
  remount_reason: 'startup_watchdog',
733
838
  startup_watchdog_remount_count: startupWatchdogRemountCount,
734
839
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
840
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
841
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
735
842
  }),
736
843
  });
737
- setCameraReady(false);
738
- cameraReadyRef.current = false;
739
- setCameraKey((k) => k + 1);
844
+ requestCameraRemount('startup_watchdog', {
845
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
846
+ log: true,
847
+ });
740
848
  }
741
849
  }
742
850
  }, watchdogMs);
@@ -745,9 +853,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
745
853
  permission?.granted,
746
854
  terminated,
747
855
  currentAppState,
856
+ cameraMounted,
748
857
  cameraKey,
749
858
  buildScannerTelemetryMetadata,
859
+ enableTorch,
750
860
  onMountError,
861
+ requestCameraRemount,
751
862
  telemetry,
752
863
  ]);
753
864
  // Track permission denied
@@ -880,11 +991,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
880
991
  const retryAt = new Date().toISOString();
881
992
  lastCaptureRetryAtRef.current = retryAt;
882
993
  cameraRemountCountRef.current++;
883
- setCameraReady(false);
884
- cameraReadyRef.current = false;
885
- setCameraKey((key) => key + 1);
994
+ requestCameraRemount('capture_retry');
886
995
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
887
- captureRetryReady = await waitForCameraReady(2500);
996
+ const captureRetryReadyTimeoutMs = Platform.OS === 'android'
997
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
998
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
999
+ captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
888
1000
  telemetry?.track('camera_capture_retry', {
889
1001
  component: 'scanner',
890
1002
  error: normalized,
@@ -894,7 +1006,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
894
1006
  capture_retry_attempted: captureRetryAttempted,
895
1007
  capture_retry_ready: captureRetryReady,
896
1008
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
897
- capture_retry_ready_timeout_ms: 2500,
1009
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
898
1010
  last_native_capture_error: lastNativeCaptureErrorMessage,
899
1011
  }),
900
1012
  });
@@ -1051,7 +1163,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1051
1163
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1052
1164
  }
1053
1165
  }
1054
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1166
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1055
1167
  // Expose capture to parent via ref
1056
1168
  if (captureRef) {
1057
1169
  captureRef.current = handleCapture;
@@ -1063,7 +1175,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1063
1175
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
1064
1176
  }
1065
1177
  const showBottomCard = status === 'success' || status === 'error';
1066
- return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: !terminated && enableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1178
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1179
+ return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
1067
1180
  setPhysicalOrientation(event.orientation);
1068
1181
  }, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
1069
1182
  styles.titleText,
@@ -1134,7 +1247,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
1134
1247
  overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
1135
1248
  (!cameraReady || status === 'capturing' || status === 'processing') &&
1136
1249
  styles.captureButtonDisabled,
1137
- ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey) }));
1250
+ ], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey)) }));
1138
1251
  }
1139
1252
  const CORNER_SIZE = 30;
1140
1253
  const CORNER_THICKNESS = 3;
@@ -6,7 +6,7 @@ import type { FeatureVector } from './types';
6
6
  /** Ontology class name mapping (must match ontology.ts) */
7
7
  export declare const ONTOLOGY_CLASS_NAMES: Record<number, string>;
8
8
  /** Detection model class index -> ontology class ID mapping. */
9
- export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 20, 21, 22, 23, 24, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
9
+ export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 6, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
10
10
  interface RawDetection {
11
11
  classId: number;
12
12
  confidence: number;
@@ -2,15 +2,22 @@
2
2
  * Feature extractor — converts raw detections into a structured FeatureVector.
3
3
  * Must produce identical output to the Dart and server TypeScript implementations.
4
4
  */
5
- const SCHEMA_VERSION = '1.0.0';
5
+ const SCHEMA_VERSION = '2.0.0';
6
6
  /** Vehicle class IDs (must match ontology.ts) */
7
- const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
7
+ const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
8
8
  /** Surface class IDs */
9
9
  const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
10
10
  /** Ontology class name mapping (must match ontology.ts) */
11
11
  export const ONTOLOGY_CLASS_NAMES = {
12
- 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
12
+ 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
13
13
  20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
14
+ // Car panels (ontology v2 — damage intelligence)
15
+ 25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
16
+ 28: 'car_front_bumper', 29: 'car_rear_bumper',
17
+ 30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
18
+ 34: 'car_fender_fl', 35: 'car_fender_fr',
19
+ 36: 'car_quarter_rl', 37: 'car_quarter_rr',
20
+ 38: 'car_rocker', 39: 'car_mirror',
14
21
  40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
15
22
  44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
16
23
  60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
@@ -20,8 +27,9 @@ export const ONTOLOGY_CLASS_NAMES = {
20
27
  };
21
28
  /** Detection model class index -> ontology class ID mapping. */
22
29
  export const MODEL_OUTPUT_CLASS_IDS = [
23
- 1, 2, 3, 4, 5,
30
+ 1, 2, 3, 4, 5, 6,
24
31
  20, 21, 22, 23, 24,
32
+ 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
25
33
  40, 41, 42, 43, 44, 45, 46, 47, 48,
26
34
  60, 61, 62, 63, 64, 65, 66,
27
35
  80, 81, 82, 83, 84, 85, 86, 87,
@@ -15,8 +15,21 @@ export declare class ModelManager {
15
15
  private _currentBundle;
16
16
  constructor(config: ModelManagerConfig);
17
17
  get currentBundle(): BundleManifest | null;
18
- /** Check for bundle updates from the server. */
19
- checkForUpdates(policyId: string, platform: 'ios' | 'android'): Promise<BundleManifest | null>;
18
+ /**
19
+ * Check for bundle updates from the server.
20
+ *
21
+ * `opts` lets callers narrow which bundle they receive when the server
22
+ * has per-asset / per-region / per-app-version targeted bundles
23
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
24
+ * omitting them keeps the legacy behaviour of receiving the wildcard
25
+ * bundle for the policy.
26
+ */
27
+ checkForUpdates(policyId: string, platform: 'ios' | 'android', opts?: {
28
+ assetType?: string;
29
+ region?: string;
30
+ appVersion?: string;
31
+ deviceId?: string;
32
+ }): Promise<BundleManifest | null>;
20
33
  /** Download model artifacts and verify integrity. */
21
34
  private downloadArtifacts;
22
35
  private readFileSha256;
@@ -26,8 +26,16 @@ export class ModelManager {
26
26
  get currentBundle() {
27
27
  return this._currentBundle;
28
28
  }
29
- /** Check for bundle updates from the server. */
30
- async checkForUpdates(policyId, platform) {
29
+ /**
30
+ * Check for bundle updates from the server.
31
+ *
32
+ * `opts` lets callers narrow which bundle they receive when the server
33
+ * has per-asset / per-region / per-app-version targeted bundles
34
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
35
+ * omitting them keeps the legacy behaviour of receiving the wildcard
36
+ * bundle for the policy.
37
+ */
38
+ async checkForUpdates(policyId, platform, opts) {
31
39
  try {
32
40
  const headers = {
33
41
  'X-API-Key': this.apiKey,
@@ -36,7 +44,16 @@ export class ModelManager {
36
44
  if (this.cachedETag) {
37
45
  headers['If-None-Match'] = this.cachedETag;
38
46
  }
39
- const url = `${this.baseUrl}/models/latest?policy=${policyId}&platform=${platform}`;
47
+ const qs = new URLSearchParams({ policy: policyId, platform });
48
+ if (opts?.assetType)
49
+ qs.set('asset_type', opts.assetType);
50
+ if (opts?.region)
51
+ qs.set('region', opts.region);
52
+ if (opts?.appVersion)
53
+ qs.set('app_version', opts.appVersion);
54
+ if (opts?.deviceId)
55
+ qs.set('device_id', opts.deviceId);
56
+ const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
40
57
  const response = await fetch(url, { headers });
41
58
  if (response.status === 304) {
42
59
  return this._currentBundle;
@@ -5,6 +5,24 @@
5
5
  * This file shares the same logic as lib/verify-ai/ml/policy-engine.ts
6
6
  * but is self-contained for the SDK package.
7
7
  */
8
+ // Severity ordering for `severity_gte`. Must match server TS + Dart impls.
9
+ const SEVERITY_ORDER = ['none', 'light', 'medium', 'severe'];
10
+ function severityRank(s) {
11
+ if (typeof s !== 'string')
12
+ return -1;
13
+ return SEVERITY_ORDER.indexOf(s);
14
+ }
15
+ function compareNumber(a, op, b) {
16
+ switch (op) {
17
+ case 'eq': return a === b;
18
+ case 'neq': return a !== b;
19
+ case 'gt': return a > b;
20
+ case 'gte': return a >= b;
21
+ case 'lt': return a < b;
22
+ case 'lte': return a <= b;
23
+ default: return false;
24
+ }
25
+ }
8
26
  // ─── Field Resolution ───
9
27
  function resolveField(features, field) {
10
28
  const parts = field.split('.');
@@ -67,9 +85,62 @@ function applyOperator(fieldValue, operator, value) {
67
85
  }
68
86
  }
69
87
  function evaluateCondition(features, condition) {
88
+ if (condition.operator === 'severity_gte' ||
89
+ condition.operator === 'panel_has_damage' ||
90
+ condition.operator === 'aggregate_count') {
91
+ return applyDamageOperator(features, condition);
92
+ }
70
93
  const fieldValue = resolveField(features, condition.field);
71
94
  return applyOperator(fieldValue, condition.operator, condition.value);
72
95
  }
96
+ function applyDamageOperator(features, condition) {
97
+ const findings = Array.isArray(features.damage_findings)
98
+ ? features.damage_findings
99
+ : [];
100
+ switch (condition.operator) {
101
+ case 'severity_gte': {
102
+ const minRank = severityRank(condition.value);
103
+ if (minRank < 0)
104
+ return false;
105
+ if (condition.field && condition.field !== 'damage_findings') {
106
+ const f = resolveField(features, condition.field);
107
+ return severityRank(f) >= minRank;
108
+ }
109
+ return findings.some((finding) => severityRank(finding.severity) >= minRank);
110
+ }
111
+ case 'panel_has_damage': {
112
+ const panelName = typeof condition.value === 'string'
113
+ ? condition.value
114
+ : typeof condition.field === 'string' &&
115
+ condition.field !== 'damage_findings' &&
116
+ condition.field !== ''
117
+ ? condition.field
118
+ : null;
119
+ if (!panelName)
120
+ return false;
121
+ return findings.some((finding) => finding.panel === panelName && finding.severity !== 'none');
122
+ }
123
+ case 'aggregate_count': {
124
+ const v = condition.value;
125
+ if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op)
126
+ return false;
127
+ const where = v.where || {};
128
+ const minSeverityRank = where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
129
+ const count = findings.filter((finding) => {
130
+ if (where.panel && finding.panel !== where.panel)
131
+ return false;
132
+ if (where.damage_type && finding.damage_type !== where.damage_type)
133
+ return false;
134
+ if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank)
135
+ return false;
136
+ return true;
137
+ }).length;
138
+ return compareNumber(count, v.op, v.value);
139
+ }
140
+ default:
141
+ return false;
142
+ }
143
+ }
73
144
  function evaluateRule(features, rule) {
74
145
  const passed = rule.conditions.every((c) => evaluateCondition(features, c));
75
146
  return {
package/lib/ml/types.d.ts CHANGED
@@ -32,6 +32,18 @@ export interface ImageQuality {
32
32
  is_dark: boolean;
33
33
  has_vehicle: boolean;
34
34
  }
35
+ export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
36
+ export type DamageType = 'scratch' | 'dent' | 'paint_chip' | 'crack' | 'broken' | 'missing' | 'rust' | 'tear' | 'stain' | 'glass_damage' | 'other';
37
+ export interface DamageFinding {
38
+ finding_id: string;
39
+ panel: string;
40
+ damage_type: DamageType;
41
+ severity: DamageSeverity;
42
+ severity_score: number;
43
+ bbox: [number, number, number, number];
44
+ area_pct: number;
45
+ confidence: number;
46
+ }
35
47
  export interface FeatureVector {
36
48
  schema_version: string;
37
49
  detections: Detection[];
@@ -39,8 +51,12 @@ export interface FeatureVector {
39
51
  image_quality: ImageQuality;
40
52
  vehicle_on_surface: string | null;
41
53
  vehicle_near: string[];
54
+ /** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
55
+ damage_findings?: DamageFinding[];
56
+ /** Panels visible in this frame (schema v2). */
57
+ panel_inventory?: string[];
42
58
  }
43
- export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps';
59
+ export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps' | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
44
60
  export interface Condition {
45
61
  field: string;
46
62
  operator: ComparisonOperator;
@@ -13,6 +13,7 @@ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
13
13
  const CRITICAL_EVENTS = new Set([
14
14
  'camera_init_failure',
15
15
  'camera_preview_timeout',
16
+ 'camera_startup_slow',
16
17
  'camera_permission_denied',
17
18
  'camera_scanner_mounted',
18
19
  'camera_scanner_disposed',
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "2.4.23";
1
+ export declare const SDK_VERSION = "2.5.1";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.23';
1
+ export const SDK_VERSION = '2.5.1';
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.23",
3
+ "version": "2.5.1",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/levyelectric/verify.git",
8
+ "directory": "packages/verify-ai-react-native"
9
+ },
5
10
  "main": "./lib/index.js",
6
11
  "types": "./lib/index.d.ts",
7
12
  "exports": {
@@ -45,8 +45,12 @@ 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 IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
49
- const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
48
+ const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
49
+ const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
50
+ const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
51
+ const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
52
+ const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
53
+ const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
50
54
  const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
51
55
  const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
52
56
  const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
@@ -307,6 +311,7 @@ export function VerifyAIScanner({
307
311
  const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
308
312
  const [cameraReady, setCameraReady] = useState(false);
309
313
  const [cameraKey, setCameraKey] = useState(0);
314
+ const [cameraMounted, setCameraMounted] = useState(true);
310
315
  const terminalResultRef = useRef<VerificationResult | null>(null);
311
316
  const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
312
317
  const terminalResultDeliveredRef = useRef(false);
@@ -319,6 +324,11 @@ export function VerifyAIScanner({
319
324
  const lastAppStateRemountAtRef = useRef<string | null>(null);
320
325
  const lastOrientationRemountAtRef = useRef<string | null>(null);
321
326
  const lastCaptureRetryAtRef = useRef<string | null>(null);
327
+ const cameraStartupStartedAtRef = useRef(Date.now());
328
+ const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
329
+ const cameraStartupSlowTrackedRef = useRef(false);
330
+ const cameraStartupReadyTrackedRef = useRef(false);
331
+ const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
322
332
  const cameraRemountCountRef = useRef(0);
323
333
  const startupWatchdogRemountCountRef = useRef(0);
324
334
  const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
@@ -369,7 +379,10 @@ export function VerifyAIScanner({
369
379
  camera_ever_ready: cameraEverReadyRef.current,
370
380
  camera_init_failed: cameraInitFailedRef.current,
371
381
  camera_key: cameraKey,
382
+ camera_mounted: cameraMounted ? 1 : 0,
372
383
  camera_remount_count: cameraRemountCountRef.current,
384
+ camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
385
+ camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
373
386
  terminated,
374
387
  exhausted,
375
388
  app_state: appStateRef.current,
@@ -387,6 +400,7 @@ export function VerifyAIScanner({
387
400
  last_appstate_remount_at: lastAppStateRemountAtRef.current,
388
401
  last_orientation_remount_at: lastOrientationRemountAtRef.current,
389
402
  last_capture_retry_at: lastCaptureRetryAtRef.current,
403
+ requested_torch_enabled: enableTorch ? 1 : 0,
390
404
  android_native_orientation_subscription_active: 0,
391
405
  android_native_orientation_event_count: 0,
392
406
  android_native_orientation_change_count: 0,
@@ -423,7 +437,9 @@ export function VerifyAIScanner({
423
437
  ...extra,
424
438
  }), [
425
439
  cameraKey,
440
+ cameraMounted,
426
441
  exhausted,
442
+ enableTorch,
427
443
  isLandscape,
428
444
  overlayRotationDeg,
429
445
  physicalOrientation,
@@ -474,6 +490,52 @@ export function VerifyAIScanner({
474
490
  };
475
491
  }, []);
476
492
 
493
+ const requestCameraRemount = useCallback((
494
+ reason: string,
495
+ opts: {
496
+ backoffMs?: number;
497
+ log?: boolean;
498
+ } = {},
499
+ ) => {
500
+ if (startupRemountTimerRef.current) {
501
+ clearTimeout(startupRemountTimerRef.current);
502
+ startupRemountTimerRef.current = null;
503
+ }
504
+
505
+ setCameraReady(false);
506
+ cameraReadyRef.current = false;
507
+ cameraStartupAttemptStartedAtRef.current = Date.now();
508
+
509
+ if (opts.log) {
510
+ console.warn(
511
+ `VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
512
+ `backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
513
+ );
514
+ }
515
+
516
+ const remount = () => {
517
+ setCameraKey((k) => k + 1);
518
+ setCameraMounted(true);
519
+ startupRemountTimerRef.current = null;
520
+ };
521
+
522
+ if ((opts.backoffMs ?? 0) > 0) {
523
+ setCameraMounted(false);
524
+ startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
525
+ return;
526
+ }
527
+
528
+ setCameraMounted(true);
529
+ remount();
530
+ }, [enableTorch]);
531
+
532
+ useEffect(() => () => {
533
+ if (startupRemountTimerRef.current) {
534
+ clearTimeout(startupRemountTimerRef.current);
535
+ startupRemountTimerRef.current = null;
536
+ }
537
+ }, []);
538
+
477
539
  useEffect(() => {
478
540
  if (Platform.OS !== 'android') return;
479
541
 
@@ -726,17 +788,9 @@ export function VerifyAIScanner({
726
788
  to: windowWidth > windowHeight ? 'landscape' : 'portrait',
727
789
  },
728
790
  });
729
- setCameraReady(false);
730
- cameraReadyRef.current = false;
731
-
732
- // Delay remount so iOS rotation animation completes before the new
733
- // AVCaptureVideoPreviewLayer initializes with the final frame bounds.
734
- const timer = setTimeout(() => {
735
- setCameraKey((k) => k + 1);
736
- }, 400);
737
- return () => clearTimeout(timer);
791
+ requestCameraRemount('orientation_change', { backoffMs: 400 });
738
792
  }
739
- }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
793
+ }, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
740
794
 
741
795
  // Resume camera when app returns from background/inactive (e.g. notification bar)
742
796
  useEffect(() => {
@@ -767,13 +821,11 @@ export function VerifyAIScanner({
767
821
  remount_requested_at: at,
768
822
  }),
769
823
  });
770
- setCameraReady(false);
771
- cameraReadyRef.current = false;
772
- setCameraKey((k) => k + 1);
824
+ requestCameraRemount('appstate_active');
773
825
  });
774
826
 
775
827
  return () => subscription.remove();
776
- }, [terminated, buildScannerTelemetryMetadata, telemetry]);
828
+ }, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
777
829
 
778
830
  const pausePreview = useCallback(() => {
779
831
  cameraRef.current?.pausePreview?.().catch(() => {});
@@ -834,13 +886,36 @@ export function VerifyAIScanner({
834
886
 
835
887
  // Camera init callbacks
836
888
  const onCameraReady = useCallback(() => {
837
- lastCameraReadyAtRef.current = new Date().toISOString();
889
+ const readyAt = new Date().toISOString();
890
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
891
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
892
+ lastCameraReadyAtRef.current = readyAt;
838
893
  setCameraReady(true);
839
894
  cameraReadyRef.current = true;
840
895
  cameraEverReadyRef.current = true;
841
896
  cameraInitFailedRef.current = false;
842
897
  startupWatchdogRemountCountRef.current = 0;
843
- }, []);
898
+ if (!cameraStartupReadyTrackedRef.current) {
899
+ cameraStartupReadyTrackedRef.current = true;
900
+ console.log(
901
+ `VerifyAI[${SDK_VERSION}]: camera ready ` +
902
+ `startupTotalMs=${startupTotalElapsedMs} ` +
903
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
904
+ `torchRequested=${enableTorch ? 1 : 0}`,
905
+ );
906
+ telemetry?.track('camera_ready', {
907
+ component: 'scanner',
908
+ error: 'camera_ready',
909
+ metadata: buildScannerTelemetryMetadata({
910
+ camera_ready_at: readyAt,
911
+ startup_total_elapsed_ms: startupTotalElapsedMs,
912
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
913
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
914
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
915
+ }),
916
+ });
917
+ }
918
+ }, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
844
919
 
845
920
  const onMountError = useCallback((event: { message?: string }) => {
846
921
  const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
@@ -863,12 +938,68 @@ export function VerifyAIScanner({
863
938
  });
864
939
  }, [buildScannerTelemetryMetadata, onError, telemetry]);
865
940
 
941
+ useEffect(() => {
942
+ if (
943
+ !permission?.granted ||
944
+ terminated ||
945
+ currentAppState !== 'active' ||
946
+ !cameraMounted ||
947
+ cameraReadyRef.current ||
948
+ cameraInitFailedRef.current ||
949
+ cameraStartupSlowTrackedRef.current
950
+ ) {
951
+ return;
952
+ }
953
+
954
+ const timer = setTimeout(() => {
955
+ if (
956
+ appStateRef.current !== 'active' ||
957
+ cameraReadyRef.current ||
958
+ cameraInitFailedRef.current ||
959
+ cameraStartupSlowTrackedRef.current
960
+ ) {
961
+ return;
962
+ }
963
+
964
+ cameraStartupSlowTrackedRef.current = true;
965
+ const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
966
+ const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
967
+ console.warn(
968
+ `VerifyAI[${SDK_VERSION}]: camera startup slow ` +
969
+ `startupTotalMs=${startupTotalElapsedMs} ` +
970
+ `startupAttemptMs=${startupAttemptElapsedMs} ` +
971
+ `torchRequested=${enableTorch ? 1 : 0}`,
972
+ );
973
+ telemetry?.track('camera_startup_slow', {
974
+ component: 'scanner',
975
+ error: 'camera_startup_slow',
976
+ metadata: buildScannerTelemetryMetadata({
977
+ startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
978
+ startup_total_elapsed_ms: startupTotalElapsedMs,
979
+ startup_attempt_elapsed_ms: startupAttemptElapsedMs,
980
+ startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
981
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
982
+ }),
983
+ });
984
+ }, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
985
+
986
+ return () => clearTimeout(timer);
987
+ }, [
988
+ permission?.granted,
989
+ terminated,
990
+ currentAppState,
991
+ cameraMounted,
992
+ buildScannerTelemetryMetadata,
993
+ enableTorch,
994
+ telemetry,
995
+ ]);
996
+
866
997
  // Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
867
998
  // On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
868
999
  // mount (e.g. during navigation transitions). Changing the key forces React to destroy and
869
1000
  // recreate the CameraView, which starts a fresh native session.
870
1001
  useEffect(() => {
871
- if (!permission?.granted || terminated || currentAppState !== 'active') return;
1002
+ if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
872
1003
 
873
1004
  const watchdogMs = Platform.OS === 'android'
874
1005
  ? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
@@ -902,11 +1033,14 @@ export function VerifyAIScanner({
902
1033
  remount_reason: 'startup_watchdog',
903
1034
  startup_watchdog_remount_count: startupWatchdogRemountCount,
904
1035
  startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
1036
+ startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1037
+ torch_deferred_until_ready: enableTorch ? 1 : 0,
905
1038
  }),
906
1039
  });
907
- setCameraReady(false);
908
- cameraReadyRef.current = false;
909
- setCameraKey((k) => k + 1);
1040
+ requestCameraRemount('startup_watchdog', {
1041
+ backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
1042
+ log: true,
1043
+ });
910
1044
  }
911
1045
  }
912
1046
  }, watchdogMs);
@@ -916,9 +1050,12 @@ export function VerifyAIScanner({
916
1050
  permission?.granted,
917
1051
  terminated,
918
1052
  currentAppState,
1053
+ cameraMounted,
919
1054
  cameraKey,
920
1055
  buildScannerTelemetryMetadata,
1056
+ enableTorch,
921
1057
  onMountError,
1058
+ requestCameraRemount,
922
1059
  telemetry,
923
1060
  ]);
924
1061
 
@@ -1075,11 +1212,12 @@ export function VerifyAIScanner({
1075
1212
  const retryAt = new Date().toISOString();
1076
1213
  lastCaptureRetryAtRef.current = retryAt;
1077
1214
  cameraRemountCountRef.current++;
1078
- setCameraReady(false);
1079
- cameraReadyRef.current = false;
1080
- setCameraKey((key) => key + 1);
1215
+ requestCameraRemount('capture_retry');
1081
1216
  await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
1082
- captureRetryReady = await waitForCameraReady(2500);
1217
+ const captureRetryReadyTimeoutMs = Platform.OS === 'android'
1218
+ ? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
1219
+ : IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
1220
+ captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
1083
1221
  telemetry?.track('camera_capture_retry', {
1084
1222
  component: 'scanner',
1085
1223
  error: normalized,
@@ -1089,7 +1227,7 @@ export function VerifyAIScanner({
1089
1227
  capture_retry_attempted: captureRetryAttempted,
1090
1228
  capture_retry_ready: captureRetryReady,
1091
1229
  capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
1092
- capture_retry_ready_timeout_ms: 2500,
1230
+ capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
1093
1231
  last_native_capture_error: lastNativeCaptureErrorMessage,
1094
1232
  }),
1095
1233
  });
@@ -1280,7 +1418,7 @@ export function VerifyAIScanner({
1280
1418
  setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
1281
1419
  }
1282
1420
  }
1283
- }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1421
+ }, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
1284
1422
 
1285
1423
  // Expose capture to parent via ref
1286
1424
  if (captureRef) {
@@ -1303,22 +1441,24 @@ export function VerifyAIScanner({
1303
1441
  }
1304
1442
 
1305
1443
  const showBottomCard = status === 'success' || status === 'error';
1444
+ const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
1306
1445
 
1307
1446
  return (
1308
1447
  <View style={[styles.container, style]}>
1309
- <CameraView
1310
- key={cameraKey}
1311
- ref={cameraRef}
1312
- style={styles.camera}
1313
- facing="back"
1314
- enableTorch={!terminated && enableTorch}
1315
- onCameraReady={onCameraReady}
1316
- onMountError={onMountError}
1317
- responsiveOrientationWhenOrientationLocked
1318
- onResponsiveOrientationChanged={(event) => {
1319
- setPhysicalOrientation(event.orientation);
1320
- }}
1321
- >
1448
+ {cameraMounted && (
1449
+ <CameraView
1450
+ key={cameraKey}
1451
+ ref={cameraRef}
1452
+ style={styles.camera}
1453
+ facing="back"
1454
+ enableTorch={shouldEnableTorch}
1455
+ onCameraReady={onCameraReady}
1456
+ onMountError={onMountError}
1457
+ responsiveOrientationWhenOrientationLocked
1458
+ onResponsiveOrientationChanged={(event) => {
1459
+ setPhysicalOrientation(event.orientation);
1460
+ }}
1461
+ >
1322
1462
  {/* Overlay */}
1323
1463
  <View style={styles.overlay}>
1324
1464
  {overlay?.title && (
@@ -1522,7 +1662,8 @@ export function VerifyAIScanner({
1522
1662
  </TouchableOpacity>
1523
1663
  )}
1524
1664
  </View>
1525
- </CameraView>
1665
+ </CameraView>
1666
+ )}
1526
1667
  </View>
1527
1668
  );
1528
1669
  }
@@ -5,17 +5,24 @@
5
5
 
6
6
  import type { FeatureVector, Detection, ImageQuality } from './types';
7
7
 
8
- const SCHEMA_VERSION = '1.0.0';
8
+ const SCHEMA_VERSION = '2.0.0';
9
9
 
10
10
  /** Vehicle class IDs (must match ontology.ts) */
11
- const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
11
+ const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
12
12
  /** Surface class IDs */
13
13
  const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
14
14
 
15
15
  /** Ontology class name mapping (must match ontology.ts) */
16
16
  export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
17
- 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
17
+ 1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
18
18
  20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
19
+ // Car panels (ontology v2 — damage intelligence)
20
+ 25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
21
+ 28: 'car_front_bumper', 29: 'car_rear_bumper',
22
+ 30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
23
+ 34: 'car_fender_fl', 35: 'car_fender_fr',
24
+ 36: 'car_quarter_rl', 37: 'car_quarter_rr',
25
+ 38: 'car_rocker', 39: 'car_mirror',
19
26
  40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
20
27
  44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
21
28
  60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
@@ -26,8 +33,9 @@ export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
26
33
 
27
34
  /** Detection model class index -> ontology class ID mapping. */
28
35
  export const MODEL_OUTPUT_CLASS_IDS = [
29
- 1, 2, 3, 4, 5,
36
+ 1, 2, 3, 4, 5, 6,
30
37
  20, 21, 22, 23, 24,
38
+ 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
31
39
  40, 41, 42, 43, 44, 45, 46, 47, 48,
32
40
  60, 61, 62, 63, 64, 65, 66,
33
41
  80, 81, 82, 83, 84, 85, 86, 87,
@@ -62,8 +62,25 @@ export class ModelManager {
62
62
  return this._currentBundle;
63
63
  }
64
64
 
65
- /** Check for bundle updates from the server. */
66
- async checkForUpdates(policyId: string, platform: 'ios' | 'android'): Promise<BundleManifest | null> {
65
+ /**
66
+ * Check for bundle updates from the server.
67
+ *
68
+ * `opts` lets callers narrow which bundle they receive when the server
69
+ * has per-asset / per-region / per-app-version targeted bundles
70
+ * configured (see `app/api/v1/models/latest`). All fields are optional;
71
+ * omitting them keeps the legacy behaviour of receiving the wildcard
72
+ * bundle for the policy.
73
+ */
74
+ async checkForUpdates(
75
+ policyId: string,
76
+ platform: 'ios' | 'android',
77
+ opts?: {
78
+ assetType?: string;
79
+ region?: string;
80
+ appVersion?: string;
81
+ deviceId?: string;
82
+ },
83
+ ): Promise<BundleManifest | null> {
67
84
  try {
68
85
  const headers: Record<string, string> = {
69
86
  'X-API-Key': this.apiKey,
@@ -74,7 +91,13 @@ export class ModelManager {
74
91
  headers['If-None-Match'] = this.cachedETag;
75
92
  }
76
93
 
77
- const url = `${this.baseUrl}/models/latest?policy=${policyId}&platform=${platform}`;
94
+ const qs = new URLSearchParams({ policy: policyId, platform });
95
+ if (opts?.assetType) qs.set('asset_type', opts.assetType);
96
+ if (opts?.region) qs.set('region', opts.region);
97
+ if (opts?.appVersion) qs.set('app_version', opts.appVersion);
98
+ if (opts?.deviceId) qs.set('device_id', opts.deviceId);
99
+
100
+ const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
78
101
  const response = await fetch(url, { headers });
79
102
 
80
103
  if (response.status === 304) {
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import type {
10
+ DamageFinding,
11
+ DamageSeverity,
10
12
  FeatureVector,
11
13
  PolicyAST,
12
14
  PolicyResult,
@@ -17,6 +19,35 @@ import type {
17
19
  ComparisonOperator,
18
20
  } from './types';
19
21
 
22
+ // Severity ordering for `severity_gte`. Must match server TS + Dart impls.
23
+ const SEVERITY_ORDER: DamageSeverity[] = ['none', 'light', 'medium', 'severe'];
24
+ function severityRank(s: unknown): number {
25
+ if (typeof s !== 'string') return -1;
26
+ return SEVERITY_ORDER.indexOf(s as DamageSeverity);
27
+ }
28
+
29
+ interface AggregateCountValue {
30
+ op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
31
+ value: number;
32
+ where?: {
33
+ panel?: string;
34
+ damage_type?: string;
35
+ severity_gte?: DamageSeverity;
36
+ };
37
+ }
38
+
39
+ function compareNumber(a: number, op: AggregateCountValue['op'], b: number): boolean {
40
+ switch (op) {
41
+ case 'eq': return a === b;
42
+ case 'neq': return a !== b;
43
+ case 'gt': return a > b;
44
+ case 'gte': return a >= b;
45
+ case 'lt': return a < b;
46
+ case 'lte': return a <= b;
47
+ default: return false;
48
+ }
49
+ }
50
+
20
51
  // ─── Field Resolution ───
21
52
 
22
53
  function resolveField(features: FeatureVector, field: string): unknown {
@@ -77,10 +108,65 @@ function applyOperator(fieldValue: unknown, operator: ComparisonOperator, value:
77
108
  }
78
109
 
79
110
  function evaluateCondition(features: FeatureVector, condition: Condition): boolean {
111
+ if (
112
+ condition.operator === 'severity_gte' ||
113
+ condition.operator === 'panel_has_damage' ||
114
+ condition.operator === 'aggregate_count'
115
+ ) {
116
+ return applyDamageOperator(features, condition);
117
+ }
80
118
  const fieldValue = resolveField(features, condition.field);
81
119
  return applyOperator(fieldValue, condition.operator, condition.value);
82
120
  }
83
121
 
122
+ function applyDamageOperator(features: FeatureVector, condition: Condition): boolean {
123
+ const findings: DamageFinding[] = Array.isArray(features.damage_findings)
124
+ ? features.damage_findings
125
+ : [];
126
+
127
+ switch (condition.operator) {
128
+ case 'severity_gte': {
129
+ const minRank = severityRank(condition.value);
130
+ if (minRank < 0) return false;
131
+ if (condition.field && condition.field !== 'damage_findings') {
132
+ const f = resolveField(features, condition.field);
133
+ return severityRank(f) >= minRank;
134
+ }
135
+ return findings.some((finding) => severityRank(finding.severity) >= minRank);
136
+ }
137
+ case 'panel_has_damage': {
138
+ const panelName =
139
+ typeof condition.value === 'string'
140
+ ? condition.value
141
+ : typeof condition.field === 'string' &&
142
+ condition.field !== 'damage_findings' &&
143
+ condition.field !== ''
144
+ ? condition.field
145
+ : null;
146
+ if (!panelName) return false;
147
+ return findings.some(
148
+ (finding) => finding.panel === panelName && finding.severity !== 'none',
149
+ );
150
+ }
151
+ case 'aggregate_count': {
152
+ const v = condition.value as AggregateCountValue | null | undefined;
153
+ if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op) return false;
154
+ const where = v.where || {};
155
+ const minSeverityRank =
156
+ where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
157
+ const count = findings.filter((finding) => {
158
+ if (where.panel && finding.panel !== where.panel) return false;
159
+ if (where.damage_type && finding.damage_type !== where.damage_type) return false;
160
+ if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank) return false;
161
+ return true;
162
+ }).length;
163
+ return compareNumber(count, v.op, v.value);
164
+ }
165
+ default:
166
+ return false;
167
+ }
168
+ }
169
+
84
170
  function evaluateRule(features: FeatureVector, rule: Rule): RuleResult {
85
171
  const passed = rule.conditions.every((c) => evaluateCondition(features, c));
86
172
  return {
package/src/ml/types.ts CHANGED
@@ -39,6 +39,33 @@ export interface ImageQuality {
39
39
  has_vehicle: boolean;
40
40
  }
41
41
 
42
+ // ─── Damage Findings (schema v2) ───
43
+
44
+ export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
45
+ export type DamageType =
46
+ | 'scratch'
47
+ | 'dent'
48
+ | 'paint_chip'
49
+ | 'crack'
50
+ | 'broken'
51
+ | 'missing'
52
+ | 'rust'
53
+ | 'tear'
54
+ | 'stain'
55
+ | 'glass_damage'
56
+ | 'other';
57
+
58
+ export interface DamageFinding {
59
+ finding_id: string;
60
+ panel: string;
61
+ damage_type: DamageType;
62
+ severity: DamageSeverity;
63
+ severity_score: number;
64
+ bbox: [number, number, number, number];
65
+ area_pct: number;
66
+ confidence: number;
67
+ }
68
+
42
69
  export interface FeatureVector {
43
70
  schema_version: string;
44
71
  detections: Detection[];
@@ -46,6 +73,10 @@ export interface FeatureVector {
46
73
  image_quality: ImageQuality;
47
74
  vehicle_on_surface: string | null;
48
75
  vehicle_near: string[];
76
+ /** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
77
+ damage_findings?: DamageFinding[];
78
+ /** Panels visible in this frame (schema v2). */
79
+ panel_inventory?: string[];
49
80
  }
50
81
 
51
82
  // ─── Policy AST Types ───
@@ -54,7 +85,9 @@ export type ComparisonOperator =
54
85
  | 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
55
86
  | 'contains' | 'not_contains'
56
87
  | 'exists' | 'not_exists'
57
- | 'in' | 'not_in' | 'overlaps';
88
+ | 'in' | 'not_in' | 'overlaps'
89
+ // AST v1.1 — damage operators
90
+ | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
58
91
 
59
92
  export interface Condition {
60
93
  field: string;
@@ -38,6 +38,7 @@ interface BufferedEvent {
38
38
  const CRITICAL_EVENTS = new Set([
39
39
  'camera_init_failure',
40
40
  'camera_preview_timeout',
41
+ 'camera_startup_slow',
41
42
  'camera_permission_denied',
42
43
  'camera_scanner_mounted',
43
44
  'camera_scanner_disposed',
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.23';
1
+ export const SDK_VERSION = '2.5.1';