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