@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/README.md
CHANGED
|
@@ -13,11 +13,14 @@ npm install @switchlabs/verify-ai-react-native
|
|
|
13
13
|
With built-in camera scanner:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator
|
|
16
|
+
npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator expo-sensors
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
For offline queue support, also install `@react-native-async-storage/async-storage`.
|
|
20
20
|
|
|
21
|
+
`expo-sensors` powers Android physical-orientation tracking for scanner overlays
|
|
22
|
+
when the host app is portrait-locked.
|
|
23
|
+
|
|
21
24
|
If you want on-device inference, also install `expo-file-system`,
|
|
22
25
|
`react-native-fast-tflite`, and configure `react-native-fast-tflite` for the
|
|
23
26
|
delegates you plan to use.
|
package/lib/client/index.js
CHANGED
|
@@ -22,7 +22,7 @@ function enrichMetadata(metadata) {
|
|
|
22
22
|
export class VerifyAIClient {
|
|
23
23
|
constructor(config) {
|
|
24
24
|
if (!config.apiKey) {
|
|
25
|
-
throw new Error(
|
|
25
|
+
throw new Error(`VerifyAI[v${SDK_VERSION}]: apiKey is required`);
|
|
26
26
|
}
|
|
27
27
|
this.apiKey = config.apiKey;
|
|
28
28
|
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
@@ -95,7 +95,7 @@ export class VerifyAIClient {
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
if (!body || typeof body !== 'object') {
|
|
98
|
-
throw this.buildRequestError(
|
|
98
|
+
throw this.buildRequestError(`VerifyAI[v${SDK_VERSION}]: Invalid response payload`, 0, context, {
|
|
99
99
|
code: 'invalid_response',
|
|
100
100
|
request_id: response.headers.get('X-Request-Id') || undefined,
|
|
101
101
|
});
|
|
@@ -6,6 +6,7 @@ import { VerifyAIRequestError } from '../client';
|
|
|
6
6
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
7
|
import { BikeOverlay } from './BikeOverlay';
|
|
8
8
|
import { ScooterOverlay } from './ScooterOverlay';
|
|
9
|
+
import { ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD, classifyAndroidAccelerometerOrientation, getOverlayRotationDeg, } from './scannerOrientation';
|
|
9
10
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
10
11
|
const FALLBACK_QUALITY = 0.65;
|
|
11
12
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
@@ -14,13 +15,16 @@ const MANIPULATOR_QUALITY = 0.8;
|
|
|
14
15
|
const MAX_DIMENSION = 1600;
|
|
15
16
|
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
16
17
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
18
|
+
const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
17
19
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
18
20
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
19
21
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
20
|
-
const IOS_CAMERA_STARTUP_WATCHDOG_MS =
|
|
22
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
21
23
|
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 5000;
|
|
24
|
+
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
22
25
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
23
26
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
27
|
+
const ANDROID_ORIENTATION_SETTLE_MS = 250;
|
|
24
28
|
function getPolicyScannerDefaults(policy) {
|
|
25
29
|
const id = policy?.toLowerCase() ?? '';
|
|
26
30
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -121,6 +125,9 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
121
125
|
else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
122
126
|
message = 'The camera had trouble taking a photo. Please try again.';
|
|
123
127
|
}
|
|
128
|
+
else if (code === CAMERA_INIT_ERROR_CODE) {
|
|
129
|
+
message = 'The camera could not be started. Close any other app using the camera and try again.';
|
|
130
|
+
}
|
|
124
131
|
else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
125
132
|
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
126
133
|
}
|
|
@@ -194,11 +201,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
194
201
|
const attemptCountRef = useRef(0);
|
|
195
202
|
const [exhausted, setExhausted] = useState(false);
|
|
196
203
|
const [terminated, setTerminated] = useState(false);
|
|
204
|
+
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
197
205
|
const [cameraReady, setCameraReady] = useState(false);
|
|
198
206
|
const [cameraKey, setCameraKey] = useState(0);
|
|
199
207
|
const terminalResultRef = useRef(null);
|
|
200
208
|
const terminalResultTimerRef = useRef(null);
|
|
201
209
|
const terminalResultDeliveredRef = useRef(false);
|
|
210
|
+
const appStateRef = useRef(AppState.currentState);
|
|
202
211
|
const cameraReadyRef = useRef(false);
|
|
203
212
|
const cameraEverReadyRef = useRef(false);
|
|
204
213
|
const cameraInitFailedRef = useRef(false);
|
|
@@ -208,6 +217,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
208
217
|
const lastOrientationRemountAtRef = useRef(null);
|
|
209
218
|
const lastCaptureRetryAtRef = useRef(null);
|
|
210
219
|
const cameraRemountCountRef = useRef(0);
|
|
220
|
+
const startupWatchdogRemountCountRef = useRef(0);
|
|
221
|
+
const telemetryRef = useRef(telemetry);
|
|
222
|
+
const buildScannerTelemetryMetadataRef = useRef(null);
|
|
211
223
|
const androidOrientationSubscriptionActiveRef = useRef(false);
|
|
212
224
|
const androidOrientationEventCountRef = useRef(0);
|
|
213
225
|
const androidOrientationChangeCountRef = useRef(0);
|
|
@@ -219,8 +231,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
219
231
|
const lastAndroidOrientationZRef = useRef(null);
|
|
220
232
|
const lastAndroidOrientationDerivedRef = useRef(null);
|
|
221
233
|
const lastAndroidOrientationIgnoredReasonRef = useRef(null);
|
|
234
|
+
const lastAndroidOrientationDominantAxisRef = useRef(null);
|
|
222
235
|
const lastAndroidOrientationErrorAtRef = useRef(null);
|
|
223
236
|
const lastAndroidOrientationErrorRef = useRef(null);
|
|
237
|
+
const pendingAndroidPhysicalOrientationRef = useRef(null);
|
|
238
|
+
const pendingAndroidPhysicalOrientationStartedAtRef = useRef(null);
|
|
239
|
+
const androidOrientationSettleTimerRef = useRef(null);
|
|
240
|
+
const physicalOrientationRef = useRef('portrait');
|
|
224
241
|
// Track dimensions for orientation detection and responsive layout
|
|
225
242
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
226
243
|
const isLandscape = windowWidth > windowHeight;
|
|
@@ -231,19 +248,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
231
248
|
// uses the accelerometer via expo-sensors (the callback is iOS-only). Either
|
|
232
249
|
// way we rotate the overlay UI to stay readable from the user's viewpoint.
|
|
233
250
|
const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
case 'landscapeLeft':
|
|
237
|
-
return 90;
|
|
238
|
-
case 'landscapeRight':
|
|
239
|
-
return -90;
|
|
240
|
-
case 'portraitUpsideDown':
|
|
241
|
-
return 180;
|
|
242
|
-
case 'portrait':
|
|
243
|
-
default:
|
|
244
|
-
return 0;
|
|
245
|
-
}
|
|
246
|
-
})();
|
|
251
|
+
physicalOrientationRef.current = physicalOrientation;
|
|
252
|
+
const overlayRotationDeg = getOverlayRotationDeg(physicalOrientation);
|
|
247
253
|
const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
|
|
248
254
|
policy,
|
|
249
255
|
status,
|
|
@@ -254,7 +260,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
254
260
|
camera_remount_count: cameraRemountCountRef.current,
|
|
255
261
|
terminated,
|
|
256
262
|
exhausted,
|
|
257
|
-
app_state:
|
|
263
|
+
app_state: appStateRef.current,
|
|
258
264
|
sdk_platform: Platform.OS,
|
|
259
265
|
device_model: getPlatformConstantString('Model', 'model'),
|
|
260
266
|
device_os_version: String(Platform.Version),
|
|
@@ -269,10 +275,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
269
275
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
270
276
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
271
277
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
278
|
+
android_native_orientation_subscription_active: 0,
|
|
279
|
+
android_native_orientation_event_count: 0,
|
|
280
|
+
android_native_orientation_change_count: 0,
|
|
281
|
+
android_native_orientation_source: 'expo_camera_internal_capture_orientation',
|
|
272
282
|
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
273
283
|
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
274
284
|
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
275
285
|
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
286
|
+
android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
287
|
+
android_accelerometer_started_at: androidOrientationStartedAtRef.current,
|
|
288
|
+
android_accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
289
|
+
android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
290
|
+
android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
291
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
292
|
+
pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
|
|
293
|
+
pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
|
|
276
294
|
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
277
295
|
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
278
296
|
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
@@ -281,6 +299,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
281
299
|
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
282
300
|
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
283
301
|
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
302
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
303
|
+
last_accelerometer_x: lastAndroidOrientationXRef.current,
|
|
304
|
+
last_accelerometer_y: lastAndroidOrientationYRef.current,
|
|
305
|
+
last_accelerometer_z: lastAndroidOrientationZRef.current,
|
|
306
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
307
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
308
|
+
last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
|
|
309
|
+
last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
310
|
+
last_accelerometer_error: lastAndroidOrientationErrorRef.current,
|
|
284
311
|
...extra,
|
|
285
312
|
}), [
|
|
286
313
|
cameraKey,
|
|
@@ -296,13 +323,33 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
296
323
|
windowHeight,
|
|
297
324
|
windowWidth,
|
|
298
325
|
]);
|
|
326
|
+
telemetryRef.current = telemetry;
|
|
327
|
+
buildScannerTelemetryMetadataRef.current = buildScannerTelemetryMetadata;
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
telemetryRef.current?.track('camera_scanner_mounted', {
|
|
330
|
+
component: 'scanner',
|
|
331
|
+
error: 'scanner_mounted',
|
|
332
|
+
metadata: buildScannerTelemetryMetadataRef.current?.({
|
|
333
|
+
scanner_telemetry_attached: telemetryRef.current ? 1 : 0,
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
return () => {
|
|
337
|
+
const activeTelemetry = telemetryRef.current;
|
|
338
|
+
activeTelemetry?.track('camera_scanner_disposed', {
|
|
339
|
+
component: 'scanner',
|
|
340
|
+
error: 'scanner_disposed',
|
|
341
|
+
metadata: buildScannerTelemetryMetadataRef.current?.(),
|
|
342
|
+
});
|
|
343
|
+
void activeTelemetry?.flush();
|
|
344
|
+
};
|
|
345
|
+
}, []);
|
|
299
346
|
useEffect(() => {
|
|
300
347
|
if (Platform.OS !== 'android')
|
|
301
348
|
return;
|
|
302
349
|
let subscription = null;
|
|
303
350
|
let cancelled = false;
|
|
304
351
|
let noEventTimer = null;
|
|
305
|
-
let lastOrientation =
|
|
352
|
+
let lastOrientation = physicalOrientationRef.current;
|
|
306
353
|
const androidMetadata = (extra = {}) => compactTelemetryMetadata({
|
|
307
354
|
policy,
|
|
308
355
|
sdk_platform: Platform.OS,
|
|
@@ -310,10 +357,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
310
357
|
device_os_version: String(Platform.Version),
|
|
311
358
|
route_name: telemetryContext?.routeName,
|
|
312
359
|
is_portrait_locked: telemetryContext?.isPortraitLocked,
|
|
360
|
+
android_native_orientation_subscription_active: 0,
|
|
361
|
+
android_native_orientation_event_count: 0,
|
|
362
|
+
android_native_orientation_change_count: 0,
|
|
363
|
+
android_native_orientation_source: 'expo_camera_internal_capture_orientation',
|
|
313
364
|
android_orientation_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
314
365
|
android_orientation_started_at: androidOrientationStartedAtRef.current,
|
|
315
366
|
android_orientation_event_count: androidOrientationEventCountRef.current,
|
|
316
367
|
android_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
368
|
+
android_accelerometer_subscription_active: androidOrientationSubscriptionActiveRef.current,
|
|
369
|
+
android_accelerometer_started_at: androidOrientationStartedAtRef.current,
|
|
370
|
+
android_accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
371
|
+
android_accelerometer_orientation_change_count: androidOrientationChangeCountRef.current,
|
|
372
|
+
android_accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
373
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
374
|
+
pending_physical_orientation: pendingAndroidPhysicalOrientationRef.current,
|
|
375
|
+
pending_physical_orientation_started_at: pendingAndroidPhysicalOrientationStartedAtRef.current,
|
|
317
376
|
last_android_orientation_event_at: lastAndroidOrientationEventAtRef.current,
|
|
318
377
|
last_android_orientation_x: lastAndroidOrientationXRef.current,
|
|
319
378
|
last_android_orientation_y: lastAndroidOrientationYRef.current,
|
|
@@ -322,6 +381,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
322
381
|
last_android_orientation_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
323
382
|
last_android_orientation_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
324
383
|
last_android_orientation_error: lastAndroidOrientationErrorRef.current,
|
|
384
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
385
|
+
last_accelerometer_x: lastAndroidOrientationXRef.current,
|
|
386
|
+
last_accelerometer_y: lastAndroidOrientationYRef.current,
|
|
387
|
+
last_accelerometer_z: lastAndroidOrientationZRef.current,
|
|
388
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
389
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
390
|
+
last_accelerometer_dominant_axis: lastAndroidOrientationDominantAxisRef.current,
|
|
391
|
+
last_accelerometer_error_at: lastAndroidOrientationErrorAtRef.current,
|
|
392
|
+
last_accelerometer_error: lastAndroidOrientationErrorRef.current,
|
|
325
393
|
...extra,
|
|
326
394
|
});
|
|
327
395
|
const trackAndroidOrientationEvent = (eventType, error, metadata = {}) => {
|
|
@@ -368,8 +436,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
368
436
|
androidOrientationChangeCountRef.current = 0;
|
|
369
437
|
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
370
438
|
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
439
|
+
accelerometer_start_reason: 'android_overlay_orientation_primary',
|
|
371
440
|
accelerometer_update_interval_ms: 500,
|
|
372
441
|
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
442
|
+
accelerometer_axis_dominance_threshold: ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD,
|
|
373
443
|
});
|
|
374
444
|
noEventTimer = setTimeout(() => {
|
|
375
445
|
if (cancelled || androidOrientationEventCountRef.current > 0)
|
|
@@ -380,54 +450,90 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
380
450
|
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
381
451
|
const at = new Date();
|
|
382
452
|
const atIso = at.toISOString();
|
|
453
|
+
const sample = classifyAndroidAccelerometerOrientation({ x, y, z });
|
|
383
454
|
androidOrientationEventCountRef.current++;
|
|
384
455
|
lastAndroidOrientationEventAtRef.current = atIso;
|
|
385
|
-
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
386
|
-
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
387
|
-
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
456
|
+
lastAndroidOrientationXRef.current = Number(sample.x.toFixed(4));
|
|
457
|
+
lastAndroidOrientationYRef.current = Number(sample.y.toFixed(4));
|
|
458
|
+
lastAndroidOrientationZRef.current = Number(sample.z.toFixed(4));
|
|
459
|
+
lastAndroidOrientationDerivedRef.current = sample.orientation;
|
|
460
|
+
lastAndroidOrientationIgnoredReasonRef.current = sample.ignoredReason;
|
|
461
|
+
lastAndroidOrientationDominantAxisRef.current = sample.dominantAxis;
|
|
388
462
|
if (noEventTimer) {
|
|
389
463
|
clearTimeout(noEventTimer);
|
|
390
464
|
noEventTimer = null;
|
|
391
465
|
}
|
|
392
|
-
|
|
393
|
-
let ignoredReason = null;
|
|
394
|
-
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
395
|
-
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
396
|
-
}
|
|
397
|
-
else if (Math.abs(y) > Math.abs(x) + 0.2) {
|
|
398
|
-
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
399
|
-
}
|
|
400
|
-
else {
|
|
401
|
-
ignoredReason = 'ambiguous_tilt';
|
|
402
|
-
}
|
|
403
|
-
lastAndroidOrientationDerivedRef.current = next;
|
|
404
|
-
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
466
|
+
const next = sample.orientation;
|
|
405
467
|
if (next && next !== lastOrientation) {
|
|
406
468
|
const previous = lastOrientation;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
469
|
+
if (pendingAndroidPhysicalOrientationRef.current === next &&
|
|
470
|
+
androidOrientationSettleTimerRef.current) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
pendingAndroidPhysicalOrientationRef.current = next;
|
|
474
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = atIso;
|
|
475
|
+
if (androidOrientationSettleTimerRef.current) {
|
|
476
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
477
|
+
}
|
|
478
|
+
androidOrientationSettleTimerRef.current = setTimeout(() => {
|
|
479
|
+
if (cancelled || pendingAndroidPhysicalOrientationRef.current !== next)
|
|
480
|
+
return;
|
|
481
|
+
const pendingStartedAt = pendingAndroidPhysicalOrientationStartedAtRef.current;
|
|
482
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
483
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
484
|
+
androidOrientationSettleTimerRef.current = null;
|
|
485
|
+
if (next === lastOrientation)
|
|
486
|
+
return;
|
|
487
|
+
lastOrientation = next;
|
|
488
|
+
androidOrientationChangeCountRef.current++;
|
|
489
|
+
setPhysicalOrientation(next);
|
|
490
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
491
|
+
android_physical_orientation_source: 'accelerometer_fallback',
|
|
492
|
+
previous_physical_orientation: previous,
|
|
493
|
+
next_physical_orientation: next,
|
|
494
|
+
accelerometer_sampled_at: atIso,
|
|
495
|
+
accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
|
|
496
|
+
accelerometer_dominant_axis: sample.dominantAxis,
|
|
497
|
+
android_physical_orientation_settle_duration_ms: ANDROID_ORIENTATION_SETTLE_MS,
|
|
498
|
+
android_physical_orientation_pending_started_at: pendingStartedAt,
|
|
499
|
+
});
|
|
500
|
+
}, ANDROID_ORIENTATION_SETTLE_MS);
|
|
415
501
|
return;
|
|
416
502
|
}
|
|
503
|
+
if (!next && androidOrientationSettleTimerRef.current) {
|
|
504
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
505
|
+
androidOrientationSettleTimerRef.current = null;
|
|
506
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
507
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
508
|
+
}
|
|
417
509
|
const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
|
|
418
510
|
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
419
511
|
if (shouldTrackSample) {
|
|
420
512
|
lastAndroidOrientationTelemetryAtRef.current = at.getTime();
|
|
421
|
-
trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, {
|
|
513
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, {
|
|
514
|
+
accelerometer_sampled_at: atIso,
|
|
515
|
+
accelerometer_xy_dominance: Number(sample.xyDominance.toFixed(4)),
|
|
516
|
+
accelerometer_dominant_axis: sample.dominantAxis,
|
|
517
|
+
});
|
|
422
518
|
}
|
|
423
519
|
});
|
|
424
520
|
})();
|
|
425
521
|
return () => {
|
|
426
522
|
cancelled = true;
|
|
523
|
+
const wasActive = subscription != null || androidOrientationSubscriptionActiveRef.current;
|
|
524
|
+
if (androidOrientationSettleTimerRef.current) {
|
|
525
|
+
clearTimeout(androidOrientationSettleTimerRef.current);
|
|
526
|
+
androidOrientationSettleTimerRef.current = null;
|
|
527
|
+
}
|
|
528
|
+
pendingAndroidPhysicalOrientationRef.current = null;
|
|
529
|
+
pendingAndroidPhysicalOrientationStartedAtRef.current = null;
|
|
427
530
|
androidOrientationSubscriptionActiveRef.current = false;
|
|
428
531
|
if (noEventTimer)
|
|
429
532
|
clearTimeout(noEventTimer);
|
|
430
533
|
subscription?.remove();
|
|
534
|
+
if (wasActive) {
|
|
535
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_stopped', 'android_accelerometer_tracking_stopped', { accelerometer_stop_reason: 'orientation_tracking_stopped' });
|
|
536
|
+
}
|
|
431
537
|
};
|
|
432
538
|
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
433
539
|
// Detect orientation changes and remount camera after rotation settles.
|
|
@@ -468,23 +574,31 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
468
574
|
if (terminated)
|
|
469
575
|
return;
|
|
470
576
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
476
|
-
// its preview layer after returning from the notification bar or control center.
|
|
477
|
-
telemetry?.track('camera_appstate_remount', {
|
|
478
|
-
component: 'scanner',
|
|
479
|
-
metadata: buildScannerTelemetryMetadata({
|
|
480
|
-
remount_reason: 'appstate_active',
|
|
481
|
-
remount_requested_at: at,
|
|
482
|
-
}),
|
|
483
|
-
});
|
|
577
|
+
const previousState = appStateRef.current;
|
|
578
|
+
appStateRef.current = nextState;
|
|
579
|
+
setCurrentAppState(nextState);
|
|
580
|
+
if (nextState !== 'active') {
|
|
484
581
|
setCameraReady(false);
|
|
485
582
|
cameraReadyRef.current = false;
|
|
486
|
-
|
|
583
|
+
return;
|
|
487
584
|
}
|
|
585
|
+
if (previousState === 'active')
|
|
586
|
+
return;
|
|
587
|
+
const at = new Date().toISOString();
|
|
588
|
+
lastAppStateRemountAtRef.current = at;
|
|
589
|
+
cameraRemountCountRef.current++;
|
|
590
|
+
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
591
|
+
// its preview layer after returning from the notification bar or control center.
|
|
592
|
+
telemetry?.track('camera_appstate_remount', {
|
|
593
|
+
component: 'scanner',
|
|
594
|
+
metadata: buildScannerTelemetryMetadata({
|
|
595
|
+
remount_reason: 'appstate_active',
|
|
596
|
+
remount_requested_at: at,
|
|
597
|
+
}),
|
|
598
|
+
});
|
|
599
|
+
setCameraReady(false);
|
|
600
|
+
cameraReadyRef.current = false;
|
|
601
|
+
setCameraKey((k) => k + 1);
|
|
488
602
|
});
|
|
489
603
|
return () => subscription.remove();
|
|
490
604
|
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
@@ -546,9 +660,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
546
660
|
cameraReadyRef.current = true;
|
|
547
661
|
cameraEverReadyRef.current = true;
|
|
548
662
|
cameraInitFailedRef.current = false;
|
|
663
|
+
startupWatchdogRemountCountRef.current = 0;
|
|
549
664
|
}, []);
|
|
550
665
|
const onMountError = useCallback((event) => {
|
|
551
666
|
const error = new Error(event.message || 'Camera mount error');
|
|
667
|
+
error.code = CAMERA_INIT_ERROR_CODE;
|
|
552
668
|
setResult(null);
|
|
553
669
|
setLastError(error);
|
|
554
670
|
setStatus('error');
|
|
@@ -561,6 +677,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
561
677
|
error,
|
|
562
678
|
metadata: buildScannerTelemetryMetadata({
|
|
563
679
|
mount_error_message: event.message,
|
|
680
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
681
|
+
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
564
682
|
}),
|
|
565
683
|
});
|
|
566
684
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
@@ -569,19 +687,29 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
569
687
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
570
688
|
// recreate the CameraView, which starts a fresh native session.
|
|
571
689
|
useEffect(() => {
|
|
572
|
-
if (!permission?.granted || terminated)
|
|
690
|
+
if (!permission?.granted || terminated || currentAppState !== 'active')
|
|
573
691
|
return;
|
|
574
692
|
const watchdogMs = Platform.OS === 'android'
|
|
575
693
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
576
694
|
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
577
695
|
const timer = setTimeout(() => {
|
|
696
|
+
if (appStateRef.current !== 'active')
|
|
697
|
+
return;
|
|
578
698
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
579
699
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
580
|
-
// triggered remount can legitimately take
|
|
700
|
+
// triggered remount can legitimately take several seconds without indicating a
|
|
581
701
|
// real failure, and firing the alert each time is noisy without
|
|
582
|
-
// surfacing new information.
|
|
583
|
-
//
|
|
702
|
+
// surfacing new information. First startup still gets a capped retry loop
|
|
703
|
+
// so a persistent native camera failure becomes visible to the user.
|
|
584
704
|
if (!cameraEverReadyRef.current) {
|
|
705
|
+
if (startupWatchdogRemountCountRef.current >= CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS) {
|
|
706
|
+
onMountError({
|
|
707
|
+
message: `Camera did not initialize after ${CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS} startup remount attempts`,
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const startupWatchdogRemountCount = startupWatchdogRemountCountRef.current + 1;
|
|
712
|
+
startupWatchdogRemountCountRef.current = startupWatchdogRemountCount;
|
|
585
713
|
cameraRemountCountRef.current++;
|
|
586
714
|
telemetry?.track('camera_preview_timeout', {
|
|
587
715
|
component: 'scanner',
|
|
@@ -589,6 +717,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
589
717
|
metadata: buildScannerTelemetryMetadata({
|
|
590
718
|
watchdog_ms: watchdogMs,
|
|
591
719
|
remount_reason: 'startup_watchdog',
|
|
720
|
+
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
721
|
+
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
592
722
|
}),
|
|
593
723
|
});
|
|
594
724
|
setCameraReady(false);
|
|
@@ -598,7 +728,15 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
598
728
|
}
|
|
599
729
|
}, watchdogMs);
|
|
600
730
|
return () => clearTimeout(timer);
|
|
601
|
-
}, [
|
|
731
|
+
}, [
|
|
732
|
+
permission?.granted,
|
|
733
|
+
terminated,
|
|
734
|
+
currentAppState,
|
|
735
|
+
cameraKey,
|
|
736
|
+
buildScannerTelemetryMetadata,
|
|
737
|
+
onMountError,
|
|
738
|
+
telemetry,
|
|
739
|
+
]);
|
|
602
740
|
// Track permission denied
|
|
603
741
|
useEffect(() => {
|
|
604
742
|
if (permission &&
|
|
@@ -672,6 +810,13 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
672
810
|
capture_physical_orientation: capturePhysicalOrientation,
|
|
673
811
|
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
674
812
|
capture_rotation_applied: 0,
|
|
813
|
+
capture_rotation_source: Platform.OS === 'android'
|
|
814
|
+
? 'expo_camera_native_orientation_event_listener'
|
|
815
|
+
: 'expo_camera_responsive_orientation',
|
|
816
|
+
accelerometer_event_count: androidOrientationEventCountRef.current,
|
|
817
|
+
last_accelerometer_event_at: lastAndroidOrientationEventAtRef.current,
|
|
818
|
+
last_accelerometer_derived_orientation: lastAndroidOrientationDerivedRef.current,
|
|
819
|
+
last_accelerometer_ignored_reason: lastAndroidOrientationIgnoredReasonRef.current,
|
|
675
820
|
}),
|
|
676
821
|
});
|
|
677
822
|
// --- Capture + best-effort resize ---
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ScannerPhysicalOrientation = 'portrait' | 'portraitUpsideDown' | 'landscapeLeft' | 'landscapeRight';
|
|
2
|
+
export type AndroidAccelerometerSample = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
z: number;
|
|
6
|
+
};
|
|
7
|
+
export type AndroidAccelerometerOrientationSample = AndroidAccelerometerSample & {
|
|
8
|
+
orientation: ScannerPhysicalOrientation | null;
|
|
9
|
+
ignoredReason: string | null;
|
|
10
|
+
dominantAxis: 'x' | 'y' | 'ambiguous';
|
|
11
|
+
xyDominance: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
|
|
14
|
+
export declare function classifyAndroidAccelerometerOrientation(sample: AndroidAccelerometerSample, axisDominanceThreshold?: number): AndroidAccelerometerOrientationSample;
|
|
15
|
+
export declare function getOverlayRotationDeg(orientation: ScannerPhysicalOrientation): number;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD = 0.2;
|
|
2
|
+
export function classifyAndroidAccelerometerOrientation(sample, axisDominanceThreshold = ANDROID_ACCELEROMETER_AXIS_DOMINANCE_THRESHOLD) {
|
|
3
|
+
const { x, y, z } = sample;
|
|
4
|
+
const xAbs = Math.abs(x);
|
|
5
|
+
const yAbs = Math.abs(y);
|
|
6
|
+
let orientation = null;
|
|
7
|
+
let ignoredReason = null;
|
|
8
|
+
let dominantAxis = 'ambiguous';
|
|
9
|
+
if (xAbs > yAbs + axisDominanceThreshold) {
|
|
10
|
+
dominantAxis = 'x';
|
|
11
|
+
orientation = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
12
|
+
}
|
|
13
|
+
else if (yAbs > xAbs + axisDominanceThreshold) {
|
|
14
|
+
dominantAxis = 'y';
|
|
15
|
+
orientation = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
ignoredReason = 'ambiguous_xy';
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
x,
|
|
22
|
+
y,
|
|
23
|
+
z,
|
|
24
|
+
orientation,
|
|
25
|
+
ignoredReason,
|
|
26
|
+
dominantAxis,
|
|
27
|
+
xyDominance: Math.abs(xAbs - yAbs),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function getOverlayRotationDeg(orientation) {
|
|
31
|
+
switch (orientation) {
|
|
32
|
+
case 'landscapeLeft':
|
|
33
|
+
return 90;
|
|
34
|
+
case 'landscapeRight':
|
|
35
|
+
return -90;
|
|
36
|
+
case 'portraitUpsideDown':
|
|
37
|
+
return 180;
|
|
38
|
+
case 'portrait':
|
|
39
|
+
default:
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/lib/hooks/useVerifyAI.js
CHANGED
|
@@ -3,6 +3,7 @@ import { AppState, Platform } from 'react-native';
|
|
|
3
3
|
import { VerifyAIClient, VerifyAIRequestError } from '../client';
|
|
4
4
|
import { OfflineQueue } from '../storage/offlineQueue';
|
|
5
5
|
import { TelemetryContext } from '../telemetry/TelemetryContext';
|
|
6
|
+
import { SDK_VERSION } from '../version';
|
|
6
7
|
function isQueueableError(error) {
|
|
7
8
|
if (error instanceof VerifyAIRequestError) {
|
|
8
9
|
return error.isRetryable;
|
|
@@ -86,7 +87,7 @@ export function useVerifyAI(config) {
|
|
|
86
87
|
inferenceEngineRef.current = new InferenceEngine();
|
|
87
88
|
}
|
|
88
89
|
catch (err) {
|
|
89
|
-
console.warn(
|
|
90
|
+
console.warn(`[VerifyAI v${SDK_VERSION}] Failed to load ML modules:`, err);
|
|
90
91
|
}
|
|
91
92
|
})();
|
|
92
93
|
return () => {
|
package/lib/ml/modelManager.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Buffer } from 'buffer';
|
|
6
6
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
7
|
+
import { SDK_VERSION } from '../version';
|
|
7
8
|
async function loadFileSystem() {
|
|
8
9
|
try {
|
|
9
10
|
return await import('expo-file-system');
|
|
@@ -68,7 +69,7 @@ export class ModelManager {
|
|
|
68
69
|
async downloadArtifacts(manifest, policyId) {
|
|
69
70
|
const fileSystem = await loadFileSystem();
|
|
70
71
|
if (!fileSystem || !fileSystem.documentDirectory) {
|
|
71
|
-
console.warn(
|
|
72
|
+
console.warn(`[VerifyAI v${SDK_VERSION}] expo-file-system not available, skipping download`);
|
|
72
73
|
return;
|
|
73
74
|
}
|
|
74
75
|
const cacheDir = `${fileSystem.documentDirectory}verify-ai/bundles/${policyId}/`;
|
|
@@ -14,6 +14,17 @@ const CRITICAL_EVENTS = new Set([
|
|
|
14
14
|
'camera_init_failure',
|
|
15
15
|
'camera_preview_timeout',
|
|
16
16
|
'camera_permission_denied',
|
|
17
|
+
'camera_scanner_mounted',
|
|
18
|
+
'camera_scanner_disposed',
|
|
19
|
+
'camera_android_native_orientation_started',
|
|
20
|
+
'camera_android_native_orientation_no_events',
|
|
21
|
+
'camera_android_native_orientation_error',
|
|
22
|
+
'camera_android_native_orientation_start_failure',
|
|
23
|
+
'camera_android_native_orientation_done',
|
|
24
|
+
'camera_android_accelerometer_started',
|
|
25
|
+
'camera_android_accelerometer_no_events',
|
|
26
|
+
'camera_android_accelerometer_error',
|
|
27
|
+
'camera_android_accelerometer_start_failure',
|
|
17
28
|
]);
|
|
18
29
|
export class TelemetryReporter {
|
|
19
30
|
constructor(apiKey, baseUrl) {
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.4.
|
|
1
|
+
export declare const SDK_VERSION = "2.4.21";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.21';
|
package/package.json
CHANGED