@switchlabs/verify-ai-react-native 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/lib/client/index.d.ts +22 -2
- package/lib/client/index.js +132 -19
- package/lib/components/VerifyAIScanner.d.ts +7 -4
- package/lib/components/VerifyAIScanner.js +235 -18
- package/lib/hooks/useVerifyAI.d.ts +32 -10
- package/lib/hooks/useVerifyAI.js +246 -14
- package/lib/index.d.ts +5 -2
- package/lib/index.js +3 -0
- package/lib/ml/featureExtractor.d.ts +16 -0
- package/lib/ml/featureExtractor.js +123 -0
- package/lib/ml/imagePreprocessor.d.ts +2 -0
- package/lib/ml/imagePreprocessor.js +48 -0
- package/lib/ml/index.d.ts +5 -0
- package/lib/ml/index.js +4 -0
- package/lib/ml/inferenceEngine.d.ts +24 -0
- package/lib/ml/inferenceEngine.js +156 -0
- package/lib/ml/modelManager.d.ts +26 -0
- package/lib/ml/modelManager.js +207 -0
- package/lib/ml/policyEngine.d.ts +14 -0
- package/lib/ml/policyEngine.js +161 -0
- package/lib/ml/types.d.ts +84 -0
- package/lib/ml/types.js +4 -0
- package/lib/storage/offlineQueue.js +1 -1
- package/lib/telemetry/TelemetryContext.d.ts +4 -0
- package/lib/telemetry/TelemetryContext.js +5 -0
- package/lib/telemetry/TelemetryReporter.d.ts +24 -0
- package/lib/telemetry/TelemetryReporter.js +141 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/version.d.ts +1 -0
- package/lib/version.js +1 -0
- package/package.json +23 -2
- package/src/client/index.ts +176 -25
- package/src/components/VerifyAIScanner.tsx +282 -21
- package/src/hooks/useVerifyAI.ts +332 -18
- package/src/index.ts +20 -1
- package/src/ml/featureExtractor.ts +160 -0
- package/src/ml/imagePreprocessor.ts +72 -0
- package/src/ml/index.ts +14 -0
- package/src/ml/inferenceEngine.ts +200 -0
- package/src/ml/modelManager.ts +265 -0
- package/src/ml/policyEngine.ts +201 -0
- package/src/ml/types.ts +104 -0
- package/src/storage/offlineQueue.ts +1 -1
- package/src/telemetry/TelemetryContext.tsx +8 -0
- package/src/telemetry/TelemetryReporter.ts +184 -0
- package/src/types/index.ts +20 -0
- package/src/version.ts +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState, useCallback } from 'react';
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -10,18 +10,26 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
CameraView,
|
|
12
12
|
useCameraPermissions,
|
|
13
|
-
type CameraCapturedPicture,
|
|
14
13
|
} from 'expo-camera';
|
|
15
14
|
import type {
|
|
16
15
|
VerificationResult,
|
|
17
16
|
ScannerStatus,
|
|
18
17
|
ScannerOverlayConfig,
|
|
19
18
|
} from '../types';
|
|
19
|
+
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
20
|
+
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
21
|
+
|
|
22
|
+
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
23
|
+
const FALLBACK_QUALITY = 0.5;
|
|
24
|
+
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
25
|
+
const MANIPULATOR_QUALITY = 0.7;
|
|
26
|
+
/** Max dimension (px) on longest side when resize is available. */
|
|
27
|
+
const MAX_DIMENSION = 2048;
|
|
20
28
|
|
|
21
29
|
export interface VerifyAIScannerProps {
|
|
22
|
-
/** Called with
|
|
23
|
-
onCapture: (
|
|
24
|
-
/** Called when verification
|
|
30
|
+
/** Called with base64 image data when the user captures a photo. */
|
|
31
|
+
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
32
|
+
/** Called when a terminal verification result is reached. */
|
|
25
33
|
onResult?: (result: VerificationResult) => void;
|
|
26
34
|
/** Called when an error occurs. */
|
|
27
35
|
onError?: (error: Error) => void;
|
|
@@ -35,6 +43,64 @@ export interface VerifyAIScannerProps {
|
|
|
35
43
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
36
44
|
/** Whether to enable the camera torch/flashlight. */
|
|
37
45
|
enableTorch?: boolean;
|
|
46
|
+
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
47
|
+
telemetry?: TelemetryReporter | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ErrorWithDetails = Error & {
|
|
51
|
+
status?: number;
|
|
52
|
+
requestId?: string;
|
|
53
|
+
code?: string;
|
|
54
|
+
body?: {
|
|
55
|
+
request_id?: string;
|
|
56
|
+
code?: string;
|
|
57
|
+
status?: number;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
62
|
+
if (!error) {
|
|
63
|
+
return {
|
|
64
|
+
title: 'Something went wrong',
|
|
65
|
+
message: "We couldn't process your photo. Please try again.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const status = error.status ?? error.body?.status;
|
|
70
|
+
const code = error.code ?? error.body?.code;
|
|
71
|
+
const requestId = error.requestId ?? error.body?.request_id;
|
|
72
|
+
|
|
73
|
+
let message = error.message?.trim() || "We couldn't process your photo. Please try again.";
|
|
74
|
+
|
|
75
|
+
if (code === 'timeout' || status === 408 || error.name === 'AbortError') {
|
|
76
|
+
message = 'Verification timed out. Please try again.';
|
|
77
|
+
} else if (code === 'network_error' || status === 0) {
|
|
78
|
+
message = 'Network request failed. Check your connection and try again.';
|
|
79
|
+
} else if (status === 401) {
|
|
80
|
+
message = 'Verification is not configured correctly.';
|
|
81
|
+
} else if (status === 413) {
|
|
82
|
+
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
83
|
+
} else if (status === 429) {
|
|
84
|
+
message = 'Verification is temporarily unavailable. Please try again.';
|
|
85
|
+
} else if (status !== undefined && status >= 500) {
|
|
86
|
+
message = 'Verify AI is unavailable right now. Please try again.';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (showTechnicalDetails) {
|
|
90
|
+
const details = [
|
|
91
|
+
status != null ? `status ${status}` : null,
|
|
92
|
+
requestId ? `request ${requestId}` : null,
|
|
93
|
+
].filter(Boolean).join(' · ');
|
|
94
|
+
|
|
95
|
+
if (details) {
|
|
96
|
+
message = `${message}\n\n${details}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
title: 'Verification failed',
|
|
102
|
+
message,
|
|
103
|
+
};
|
|
38
104
|
}
|
|
39
105
|
|
|
40
106
|
/**
|
|
@@ -67,33 +133,204 @@ export function VerifyAIScanner({
|
|
|
67
133
|
showCaptureButton = true,
|
|
68
134
|
captureRef,
|
|
69
135
|
enableTorch,
|
|
136
|
+
telemetry: telemetryProp,
|
|
70
137
|
}: VerifyAIScannerProps) {
|
|
138
|
+
const contextTelemetry = useTelemetry();
|
|
139
|
+
const telemetry = telemetryProp ?? contextTelemetry;
|
|
140
|
+
|
|
71
141
|
const cameraRef = useRef<CameraView>(null);
|
|
72
142
|
const [status, setStatus] = useState<ScannerStatus>('idle');
|
|
73
143
|
const [result, setResult] = useState<VerificationResult | null>(null);
|
|
144
|
+
const [lastError, setLastError] = useState<ErrorWithDetails | null>(null);
|
|
74
145
|
const [permission, requestPermission] = useCameraPermissions();
|
|
75
146
|
const attemptCountRef = useRef(0);
|
|
76
147
|
const [exhausted, setExhausted] = useState(false);
|
|
148
|
+
const [terminated, setTerminated] = useState(false);
|
|
149
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
150
|
+
const cameraReadyRef = useRef(false);
|
|
151
|
+
const cameraInitFailedRef = useRef(false);
|
|
152
|
+
const permissionDeniedTrackedRef = useRef(false);
|
|
153
|
+
|
|
154
|
+
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
155
|
+
const releaseCamera = useCallback(() => {
|
|
156
|
+
setTerminated(true);
|
|
157
|
+
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
return () => {
|
|
162
|
+
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
163
|
+
};
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
// Camera init callbacks
|
|
167
|
+
const onCameraReady = useCallback(() => {
|
|
168
|
+
setCameraReady(true);
|
|
169
|
+
cameraReadyRef.current = true;
|
|
170
|
+
cameraInitFailedRef.current = false;
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const onMountError = useCallback((event: { message?: string }) => {
|
|
174
|
+
const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
|
|
175
|
+
setResult(null);
|
|
176
|
+
setLastError(error);
|
|
177
|
+
setStatus('error');
|
|
178
|
+
setCameraReady(false);
|
|
179
|
+
cameraReadyRef.current = false;
|
|
180
|
+
cameraInitFailedRef.current = true;
|
|
181
|
+
onError?.(error);
|
|
182
|
+
telemetry?.track('camera_init_failure', {
|
|
183
|
+
component: 'scanner',
|
|
184
|
+
error,
|
|
185
|
+
});
|
|
186
|
+
}, [onError, telemetry]);
|
|
187
|
+
|
|
188
|
+
// Startup watchdog — if camera hasn't fired onCameraReady within 5s, report timeout
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!permission?.granted || terminated) return;
|
|
191
|
+
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
194
|
+
telemetry?.track('camera_preview_timeout', {
|
|
195
|
+
component: 'scanner',
|
|
196
|
+
error: 'Camera did not initialize within 5 seconds',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}, 5000);
|
|
200
|
+
|
|
201
|
+
return () => clearTimeout(timer);
|
|
202
|
+
}, [permission?.granted, terminated, telemetry]);
|
|
203
|
+
|
|
204
|
+
// Track permission denied
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (
|
|
207
|
+
permission &&
|
|
208
|
+
!permission.granted &&
|
|
209
|
+
permission.canAskAgain === false &&
|
|
210
|
+
!permissionDeniedTrackedRef.current
|
|
211
|
+
) {
|
|
212
|
+
permissionDeniedTrackedRef.current = true;
|
|
213
|
+
telemetry?.track('camera_permission_denied', { component: 'scanner' });
|
|
214
|
+
}
|
|
215
|
+
}, [permission, telemetry]);
|
|
77
216
|
|
|
78
217
|
const handleCapture = useCallback(async () => {
|
|
79
218
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
219
|
+
if (!cameraReadyRef.current) {
|
|
220
|
+
const error = new Error('Camera is not ready yet. Please wait a moment and try again.') as ErrorWithDetails;
|
|
221
|
+
setResult(null);
|
|
222
|
+
setLastError(error);
|
|
223
|
+
setStatus('error');
|
|
224
|
+
onError?.(error);
|
|
225
|
+
telemetry?.track('camera_not_ready', {
|
|
226
|
+
component: 'scanner',
|
|
227
|
+
error,
|
|
228
|
+
});
|
|
229
|
+
setTimeout(() => setStatus('idle'), 2000);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
80
232
|
|
|
81
233
|
setStatus('capturing');
|
|
82
234
|
setResult(null);
|
|
235
|
+
setLastError(null);
|
|
83
236
|
|
|
84
237
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
238
|
+
// --- Capture + best-effort resize ---
|
|
239
|
+
// Strategy: try to dynamically import expo-image-manipulator.
|
|
240
|
+
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
241
|
+
// If not available → use expo-camera's built-in base64 at lower quality.
|
|
242
|
+
// This keeps expo-image-manipulator as an *optional* dependency.
|
|
243
|
+
let base64: string;
|
|
244
|
+
let origWidth = 0;
|
|
245
|
+
let origHeight = 0;
|
|
246
|
+
let processedWidth = 0;
|
|
247
|
+
let processedHeight = 0;
|
|
248
|
+
let didResize = false;
|
|
90
249
|
|
|
91
|
-
|
|
92
|
-
|
|
250
|
+
let ImageManipulator: typeof import('expo-image-manipulator') | null = null;
|
|
251
|
+
try {
|
|
252
|
+
ImageManipulator = await import('expo-image-manipulator');
|
|
253
|
+
} catch {
|
|
254
|
+
// Not installed — fall back to camera-only base64 below
|
|
93
255
|
}
|
|
94
256
|
|
|
257
|
+
if (ImageManipulator) {
|
|
258
|
+
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
259
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
260
|
+
quality: 0.8,
|
|
261
|
+
exif: false,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!photo?.uri) {
|
|
265
|
+
throw new Error('Failed to capture photo');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
origWidth = photo.width ?? 0;
|
|
269
|
+
origHeight = photo.height ?? 0;
|
|
270
|
+
|
|
271
|
+
const actions: Array<{ resize: { width?: number; height?: number } }> = [];
|
|
272
|
+
if (origWidth > MAX_DIMENSION || origHeight > MAX_DIMENSION) {
|
|
273
|
+
if (origWidth >= origHeight) {
|
|
274
|
+
actions.push({ resize: { width: MAX_DIMENSION } });
|
|
275
|
+
} else {
|
|
276
|
+
actions.push({ resize: { height: MAX_DIMENSION } });
|
|
277
|
+
}
|
|
278
|
+
didResize = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const normalized = await ImageManipulator.manipulateAsync(
|
|
282
|
+
photo.uri,
|
|
283
|
+
actions,
|
|
284
|
+
{
|
|
285
|
+
compress: MANIPULATOR_QUALITY,
|
|
286
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
287
|
+
base64: true,
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!normalized.base64) {
|
|
292
|
+
throw new Error('ImageManipulator did not return base64');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
base64 = normalized.base64;
|
|
296
|
+
processedWidth = normalized.width;
|
|
297
|
+
processedHeight = normalized.height;
|
|
298
|
+
} else {
|
|
299
|
+
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
300
|
+
// No resize is possible without ImageManipulator, but the lower quality
|
|
301
|
+
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
302
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
303
|
+
base64: true,
|
|
304
|
+
quality: FALLBACK_QUALITY,
|
|
305
|
+
exif: false,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
if (!photo?.base64) {
|
|
309
|
+
throw new Error('Failed to capture photo');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
origWidth = photo.width ?? 0;
|
|
313
|
+
origHeight = photo.height ?? 0;
|
|
314
|
+
processedWidth = origWidth;
|
|
315
|
+
processedHeight = origHeight;
|
|
316
|
+
base64 = photo.base64;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Best-effort telemetry — never blocks capture
|
|
320
|
+
telemetry?.track('image_processed', {
|
|
321
|
+
component: 'scanner',
|
|
322
|
+
metadata: {
|
|
323
|
+
original_width: origWidth,
|
|
324
|
+
original_height: origHeight,
|
|
325
|
+
processed_width: processedWidth,
|
|
326
|
+
processed_height: processedHeight,
|
|
327
|
+
resized: didResize ? 1 : 0,
|
|
328
|
+
has_manipulator: ImageManipulator ? 1 : 0,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
95
332
|
setStatus('processing');
|
|
96
|
-
const verificationResult = await onCapture(
|
|
333
|
+
const verificationResult = await onCapture(base64);
|
|
97
334
|
|
|
98
335
|
attemptCountRef.current++;
|
|
99
336
|
|
|
@@ -106,6 +343,7 @@ export function VerifyAIScanner({
|
|
|
106
343
|
maxAttempts != null &&
|
|
107
344
|
attemptCountRef.current >= maxAttempts) {
|
|
108
345
|
setExhausted(true);
|
|
346
|
+
releaseCamera();
|
|
109
347
|
if (autoApprove) {
|
|
110
348
|
const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
|
|
111
349
|
setResult(approvedResult);
|
|
@@ -114,26 +352,48 @@ export function VerifyAIScanner({
|
|
|
114
352
|
} else {
|
|
115
353
|
setResult(verificationResult);
|
|
116
354
|
setStatus('error');
|
|
355
|
+
onResult?.(verificationResult);
|
|
117
356
|
}
|
|
118
357
|
return;
|
|
119
358
|
}
|
|
120
359
|
|
|
360
|
+
if (!verificationResult.is_compliant &&
|
|
361
|
+
maxAttempts != null &&
|
|
362
|
+
attemptCountRef.current < maxAttempts) {
|
|
363
|
+
setResult(verificationResult);
|
|
364
|
+
setStatus('error');
|
|
365
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
releaseCamera();
|
|
121
370
|
setResult(verificationResult);
|
|
122
371
|
setStatus('success');
|
|
123
372
|
onResult?.(verificationResult);
|
|
124
|
-
setTimeout(() => setStatus('idle'), 3000);
|
|
125
373
|
} else {
|
|
126
374
|
// null result means queued for offline
|
|
127
375
|
setStatus('idle');
|
|
128
376
|
}
|
|
129
377
|
} catch (err) {
|
|
130
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
378
|
+
const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
|
|
379
|
+
setLastError(error);
|
|
131
380
|
setStatus('error');
|
|
132
381
|
onError?.(error);
|
|
382
|
+
|
|
383
|
+
// Track the error
|
|
384
|
+
const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
|
|
385
|
+
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
386
|
+
telemetry?.track(
|
|
387
|
+
isCaptureFail ? 'capture_failure'
|
|
388
|
+
: isImageFail ? 'image_manipulation_failure'
|
|
389
|
+
: 'unknown_error',
|
|
390
|
+
{ component: 'scanner', error },
|
|
391
|
+
);
|
|
392
|
+
|
|
133
393
|
// Reset after a brief pause
|
|
134
394
|
setTimeout(() => setStatus('idle'), 2000);
|
|
135
395
|
}
|
|
136
|
-
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
|
|
396
|
+
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
|
|
137
397
|
|
|
138
398
|
// Expose capture to parent via ref
|
|
139
399
|
if (captureRef) {
|
|
@@ -159,7 +419,7 @@ export function VerifyAIScanner({
|
|
|
159
419
|
|
|
160
420
|
return (
|
|
161
421
|
<View style={[styles.container, style]}>
|
|
162
|
-
<CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={enableTorch}>
|
|
422
|
+
<CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={!terminated && enableTorch} onCameraReady={onCameraReady} onMountError={onMountError}>
|
|
163
423
|
{/* Overlay */}
|
|
164
424
|
<View style={styles.overlay}>
|
|
165
425
|
{overlay?.title && (
|
|
@@ -249,8 +509,9 @@ export function VerifyAIScanner({
|
|
|
249
509
|
const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
|
|
250
510
|
errorMessage = template.replace('{remaining}', String(remaining));
|
|
251
511
|
} else {
|
|
252
|
-
|
|
253
|
-
|
|
512
|
+
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
513
|
+
errorTitle = display.title;
|
|
514
|
+
errorMessage = display.message;
|
|
254
515
|
}
|
|
255
516
|
|
|
256
517
|
return (
|
|
@@ -280,11 +541,11 @@ export function VerifyAIScanner({
|
|
|
280
541
|
<TouchableOpacity
|
|
281
542
|
style={[
|
|
282
543
|
styles.captureButton,
|
|
283
|
-
(status === 'capturing' || status === 'processing') &&
|
|
544
|
+
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
284
545
|
styles.captureButtonDisabled,
|
|
285
546
|
]}
|
|
286
547
|
onPress={handleCapture}
|
|
287
|
-
disabled={status === 'capturing' || status === 'processing'}
|
|
548
|
+
disabled={!cameraReady || status === 'capturing' || status === 'processing'}
|
|
288
549
|
activeOpacity={0.7}
|
|
289
550
|
>
|
|
290
551
|
<View style={styles.captureButtonInner} />
|