@switchlabs/verify-ai-react-native 2.4.18 → 2.4.20
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/components/VerifyAIScanner.js +205 -60
- package/lib/components/scannerOrientation.d.ts +15 -0
- package/lib/components/scannerOrientation.js +42 -0
- 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/components/VerifyAIScanner.tsx +228 -64
- package/src/components/scannerOrientation.ts +66 -0
- 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.
|
|
@@ -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
|
+
}
|
|
@@ -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.20";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.20';
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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.20';
|