@switchlabs/verify-ai-react-native 2.4.14 → 2.4.16
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 +371 -44
- 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 +432 -44
- package/src/index.ts +1 -0
- package/src/types/index.ts +13 -0
- package/src/version.ts +1 -1
|
@@ -16,6 +16,11 @@ const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
|
16
16
|
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
17
17
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
18
18
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
19
|
+
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
20
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 3000;
|
|
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;
|
|
19
24
|
function getPolicyScannerDefaults(policy) {
|
|
20
25
|
const id = policy?.toLowerCase() ?? '';
|
|
21
26
|
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
@@ -59,14 +64,43 @@ function createScannerError(message, code, name) {
|
|
|
59
64
|
error.code = code;
|
|
60
65
|
return error;
|
|
61
66
|
}
|
|
67
|
+
function sleep(ms) {
|
|
68
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
function isNativeCameraCaptureError(error) {
|
|
71
|
+
const message = error.message.toLowerCase();
|
|
72
|
+
return (error.code === CAMERA_CAPTURE_ERROR_CODE ||
|
|
73
|
+
error.code === 'ERR_IMAGE_CAPTURE_FAILED' ||
|
|
74
|
+
message === 'failed to capture photo' ||
|
|
75
|
+
message === 'failed to capture image' ||
|
|
76
|
+
message === 'image could not be captured');
|
|
77
|
+
}
|
|
62
78
|
function normalizeScannerError(err) {
|
|
63
79
|
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
64
|
-
if (!error.code && error
|
|
80
|
+
if (!error.code && isNativeCameraCaptureError(error)) {
|
|
65
81
|
error.name = 'CameraCaptureError';
|
|
66
82
|
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
67
83
|
}
|
|
68
84
|
return error;
|
|
69
85
|
}
|
|
86
|
+
function compactTelemetryMetadata(metadata) {
|
|
87
|
+
const compacted = {};
|
|
88
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
89
|
+
if (value === null || value === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
compacted[key] = typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
92
|
+
}
|
|
93
|
+
return compacted;
|
|
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
|
+
}
|
|
70
104
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
71
105
|
if (!error) {
|
|
72
106
|
return {
|
|
@@ -148,7 +182,7 @@ function isTerminalRequestError(error) {
|
|
|
148
182
|
* />
|
|
149
183
|
* ```
|
|
150
184
|
*/
|
|
151
|
-
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, }) {
|
|
152
186
|
const contextTelemetry = useTelemetry();
|
|
153
187
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
154
188
|
const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
|
|
@@ -169,6 +203,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
169
203
|
const cameraEverReadyRef = useRef(false);
|
|
170
204
|
const cameraInitFailedRef = useRef(false);
|
|
171
205
|
const permissionDeniedTrackedRef = useRef(false);
|
|
206
|
+
const lastCameraReadyAtRef = useRef(null);
|
|
207
|
+
const lastAppStateRemountAtRef = useRef(null);
|
|
208
|
+
const lastOrientationRemountAtRef = useRef(null);
|
|
209
|
+
const lastCaptureRetryAtRef = useRef(null);
|
|
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);
|
|
172
224
|
// Track dimensions for orientation detection and responsive layout
|
|
173
225
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
174
226
|
const isLandscape = windowWidth > windowHeight;
|
|
@@ -179,12 +231,106 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
179
231
|
// uses the accelerometer via expo-sensors (the callback is iOS-only). Either
|
|
180
232
|
// way we rotate the overlay UI to stay readable from the user's viewpoint.
|
|
181
233
|
const [physicalOrientation, setPhysicalOrientation] = useState('portrait');
|
|
234
|
+
const overlayRotationDeg = (() => {
|
|
235
|
+
switch (physicalOrientation) {
|
|
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
|
+
})();
|
|
247
|
+
const buildScannerTelemetryMetadata = useCallback((extra = {}) => compactTelemetryMetadata({
|
|
248
|
+
policy,
|
|
249
|
+
status,
|
|
250
|
+
camera_ready: cameraReadyRef.current,
|
|
251
|
+
camera_ever_ready: cameraEverReadyRef.current,
|
|
252
|
+
camera_init_failed: cameraInitFailedRef.current,
|
|
253
|
+
camera_key: cameraKey,
|
|
254
|
+
camera_remount_count: cameraRemountCountRef.current,
|
|
255
|
+
terminated,
|
|
256
|
+
exhausted,
|
|
257
|
+
app_state: AppState.currentState,
|
|
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,
|
|
263
|
+
window_width: windowWidth,
|
|
264
|
+
window_height: windowHeight,
|
|
265
|
+
interface_orientation: isLandscape ? 'landscape' : 'portrait',
|
|
266
|
+
physical_orientation: physicalOrientation,
|
|
267
|
+
overlay_rotation_deg: overlayRotationDeg,
|
|
268
|
+
last_camera_ready_at: lastCameraReadyAtRef.current,
|
|
269
|
+
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
270
|
+
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
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,
|
|
284
|
+
...extra,
|
|
285
|
+
}), [
|
|
286
|
+
cameraKey,
|
|
287
|
+
exhausted,
|
|
288
|
+
isLandscape,
|
|
289
|
+
overlayRotationDeg,
|
|
290
|
+
physicalOrientation,
|
|
291
|
+
policy,
|
|
292
|
+
status,
|
|
293
|
+
telemetryContext?.isPortraitLocked,
|
|
294
|
+
telemetryContext?.routeName,
|
|
295
|
+
terminated,
|
|
296
|
+
windowHeight,
|
|
297
|
+
windowWidth,
|
|
298
|
+
]);
|
|
182
299
|
useEffect(() => {
|
|
183
300
|
if (Platform.OS !== 'android')
|
|
184
301
|
return;
|
|
185
302
|
let subscription = null;
|
|
186
303
|
let cancelled = false;
|
|
304
|
+
let noEventTimer = null;
|
|
187
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
|
+
};
|
|
188
334
|
(async () => {
|
|
189
335
|
let Accelerometer = null;
|
|
190
336
|
try {
|
|
@@ -192,14 +338,49 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
192
338
|
const mod = require('expo-sensors');
|
|
193
339
|
Accelerometer = mod?.Accelerometer ?? null;
|
|
194
340
|
}
|
|
195
|
-
catch {
|
|
341
|
+
catch (err) {
|
|
342
|
+
const error = normalizeScannerError(err);
|
|
343
|
+
lastAndroidOrientationErrorAtRef.current = new Date().toISOString();
|
|
344
|
+
lastAndroidOrientationErrorRef.current = error.message;
|
|
345
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', error);
|
|
196
346
|
return;
|
|
197
347
|
}
|
|
198
|
-
if (cancelled
|
|
348
|
+
if (cancelled)
|
|
349
|
+
return;
|
|
350
|
+
if (!Accelerometer) {
|
|
351
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_start_failure', 'expo-sensors Accelerometer is unavailable');
|
|
199
352
|
return;
|
|
353
|
+
}
|
|
354
|
+
const startedAt = new Date().toISOString();
|
|
355
|
+
androidOrientationStartedAtRef.current = startedAt;
|
|
356
|
+
androidOrientationSubscriptionActiveRef.current = true;
|
|
357
|
+
androidOrientationEventCountRef.current = 0;
|
|
358
|
+
androidOrientationChangeCountRef.current = 0;
|
|
359
|
+
lastAndroidOrientationTelemetryAtRef.current = 0;
|
|
360
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_started', 'android_accelerometer_orientation_tracking_started', {
|
|
361
|
+
accelerometer_update_interval_ms: 500,
|
|
362
|
+
accelerometer_no_event_timeout_ms: ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS,
|
|
363
|
+
});
|
|
364
|
+
noEventTimer = setTimeout(() => {
|
|
365
|
+
if (cancelled || androidOrientationEventCountRef.current > 0)
|
|
366
|
+
return;
|
|
367
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_no_events', `No Android accelerometer events within ${ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS}ms`);
|
|
368
|
+
}, ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS);
|
|
200
369
|
Accelerometer.setUpdateInterval(500);
|
|
201
|
-
subscription = Accelerometer.addListener(({ x, y }) => {
|
|
202
|
-
|
|
370
|
+
subscription = Accelerometer.addListener(({ x, y, z }) => {
|
|
371
|
+
const at = new Date();
|
|
372
|
+
const atIso = at.toISOString();
|
|
373
|
+
androidOrientationEventCountRef.current++;
|
|
374
|
+
lastAndroidOrientationEventAtRef.current = atIso;
|
|
375
|
+
lastAndroidOrientationXRef.current = Number(x.toFixed(4));
|
|
376
|
+
lastAndroidOrientationYRef.current = Number(y.toFixed(4));
|
|
377
|
+
lastAndroidOrientationZRef.current = Number(z.toFixed(4));
|
|
378
|
+
if (noEventTimer) {
|
|
379
|
+
clearTimeout(noEventTimer);
|
|
380
|
+
noEventTimer = null;
|
|
381
|
+
}
|
|
382
|
+
let next = null;
|
|
383
|
+
let ignoredReason = null;
|
|
203
384
|
if (Math.abs(x) > Math.abs(y) + 0.2) {
|
|
204
385
|
next = x > 0 ? 'landscapeRight' : 'landscapeLeft';
|
|
205
386
|
}
|
|
@@ -207,32 +388,38 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
207
388
|
next = y > 0 ? 'portraitUpsideDown' : 'portrait';
|
|
208
389
|
}
|
|
209
390
|
else {
|
|
210
|
-
|
|
391
|
+
ignoredReason = 'ambiguous_tilt';
|
|
211
392
|
}
|
|
212
|
-
|
|
393
|
+
lastAndroidOrientationDerivedRef.current = next;
|
|
394
|
+
lastAndroidOrientationIgnoredReasonRef.current = ignoredReason;
|
|
395
|
+
if (next && next !== lastOrientation) {
|
|
396
|
+
const previous = lastOrientation;
|
|
213
397
|
lastOrientation = next;
|
|
398
|
+
androidOrientationChangeCountRef.current++;
|
|
214
399
|
setPhysicalOrientation(next);
|
|
400
|
+
trackAndroidOrientationEvent('camera_android_physical_orientation_changed', `${previous} -> ${next}`, {
|
|
401
|
+
previous_physical_orientation: previous,
|
|
402
|
+
next_physical_orientation: next,
|
|
403
|
+
accelerometer_sampled_at: atIso,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const shouldTrackSample = androidOrientationEventCountRef.current === 1 ||
|
|
408
|
+
at.getTime() - lastAndroidOrientationTelemetryAtRef.current >= ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS;
|
|
409
|
+
if (shouldTrackSample) {
|
|
410
|
+
lastAndroidOrientationTelemetryAtRef.current = at.getTime();
|
|
411
|
+
trackAndroidOrientationEvent('camera_android_accelerometer_sample', `accelerometer_sample_${androidOrientationEventCountRef.current}`, { accelerometer_sampled_at: atIso });
|
|
215
412
|
}
|
|
216
413
|
});
|
|
217
414
|
})();
|
|
218
415
|
return () => {
|
|
219
416
|
cancelled = true;
|
|
417
|
+
androidOrientationSubscriptionActiveRef.current = false;
|
|
418
|
+
if (noEventTimer)
|
|
419
|
+
clearTimeout(noEventTimer);
|
|
220
420
|
subscription?.remove();
|
|
221
421
|
};
|
|
222
|
-
}, []);
|
|
223
|
-
const overlayRotationDeg = (() => {
|
|
224
|
-
switch (physicalOrientation) {
|
|
225
|
-
case 'landscapeLeft':
|
|
226
|
-
return 90;
|
|
227
|
-
case 'landscapeRight':
|
|
228
|
-
return -90;
|
|
229
|
-
case 'portraitUpsideDown':
|
|
230
|
-
return 180;
|
|
231
|
-
case 'portrait':
|
|
232
|
-
default:
|
|
233
|
-
return 0;
|
|
234
|
-
}
|
|
235
|
-
})();
|
|
422
|
+
}, [policy, telemetry, telemetryContext?.isPortraitLocked, telemetryContext?.routeName]);
|
|
236
423
|
// Detect orientation changes and remount camera after rotation settles.
|
|
237
424
|
// On iOS, AVCaptureVideoPreviewLayer distorts if remounted during the rotation
|
|
238
425
|
// animation — the native preview layer initializes with transitional bounds.
|
|
@@ -242,9 +429,16 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
242
429
|
const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
|
|
243
430
|
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
244
431
|
if (orientationChanged && !terminated) {
|
|
432
|
+
const at = new Date().toISOString();
|
|
433
|
+
lastOrientationRemountAtRef.current = at;
|
|
434
|
+
cameraRemountCountRef.current++;
|
|
245
435
|
telemetry?.track('camera_orientation_remount', {
|
|
246
436
|
component: 'scanner',
|
|
247
437
|
metadata: {
|
|
438
|
+
...buildScannerTelemetryMetadata({
|
|
439
|
+
remount_reason: 'orientation_change',
|
|
440
|
+
remount_requested_at: at,
|
|
441
|
+
}),
|
|
248
442
|
from: prev.width > prev.height ? 'landscape' : 'portrait',
|
|
249
443
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
250
444
|
},
|
|
@@ -258,17 +452,24 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
258
452
|
}, 400);
|
|
259
453
|
return () => clearTimeout(timer);
|
|
260
454
|
}
|
|
261
|
-
}, [windowWidth, windowHeight, terminated]);
|
|
455
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
262
456
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
263
457
|
useEffect(() => {
|
|
264
458
|
if (terminated)
|
|
265
459
|
return;
|
|
266
460
|
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
267
461
|
if (nextState === 'active') {
|
|
462
|
+
const at = new Date().toISOString();
|
|
463
|
+
lastAppStateRemountAtRef.current = at;
|
|
464
|
+
cameraRemountCountRef.current++;
|
|
268
465
|
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
269
466
|
// its preview layer after returning from the notification bar or control center.
|
|
270
467
|
telemetry?.track('camera_appstate_remount', {
|
|
271
468
|
component: 'scanner',
|
|
469
|
+
metadata: buildScannerTelemetryMetadata({
|
|
470
|
+
remount_reason: 'appstate_active',
|
|
471
|
+
remount_requested_at: at,
|
|
472
|
+
}),
|
|
272
473
|
});
|
|
273
474
|
setCameraReady(false);
|
|
274
475
|
cameraReadyRef.current = false;
|
|
@@ -276,7 +477,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
276
477
|
}
|
|
277
478
|
});
|
|
278
479
|
return () => subscription.remove();
|
|
279
|
-
}, [terminated]);
|
|
480
|
+
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
280
481
|
const pausePreview = useCallback(() => {
|
|
281
482
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
282
483
|
}, []);
|
|
@@ -330,6 +531,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
330
531
|
}, [onClose, result]);
|
|
331
532
|
// Camera init callbacks
|
|
332
533
|
const onCameraReady = useCallback(() => {
|
|
534
|
+
lastCameraReadyAtRef.current = new Date().toISOString();
|
|
333
535
|
setCameraReady(true);
|
|
334
536
|
cameraReadyRef.current = true;
|
|
335
537
|
cameraEverReadyRef.current = true;
|
|
@@ -347,15 +549,21 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
347
549
|
telemetry?.track('camera_init_failure', {
|
|
348
550
|
component: 'scanner',
|
|
349
551
|
error,
|
|
552
|
+
metadata: buildScannerTelemetryMetadata({
|
|
553
|
+
mount_error_message: event.message,
|
|
554
|
+
}),
|
|
350
555
|
});
|
|
351
|
-
}, [onError, telemetry]);
|
|
352
|
-
// Startup watchdog — if camera hasn't fired onCameraReady
|
|
556
|
+
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
557
|
+
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
353
558
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
354
559
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
355
560
|
// recreate the CameraView, which starts a fresh native session.
|
|
356
561
|
useEffect(() => {
|
|
357
562
|
if (!permission?.granted || terminated)
|
|
358
563
|
return;
|
|
564
|
+
const watchdogMs = Platform.OS === 'android'
|
|
565
|
+
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
566
|
+
: IOS_CAMERA_STARTUP_WATCHDOG_MS;
|
|
359
567
|
const timer = setTimeout(() => {
|
|
360
568
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
361
569
|
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
@@ -364,18 +572,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
364
572
|
// surfacing new information. If the camera truly stays broken the
|
|
365
573
|
// user will see capture failures, which is a more meaningful signal.
|
|
366
574
|
if (!cameraEverReadyRef.current) {
|
|
575
|
+
cameraRemountCountRef.current++;
|
|
367
576
|
telemetry?.track('camera_preview_timeout', {
|
|
368
577
|
component: 'scanner',
|
|
369
|
-
error:
|
|
578
|
+
error: `Camera did not initialize within ${Math.round(watchdogMs / 1000)} seconds — remounting`,
|
|
579
|
+
metadata: buildScannerTelemetryMetadata({
|
|
580
|
+
watchdog_ms: watchdogMs,
|
|
581
|
+
remount_reason: 'startup_watchdog',
|
|
582
|
+
}),
|
|
370
583
|
});
|
|
371
584
|
setCameraReady(false);
|
|
372
585
|
cameraReadyRef.current = false;
|
|
373
586
|
setCameraKey((k) => k + 1);
|
|
374
587
|
}
|
|
375
588
|
}
|
|
376
|
-
},
|
|
589
|
+
}, watchdogMs);
|
|
377
590
|
return () => clearTimeout(timer);
|
|
378
|
-
}, [permission?.granted, terminated, cameraKey, telemetry]);
|
|
591
|
+
}, [permission?.granted, terminated, cameraKey, buildScannerTelemetryMetadata, telemetry]);
|
|
379
592
|
// Track permission denied
|
|
380
593
|
useEffect(() => {
|
|
381
594
|
if (permission &&
|
|
@@ -383,13 +596,36 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
383
596
|
permission.canAskAgain === false &&
|
|
384
597
|
!permissionDeniedTrackedRef.current) {
|
|
385
598
|
permissionDeniedTrackedRef.current = true;
|
|
386
|
-
telemetry?.track('camera_permission_denied', {
|
|
599
|
+
telemetry?.track('camera_permission_denied', {
|
|
600
|
+
component: 'scanner',
|
|
601
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
602
|
+
});
|
|
387
603
|
}
|
|
388
|
-
}, [permission, telemetry]);
|
|
604
|
+
}, [buildScannerTelemetryMetadata, permission, telemetry]);
|
|
389
605
|
const handleCapture = useCallback(async () => {
|
|
390
|
-
|
|
606
|
+
const blockedReason = !cameraRef.current
|
|
607
|
+
? 'camera_ref_null'
|
|
608
|
+
: !cameraReadyRef.current
|
|
609
|
+
? 'camera_not_ready'
|
|
610
|
+
: status === 'capturing'
|
|
611
|
+
? 'already_capturing'
|
|
612
|
+
: status === 'processing'
|
|
613
|
+
? 'already_processing'
|
|
614
|
+
: terminated
|
|
615
|
+
? 'terminated'
|
|
616
|
+
: exhausted
|
|
617
|
+
? 'exhausted'
|
|
618
|
+
: null;
|
|
619
|
+
telemetry?.track('camera_capture_request', {
|
|
620
|
+
component: 'scanner',
|
|
621
|
+
error: blockedReason == null ? 'capture_requested' : 'capture_ignored',
|
|
622
|
+
metadata: buildScannerTelemetryMetadata({
|
|
623
|
+
capture_blocked_reason: blockedReason,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
if (blockedReason && blockedReason !== 'camera_not_ready')
|
|
391
627
|
return;
|
|
392
|
-
if (
|
|
628
|
+
if (blockedReason === 'camera_not_ready') {
|
|
393
629
|
const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
|
|
394
630
|
setResult(null);
|
|
395
631
|
setLastError(error);
|
|
@@ -398,6 +634,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
398
634
|
telemetry?.track('camera_not_ready', {
|
|
399
635
|
component: 'scanner',
|
|
400
636
|
error,
|
|
637
|
+
metadata: buildScannerTelemetryMetadata(),
|
|
401
638
|
});
|
|
402
639
|
setTimeout(() => setStatus('idle'), 2000);
|
|
403
640
|
return;
|
|
@@ -411,7 +648,22 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
411
648
|
clearTimeout(terminalResultTimerRef.current);
|
|
412
649
|
terminalResultTimerRef.current = null;
|
|
413
650
|
}
|
|
651
|
+
let nativeCaptureAttempts = 0;
|
|
652
|
+
let captureRetryAttempted = false;
|
|
653
|
+
let captureRetryReady = false;
|
|
654
|
+
let lastNativeCaptureErrorMessage = null;
|
|
414
655
|
try {
|
|
656
|
+
const capturePhysicalOrientation = physicalOrientation;
|
|
657
|
+
const captureOverlayRotationDeg = overlayRotationDeg;
|
|
658
|
+
telemetry?.track('camera_capture_orientation_context', {
|
|
659
|
+
component: 'scanner',
|
|
660
|
+
error: 'capture_orientation_context',
|
|
661
|
+
metadata: buildScannerTelemetryMetadata({
|
|
662
|
+
capture_physical_orientation: capturePhysicalOrientation,
|
|
663
|
+
capture_overlay_rotation_deg: captureOverlayRotationDeg,
|
|
664
|
+
capture_rotation_applied: 0,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
415
667
|
// --- Capture + best-effort resize ---
|
|
416
668
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
417
669
|
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
@@ -430,12 +682,69 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
430
682
|
catch {
|
|
431
683
|
// Not installed — fall back to camera-only base64 below
|
|
432
684
|
}
|
|
685
|
+
const waitForCameraReady = async (timeoutMs) => {
|
|
686
|
+
const start = Date.now();
|
|
687
|
+
while (Date.now() - start < timeoutMs) {
|
|
688
|
+
if (cameraReadyRef.current && cameraRef.current) {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
await sleep(100);
|
|
692
|
+
}
|
|
693
|
+
return cameraReadyRef.current && !!cameraRef.current;
|
|
694
|
+
};
|
|
695
|
+
const takePictureWithRetry = async (options, requiredField) => {
|
|
696
|
+
let lastError = null;
|
|
697
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
698
|
+
nativeCaptureAttempts = attempt;
|
|
699
|
+
try {
|
|
700
|
+
const photo = await cameraRef.current?.takePictureAsync(options);
|
|
701
|
+
if (!photo?.[requiredField]) {
|
|
702
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
703
|
+
}
|
|
704
|
+
return photo;
|
|
705
|
+
}
|
|
706
|
+
catch (captureErr) {
|
|
707
|
+
const normalized = normalizeScannerError(captureErr);
|
|
708
|
+
lastError = normalized;
|
|
709
|
+
lastNativeCaptureErrorMessage = normalized.message;
|
|
710
|
+
if (attempt === 1 && isNativeCameraCaptureError(normalized) && !terminated) {
|
|
711
|
+
captureRetryAttempted = true;
|
|
712
|
+
const retryAt = new Date().toISOString();
|
|
713
|
+
lastCaptureRetryAtRef.current = retryAt;
|
|
714
|
+
cameraRemountCountRef.current++;
|
|
715
|
+
setCameraReady(false);
|
|
716
|
+
cameraReadyRef.current = false;
|
|
717
|
+
setCameraKey((key) => key + 1);
|
|
718
|
+
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
719
|
+
captureRetryReady = await waitForCameraReady(2500);
|
|
720
|
+
telemetry?.track('camera_capture_retry', {
|
|
721
|
+
component: 'scanner',
|
|
722
|
+
error: normalized,
|
|
723
|
+
errorCode: normalized.code,
|
|
724
|
+
metadata: buildScannerTelemetryMetadata({
|
|
725
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
726
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
727
|
+
capture_retry_ready: captureRetryReady,
|
|
728
|
+
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
729
|
+
capture_retry_ready_timeout_ms: 2500,
|
|
730
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
731
|
+
}),
|
|
732
|
+
});
|
|
733
|
+
if (captureRetryReady) {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
throw normalized;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
throw lastError ?? createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
741
|
+
};
|
|
433
742
|
if (ImageManipulator) {
|
|
434
743
|
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
435
|
-
const photo = await
|
|
744
|
+
const photo = await takePictureWithRetry({
|
|
436
745
|
quality: 0.8,
|
|
437
746
|
exif: false,
|
|
438
|
-
});
|
|
747
|
+
}, 'uri');
|
|
439
748
|
if (!photo?.uri) {
|
|
440
749
|
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
441
750
|
}
|
|
@@ -467,11 +776,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
467
776
|
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
468
777
|
// No resize is possible without ImageManipulator, but the lower quality
|
|
469
778
|
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
470
|
-
const photo = await
|
|
779
|
+
const photo = await takePictureWithRetry({
|
|
471
780
|
base64: true,
|
|
472
781
|
quality: FALLBACK_QUALITY,
|
|
473
782
|
exif: false,
|
|
474
|
-
});
|
|
783
|
+
}, 'base64');
|
|
475
784
|
if (!photo?.base64) {
|
|
476
785
|
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
477
786
|
}
|
|
@@ -484,14 +793,19 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
484
793
|
// Best-effort telemetry — never blocks capture
|
|
485
794
|
telemetry?.track('image_processed', {
|
|
486
795
|
component: 'scanner',
|
|
487
|
-
metadata: {
|
|
796
|
+
metadata: buildScannerTelemetryMetadata({
|
|
488
797
|
original_width: origWidth,
|
|
489
798
|
original_height: origHeight,
|
|
490
799
|
processed_width: processedWidth,
|
|
491
800
|
processed_height: processedHeight,
|
|
492
801
|
resized: didResize ? 1 : 0,
|
|
493
802
|
has_manipulator: ImageManipulator ? 1 : 0,
|
|
494
|
-
|
|
803
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
804
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
805
|
+
capture_retry_ready: captureRetryReady,
|
|
806
|
+
recovered_native_capture_failure: captureRetryAttempted ? 1 : 0,
|
|
807
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
808
|
+
}),
|
|
495
809
|
});
|
|
496
810
|
setStatus('processing');
|
|
497
811
|
const verificationResult = await onCapture(base64);
|
|
@@ -553,13 +867,23 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
553
867
|
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
554
868
|
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
555
869
|
: isImageFail ? 'image_manipulation_failure'
|
|
556
|
-
: 'unknown_error', {
|
|
870
|
+
: 'unknown_error', {
|
|
871
|
+
component: 'scanner',
|
|
872
|
+
error,
|
|
873
|
+
metadata: buildScannerTelemetryMetadata({
|
|
874
|
+
native_capture_attempts: nativeCaptureAttempts,
|
|
875
|
+
capture_retry_attempted: captureRetryAttempted,
|
|
876
|
+
capture_retry_ready: captureRetryReady,
|
|
877
|
+
is_native_camera_capture_error: isNativeCameraCaptureError(error),
|
|
878
|
+
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
879
|
+
}),
|
|
880
|
+
});
|
|
557
881
|
}
|
|
558
882
|
if (!terminalRequestError) {
|
|
559
883
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
560
884
|
}
|
|
561
885
|
}
|
|
562
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
886
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
563
887
|
// Expose capture to parent via ref
|
|
564
888
|
if (captureRef) {
|
|
565
889
|
captureRef.current = handleCapture;
|
|
@@ -575,6 +899,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
575
899
|
setPhysicalOrientation(event.orientation);
|
|
576
900
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
577
901
|
styles.titleText,
|
|
902
|
+
overlay.theme?.titleStyle,
|
|
578
903
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
579
904
|
], children: overlay.title }) })), overlay?.showGuideFrame && (_jsxs(View, { style: [styles.guideContainer, isLandscape && styles.guideContainerLandscape], children: [_jsxs(View, { style: [
|
|
580
905
|
styles.guideFrame,
|
|
@@ -584,8 +909,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
584
909
|
: undefined,
|
|
585
910
|
], 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: [
|
|
586
911
|
styles.guideCaptionText,
|
|
912
|
+
overlay.theme?.feedbackStyle,
|
|
587
913
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
588
|
-
], 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: [
|
|
914
|
+
], 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: [
|
|
589
915
|
styles.resultIconCircle,
|
|
590
916
|
exhausted
|
|
591
917
|
? styles.resultIconExhausted
|
|
@@ -603,7 +929,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
603
929
|
? (overlay?.exhaustedMessage || 'Submitted for review')
|
|
604
930
|
: result.is_compliant
|
|
605
931
|
? (overlay?.successMessage || 'Verified')
|
|
606
|
-
: (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' && (() => {
|
|
932
|
+
: (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' && (() => {
|
|
607
933
|
let errorTitle;
|
|
608
934
|
let errorMessage;
|
|
609
935
|
let showCloseAction = false;
|
|
@@ -630,9 +956,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
630
956
|
errorMessage = display.message;
|
|
631
957
|
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
632
958
|
}
|
|
633
|
-
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" }) }))] }));
|
|
959
|
+
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" }) }))] }));
|
|
634
960
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
|
|
635
961
|
styles.instructionsText,
|
|
962
|
+
overlay.theme?.feedbackStyle,
|
|
636
963
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
637
964
|
], children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
|
|
638
965
|
styles.captureButton,
|