@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
package/README.md
CHANGED
|
@@ -7,15 +7,19 @@ React Native SDK for Verify AI photo verification.
|
|
|
7
7
|
Expo-managed apps:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
10
|
+
npx expo install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
React Native CLI:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npm install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
|
|
16
|
+
npm install @switchlabs/verify-ai-react-native expo-camera expo-image-manipulator @react-native-async-storage/async-storage
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
If you want on-device inference, also install `expo-file-system`,
|
|
20
|
+
`react-native-fast-tflite`, and configure `react-native-fast-tflite` for the
|
|
21
|
+
delegates you plan to use.
|
|
22
|
+
|
|
19
23
|
## Quick Start
|
|
20
24
|
|
|
21
25
|
```tsx
|
|
@@ -44,16 +48,25 @@ function ParkingScreen() {
|
|
|
44
48
|
import { useVerifyAI, VerifyAIScanner } from '@switchlabs/verify-ai-react-native';
|
|
45
49
|
|
|
46
50
|
function ScannerScreen() {
|
|
47
|
-
const {
|
|
51
|
+
const { verifyMultipart } = useVerifyAI({
|
|
52
|
+
apiKey: 'vai_your_api_key',
|
|
53
|
+
enableOnDeviceML: true,
|
|
54
|
+
});
|
|
48
55
|
return (
|
|
49
56
|
<VerifyAIScanner
|
|
50
|
-
onCapture={(
|
|
57
|
+
onCapture={(imageUri) =>
|
|
58
|
+
verifyMultipart({ imageUri, policy: 'scooter_parking' })
|
|
59
|
+
}
|
|
51
60
|
onResult={(result) => console.log(result.is_compliant ? 'PASS' : 'FAIL')}
|
|
52
61
|
/>
|
|
53
62
|
);
|
|
54
63
|
}
|
|
55
64
|
```
|
|
56
65
|
|
|
66
|
+
`verifyMultipart()` is the recommended path for live camera captures. The
|
|
67
|
+
built-in offline queue currently replays base64 `verify()` requests only; raw
|
|
68
|
+
multipart uploads are not persisted automatically.
|
|
69
|
+
|
|
57
70
|
## Offline Mode
|
|
58
71
|
|
|
59
72
|
Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
|
package/lib/client/index.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError, PolicyConfigResponse } from '../types';
|
|
1
|
+
import type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError, VerifyOptions, PolicyConfigResponse } from '../types';
|
|
2
|
+
import { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
2
3
|
export declare class VerifyAIClient {
|
|
3
4
|
private apiKey;
|
|
4
5
|
private baseUrl;
|
|
5
6
|
private timeout;
|
|
7
|
+
readonly telemetry: TelemetryReporter | null;
|
|
6
8
|
constructor(config: VerifyAIConfig);
|
|
9
|
+
private parseResponseBody;
|
|
10
|
+
private buildRequestError;
|
|
11
|
+
private normalizeRequestError;
|
|
12
|
+
private executeRequest;
|
|
7
13
|
private request;
|
|
8
14
|
/**
|
|
9
15
|
* Submit a photo for AI verification.
|
|
@@ -27,7 +33,19 @@ export declare class VerifyAIClient {
|
|
|
27
33
|
* }
|
|
28
34
|
* ```
|
|
29
35
|
*/
|
|
30
|
-
verify(request: VerificationRequest): Promise<VerificationResult>;
|
|
36
|
+
verify(request: VerificationRequest, options?: VerifyOptions): Promise<VerificationResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Submit a photo for AI verification using multipart/form-data.
|
|
39
|
+
* Streams the image directly from disk — avoids base64 encoding overhead.
|
|
40
|
+
*
|
|
41
|
+
* **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
|
|
42
|
+
* an image URI instead of a base64 string.
|
|
43
|
+
*
|
|
44
|
+
* @param request - Multipart request with file URI and policy
|
|
45
|
+
* @param options - Optional verify options (e.g. idempotency key)
|
|
46
|
+
* @returns The verification result with compliance status and feedback
|
|
47
|
+
*/
|
|
48
|
+
verifyMultipart(request: MultipartVerificationRequest, options?: VerifyOptions): Promise<VerificationResult>;
|
|
31
49
|
/**
|
|
32
50
|
* List past verifications with optional filters.
|
|
33
51
|
*
|
|
@@ -84,4 +102,6 @@ export declare class VerifyAIRequestError extends Error {
|
|
|
84
102
|
get isServerError(): boolean;
|
|
85
103
|
get isRetryable(): boolean;
|
|
86
104
|
get upgradeUrl(): string | undefined;
|
|
105
|
+
get requestId(): string | undefined;
|
|
106
|
+
get code(): string | undefined;
|
|
87
107
|
}
|
package/lib/client/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
1
2
|
const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
|
|
2
3
|
const DEFAULT_TIMEOUT = 30000;
|
|
3
4
|
export class VerifyAIClient {
|
|
@@ -8,12 +9,54 @@ export class VerifyAIClient {
|
|
|
8
9
|
this.apiKey = config.apiKey;
|
|
9
10
|
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
10
11
|
this.timeout = config.timeout || DEFAULT_TIMEOUT;
|
|
12
|
+
this.telemetry = config.telemetry !== false
|
|
13
|
+
? new TelemetryReporter(this.apiKey, this.baseUrl)
|
|
14
|
+
: null;
|
|
11
15
|
}
|
|
12
|
-
|
|
16
|
+
parseResponseBody(rawBody) {
|
|
17
|
+
if (!rawBody) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(rawBody);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { error: rawBody };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
buildRequestError(message, status, context, body = {}) {
|
|
28
|
+
const error = {
|
|
29
|
+
error: message,
|
|
30
|
+
status,
|
|
31
|
+
code: status === 408 ? 'timeout' : status === 0 ? 'network_error' : 'request_error',
|
|
32
|
+
path: context.path,
|
|
33
|
+
url: context.url,
|
|
34
|
+
method: context.method,
|
|
35
|
+
...body,
|
|
36
|
+
};
|
|
37
|
+
return new VerifyAIRequestError(message, status, error);
|
|
38
|
+
}
|
|
39
|
+
normalizeRequestError(error, context) {
|
|
40
|
+
if (error instanceof VerifyAIRequestError) {
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
44
|
+
return this.buildRequestError('Verification request timed out', 408, context, { code: 'timeout' });
|
|
45
|
+
}
|
|
46
|
+
if (error instanceof TypeError) {
|
|
47
|
+
return this.buildRequestError('Network request failed', 0, context, { code: 'network_error' });
|
|
48
|
+
}
|
|
49
|
+
const message = error instanceof Error ? error.message : 'VerifyAI request failed';
|
|
50
|
+
return this.buildRequestError(message, 0, context);
|
|
51
|
+
}
|
|
52
|
+
async executeRequest(path, options = {}) {
|
|
13
53
|
const controller = new AbortController();
|
|
14
54
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
55
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
56
|
+
const url = `${this.baseUrl}${path}`;
|
|
57
|
+
const context = { path, url, method };
|
|
15
58
|
try {
|
|
16
|
-
const response = await fetch(
|
|
59
|
+
const response = await fetch(url, {
|
|
17
60
|
...options,
|
|
18
61
|
signal: controller.signal,
|
|
19
62
|
headers: {
|
|
@@ -22,35 +65,56 @@ export class VerifyAIClient {
|
|
|
22
65
|
},
|
|
23
66
|
});
|
|
24
67
|
const rawBody = await response.text();
|
|
25
|
-
|
|
26
|
-
if (rawBody) {
|
|
27
|
-
try {
|
|
28
|
-
body = JSON.parse(rawBody);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
body = { error: rawBody };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
68
|
+
const body = this.parseResponseBody(rawBody);
|
|
34
69
|
if (!response.ok) {
|
|
35
70
|
const errorBody = (body && typeof body === 'object' ? body : null);
|
|
36
|
-
|
|
37
|
-
error: errorBody?.error || `Request failed with status ${response.status}`,
|
|
38
|
-
status: response.status,
|
|
71
|
+
throw this.buildRequestError(errorBody?.error || `Request failed with status ${response.status}`, response.status, context, {
|
|
39
72
|
current_usage: errorBody?.current_usage,
|
|
40
73
|
limit: errorBody?.limit,
|
|
41
74
|
upgrade_url: errorBody?.upgrade_url,
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
request_id: errorBody?.request_id || response.headers.get('X-Request-Id') || undefined,
|
|
76
|
+
code: errorBody?.code || 'http_error',
|
|
77
|
+
});
|
|
44
78
|
}
|
|
45
79
|
if (!body || typeof body !== 'object') {
|
|
46
|
-
throw
|
|
80
|
+
throw this.buildRequestError('VerifyAI: Invalid response payload', 0, context, {
|
|
81
|
+
code: 'invalid_response',
|
|
82
|
+
request_id: response.headers.get('X-Request-Id') || undefined,
|
|
83
|
+
});
|
|
47
84
|
}
|
|
48
85
|
return body;
|
|
49
86
|
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const normalized = this.normalizeRequestError(error, context);
|
|
89
|
+
// Track network/timeout/server errors
|
|
90
|
+
if (this.telemetry) {
|
|
91
|
+
const errorCode = normalized.code || (normalized.status >= 500 ? 'server_error' : undefined);
|
|
92
|
+
const eventType = normalized.status === 408 || normalized.code === 'timeout'
|
|
93
|
+
? 'request_timeout'
|
|
94
|
+
: normalized.status === 0 || normalized.code === 'network_error'
|
|
95
|
+
? 'network_error'
|
|
96
|
+
: normalized.status === 401 || normalized.status === 403
|
|
97
|
+
? 'auth_error'
|
|
98
|
+
: normalized.status === 429
|
|
99
|
+
? 'rate_limited'
|
|
100
|
+
: normalized.status >= 400 && normalized.status < 500
|
|
101
|
+
? 'request_error'
|
|
102
|
+
: 'server_error';
|
|
103
|
+
this.telemetry.track(eventType, {
|
|
104
|
+
component: 'client',
|
|
105
|
+
error: normalized,
|
|
106
|
+
errorCode,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
throw normalized;
|
|
110
|
+
}
|
|
50
111
|
finally {
|
|
51
112
|
clearTimeout(timer);
|
|
52
113
|
}
|
|
53
114
|
}
|
|
115
|
+
async request(path, options = {}) {
|
|
116
|
+
return this.executeRequest(path, options);
|
|
117
|
+
}
|
|
54
118
|
/**
|
|
55
119
|
* Submit a photo for AI verification.
|
|
56
120
|
*
|
|
@@ -73,13 +137,56 @@ export class VerifyAIClient {
|
|
|
73
137
|
* }
|
|
74
138
|
* ```
|
|
75
139
|
*/
|
|
76
|
-
async verify(request) {
|
|
140
|
+
async verify(request, options) {
|
|
141
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
142
|
+
if (options?.idempotencyKey) {
|
|
143
|
+
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
144
|
+
}
|
|
77
145
|
return this.request('/verify', {
|
|
78
146
|
method: 'POST',
|
|
79
|
-
headers
|
|
147
|
+
headers,
|
|
80
148
|
body: JSON.stringify(request),
|
|
81
149
|
});
|
|
82
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Submit a photo for AI verification using multipart/form-data.
|
|
153
|
+
* Streams the image directly from disk — avoids base64 encoding overhead.
|
|
154
|
+
*
|
|
155
|
+
* **Breaking change:** `onCapture` prop on `VerifyAIScanner` now receives
|
|
156
|
+
* an image URI instead of a base64 string.
|
|
157
|
+
*
|
|
158
|
+
* @param request - Multipart request with file URI and policy
|
|
159
|
+
* @param options - Optional verify options (e.g. idempotency key)
|
|
160
|
+
* @returns The verification result with compliance status and feedback
|
|
161
|
+
*/
|
|
162
|
+
async verifyMultipart(request, options) {
|
|
163
|
+
const formData = new FormData();
|
|
164
|
+
// React Native FormData accepts { uri, type, name } objects for file fields
|
|
165
|
+
formData.append('image', {
|
|
166
|
+
uri: request.imageUri,
|
|
167
|
+
type: 'image/jpeg',
|
|
168
|
+
name: 'photo.jpg',
|
|
169
|
+
});
|
|
170
|
+
formData.append('policy', request.policy);
|
|
171
|
+
if (request.metadata) {
|
|
172
|
+
formData.append('metadata', JSON.stringify(request.metadata));
|
|
173
|
+
}
|
|
174
|
+
if (request.provider) {
|
|
175
|
+
formData.append('provider', request.provider);
|
|
176
|
+
}
|
|
177
|
+
const headers = {
|
|
178
|
+
'X-API-Key': this.apiKey,
|
|
179
|
+
// Do NOT set Content-Type — fetch auto-sets multipart boundary
|
|
180
|
+
};
|
|
181
|
+
if (options?.idempotencyKey) {
|
|
182
|
+
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
183
|
+
}
|
|
184
|
+
return this.executeRequest('/verify', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers,
|
|
187
|
+
body: formData,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
83
190
|
/**
|
|
84
191
|
* List past verifications with optional filters.
|
|
85
192
|
*
|
|
@@ -171,4 +278,10 @@ export class VerifyAIRequestError extends Error {
|
|
|
171
278
|
get upgradeUrl() {
|
|
172
279
|
return this.body.upgrade_url;
|
|
173
280
|
}
|
|
281
|
+
get requestId() {
|
|
282
|
+
return this.body.request_id;
|
|
283
|
+
}
|
|
284
|
+
get code() {
|
|
285
|
+
return this.body.code;
|
|
286
|
+
}
|
|
174
287
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type ViewStyle } from 'react-native';
|
|
3
3
|
import type { VerificationResult, ScannerOverlayConfig } from '../types';
|
|
4
|
+
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
4
5
|
export interface VerifyAIScannerProps {
|
|
5
|
-
/** Called with
|
|
6
|
-
onCapture: (
|
|
7
|
-
/** Called when verification
|
|
6
|
+
/** Called with base64 image data when the user captures a photo. */
|
|
7
|
+
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
8
|
+
/** Called when a terminal verification result is reached. */
|
|
8
9
|
onResult?: (result: VerificationResult) => void;
|
|
9
10
|
/** Called when an error occurs. */
|
|
10
11
|
onError?: (error: Error) => void;
|
|
@@ -18,6 +19,8 @@ export interface VerifyAIScannerProps {
|
|
|
18
19
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
19
20
|
/** Whether to enable the camera torch/flashlight. */
|
|
20
21
|
enableTorch?: boolean;
|
|
22
|
+
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
23
|
+
telemetry?: TelemetryReporter | null;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Camera scanner component for capturing verification photos.
|
|
@@ -40,4 +43,4 @@ export interface VerifyAIScannerProps {
|
|
|
40
43
|
* />
|
|
41
44
|
* ```
|
|
42
45
|
*/
|
|
43
|
-
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,7 +1,57 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
3
3
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 'react-native';
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
|
+
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
6
|
+
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
7
|
+
const FALLBACK_QUALITY = 0.5;
|
|
8
|
+
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
9
|
+
const MANIPULATOR_QUALITY = 0.7;
|
|
10
|
+
/** Max dimension (px) on longest side when resize is available. */
|
|
11
|
+
const MAX_DIMENSION = 2048;
|
|
12
|
+
function getErrorDisplay(error, showTechnicalDetails) {
|
|
13
|
+
if (!error) {
|
|
14
|
+
return {
|
|
15
|
+
title: 'Something went wrong',
|
|
16
|
+
message: "We couldn't process your photo. Please try again.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const status = error.status ?? error.body?.status;
|
|
20
|
+
const code = error.code ?? error.body?.code;
|
|
21
|
+
const requestId = error.requestId ?? error.body?.request_id;
|
|
22
|
+
let message = error.message?.trim() || "We couldn't process your photo. Please try again.";
|
|
23
|
+
if (code === 'timeout' || status === 408 || error.name === 'AbortError') {
|
|
24
|
+
message = 'Verification timed out. Please try again.';
|
|
25
|
+
}
|
|
26
|
+
else if (code === 'network_error' || status === 0) {
|
|
27
|
+
message = 'Network request failed. Check your connection and try again.';
|
|
28
|
+
}
|
|
29
|
+
else if (status === 401) {
|
|
30
|
+
message = 'Verification is not configured correctly.';
|
|
31
|
+
}
|
|
32
|
+
else if (status === 413) {
|
|
33
|
+
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
34
|
+
}
|
|
35
|
+
else if (status === 429) {
|
|
36
|
+
message = 'Verification is temporarily unavailable. Please try again.';
|
|
37
|
+
}
|
|
38
|
+
else if (status !== undefined && status >= 500) {
|
|
39
|
+
message = 'Verify AI is unavailable right now. Please try again.';
|
|
40
|
+
}
|
|
41
|
+
if (showTechnicalDetails) {
|
|
42
|
+
const details = [
|
|
43
|
+
status != null ? `status ${status}` : null,
|
|
44
|
+
requestId ? `request ${requestId}` : null,
|
|
45
|
+
].filter(Boolean).join(' · ');
|
|
46
|
+
if (details) {
|
|
47
|
+
message = `${message}\n\n${details}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
title: 'Verification failed',
|
|
52
|
+
message,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
5
55
|
/**
|
|
6
56
|
* Camera scanner component for capturing verification photos.
|
|
7
57
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -23,29 +73,178 @@ import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
|
23
73
|
* />
|
|
24
74
|
* ```
|
|
25
75
|
*/
|
|
26
|
-
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, }) {
|
|
76
|
+
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
77
|
+
const contextTelemetry = useTelemetry();
|
|
78
|
+
const telemetry = telemetryProp ?? contextTelemetry;
|
|
27
79
|
const cameraRef = useRef(null);
|
|
28
80
|
const [status, setStatus] = useState('idle');
|
|
29
81
|
const [result, setResult] = useState(null);
|
|
82
|
+
const [lastError, setLastError] = useState(null);
|
|
30
83
|
const [permission, requestPermission] = useCameraPermissions();
|
|
31
84
|
const attemptCountRef = useRef(0);
|
|
32
85
|
const [exhausted, setExhausted] = useState(false);
|
|
86
|
+
const [terminated, setTerminated] = useState(false);
|
|
87
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
88
|
+
const cameraReadyRef = useRef(false);
|
|
89
|
+
const cameraInitFailedRef = useRef(false);
|
|
90
|
+
const permissionDeniedTrackedRef = useRef(false);
|
|
91
|
+
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
92
|
+
const releaseCamera = useCallback(() => {
|
|
93
|
+
setTerminated(true);
|
|
94
|
+
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
95
|
+
}, []);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
return () => {
|
|
98
|
+
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
// Camera init callbacks
|
|
102
|
+
const onCameraReady = useCallback(() => {
|
|
103
|
+
setCameraReady(true);
|
|
104
|
+
cameraReadyRef.current = true;
|
|
105
|
+
cameraInitFailedRef.current = false;
|
|
106
|
+
}, []);
|
|
107
|
+
const onMountError = useCallback((event) => {
|
|
108
|
+
const error = new Error(event.message || 'Camera mount error');
|
|
109
|
+
setResult(null);
|
|
110
|
+
setLastError(error);
|
|
111
|
+
setStatus('error');
|
|
112
|
+
setCameraReady(false);
|
|
113
|
+
cameraReadyRef.current = false;
|
|
114
|
+
cameraInitFailedRef.current = true;
|
|
115
|
+
onError?.(error);
|
|
116
|
+
telemetry?.track('camera_init_failure', {
|
|
117
|
+
component: 'scanner',
|
|
118
|
+
error,
|
|
119
|
+
});
|
|
120
|
+
}, [onError, telemetry]);
|
|
121
|
+
// Startup watchdog — if camera hasn't fired onCameraReady within 5s, report timeout
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!permission?.granted || terminated)
|
|
124
|
+
return;
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
127
|
+
telemetry?.track('camera_preview_timeout', {
|
|
128
|
+
component: 'scanner',
|
|
129
|
+
error: 'Camera did not initialize within 5 seconds',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}, 5000);
|
|
133
|
+
return () => clearTimeout(timer);
|
|
134
|
+
}, [permission?.granted, terminated, telemetry]);
|
|
135
|
+
// Track permission denied
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (permission &&
|
|
138
|
+
!permission.granted &&
|
|
139
|
+
permission.canAskAgain === false &&
|
|
140
|
+
!permissionDeniedTrackedRef.current) {
|
|
141
|
+
permissionDeniedTrackedRef.current = true;
|
|
142
|
+
telemetry?.track('camera_permission_denied', { component: 'scanner' });
|
|
143
|
+
}
|
|
144
|
+
}, [permission, telemetry]);
|
|
33
145
|
const handleCapture = useCallback(async () => {
|
|
34
146
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
35
147
|
return;
|
|
148
|
+
if (!cameraReadyRef.current) {
|
|
149
|
+
const error = new Error('Camera is not ready yet. Please wait a moment and try again.');
|
|
150
|
+
setResult(null);
|
|
151
|
+
setLastError(error);
|
|
152
|
+
setStatus('error');
|
|
153
|
+
onError?.(error);
|
|
154
|
+
telemetry?.track('camera_not_ready', {
|
|
155
|
+
component: 'scanner',
|
|
156
|
+
error,
|
|
157
|
+
});
|
|
158
|
+
setTimeout(() => setStatus('idle'), 2000);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
36
161
|
setStatus('capturing');
|
|
37
162
|
setResult(null);
|
|
163
|
+
setLastError(null);
|
|
38
164
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
165
|
+
// --- Capture + best-effort resize ---
|
|
166
|
+
// Strategy: try to dynamically import expo-image-manipulator.
|
|
167
|
+
// If available → resize to MAX_DIMENSION, EXIF-normalize, return base64.
|
|
168
|
+
// If not available → use expo-camera's built-in base64 at lower quality.
|
|
169
|
+
// This keeps expo-image-manipulator as an *optional* dependency.
|
|
170
|
+
let base64;
|
|
171
|
+
let origWidth = 0;
|
|
172
|
+
let origHeight = 0;
|
|
173
|
+
let processedWidth = 0;
|
|
174
|
+
let processedHeight = 0;
|
|
175
|
+
let didResize = false;
|
|
176
|
+
let ImageManipulator = null;
|
|
177
|
+
try {
|
|
178
|
+
ImageManipulator = await import('expo-image-manipulator');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Not installed — fall back to camera-only base64 below
|
|
182
|
+
}
|
|
183
|
+
if (ImageManipulator) {
|
|
184
|
+
// Capture without base64 — ImageManipulator will produce it after resize.
|
|
185
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
186
|
+
quality: 0.8,
|
|
187
|
+
exif: false,
|
|
188
|
+
});
|
|
189
|
+
if (!photo?.uri) {
|
|
190
|
+
throw new Error('Failed to capture photo');
|
|
191
|
+
}
|
|
192
|
+
origWidth = photo.width ?? 0;
|
|
193
|
+
origHeight = photo.height ?? 0;
|
|
194
|
+
const actions = [];
|
|
195
|
+
if (origWidth > MAX_DIMENSION || origHeight > MAX_DIMENSION) {
|
|
196
|
+
if (origWidth >= origHeight) {
|
|
197
|
+
actions.push({ resize: { width: MAX_DIMENSION } });
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
actions.push({ resize: { height: MAX_DIMENSION } });
|
|
201
|
+
}
|
|
202
|
+
didResize = true;
|
|
203
|
+
}
|
|
204
|
+
const normalized = await ImageManipulator.manipulateAsync(photo.uri, actions, {
|
|
205
|
+
compress: MANIPULATOR_QUALITY,
|
|
206
|
+
format: ImageManipulator.SaveFormat.JPEG,
|
|
207
|
+
base64: true,
|
|
208
|
+
});
|
|
209
|
+
if (!normalized.base64) {
|
|
210
|
+
throw new Error('ImageManipulator did not return base64');
|
|
211
|
+
}
|
|
212
|
+
base64 = normalized.base64;
|
|
213
|
+
processedWidth = normalized.width;
|
|
214
|
+
processedHeight = normalized.height;
|
|
46
215
|
}
|
|
216
|
+
else {
|
|
217
|
+
// Fallback: capture base64 directly from the camera at reduced quality.
|
|
218
|
+
// No resize is possible without ImageManipulator, but the lower quality
|
|
219
|
+
// significantly reduces payload size (e.g. 50 MP @ 0.5 ≈ 3–4 MB base64).
|
|
220
|
+
const photo = await cameraRef.current.takePictureAsync({
|
|
221
|
+
base64: true,
|
|
222
|
+
quality: FALLBACK_QUALITY,
|
|
223
|
+
exif: false,
|
|
224
|
+
});
|
|
225
|
+
if (!photo?.base64) {
|
|
226
|
+
throw new Error('Failed to capture photo');
|
|
227
|
+
}
|
|
228
|
+
origWidth = photo.width ?? 0;
|
|
229
|
+
origHeight = photo.height ?? 0;
|
|
230
|
+
processedWidth = origWidth;
|
|
231
|
+
processedHeight = origHeight;
|
|
232
|
+
base64 = photo.base64;
|
|
233
|
+
}
|
|
234
|
+
// Best-effort telemetry — never blocks capture
|
|
235
|
+
telemetry?.track('image_processed', {
|
|
236
|
+
component: 'scanner',
|
|
237
|
+
metadata: {
|
|
238
|
+
original_width: origWidth,
|
|
239
|
+
original_height: origHeight,
|
|
240
|
+
processed_width: processedWidth,
|
|
241
|
+
processed_height: processedHeight,
|
|
242
|
+
resized: didResize ? 1 : 0,
|
|
243
|
+
has_manipulator: ImageManipulator ? 1 : 0,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
47
246
|
setStatus('processing');
|
|
48
|
-
const verificationResult = await onCapture(
|
|
247
|
+
const verificationResult = await onCapture(base64);
|
|
49
248
|
attemptCountRef.current++;
|
|
50
249
|
if (verificationResult) {
|
|
51
250
|
const maxAttempts = overlay?.maxAttempts;
|
|
@@ -55,6 +254,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
55
254
|
maxAttempts != null &&
|
|
56
255
|
attemptCountRef.current >= maxAttempts) {
|
|
57
256
|
setExhausted(true);
|
|
257
|
+
releaseCamera();
|
|
58
258
|
if (autoApprove) {
|
|
59
259
|
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
60
260
|
setResult(approvedResult);
|
|
@@ -64,13 +264,22 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
64
264
|
else {
|
|
65
265
|
setResult(verificationResult);
|
|
66
266
|
setStatus('error');
|
|
267
|
+
onResult?.(verificationResult);
|
|
67
268
|
}
|
|
68
269
|
return;
|
|
69
270
|
}
|
|
271
|
+
if (!verificationResult.is_compliant &&
|
|
272
|
+
maxAttempts != null &&
|
|
273
|
+
attemptCountRef.current < maxAttempts) {
|
|
274
|
+
setResult(verificationResult);
|
|
275
|
+
setStatus('error');
|
|
276
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
releaseCamera();
|
|
70
280
|
setResult(verificationResult);
|
|
71
281
|
setStatus('success');
|
|
72
282
|
onResult?.(verificationResult);
|
|
73
|
-
setTimeout(() => setStatus('idle'), 3000);
|
|
74
283
|
}
|
|
75
284
|
else {
|
|
76
285
|
// null result means queued for offline
|
|
@@ -78,13 +287,20 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
78
287
|
}
|
|
79
288
|
}
|
|
80
289
|
catch (err) {
|
|
81
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
290
|
+
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
291
|
+
setLastError(error);
|
|
82
292
|
setStatus('error');
|
|
83
293
|
onError?.(error);
|
|
294
|
+
// Track the error
|
|
295
|
+
const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
|
|
296
|
+
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
297
|
+
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
298
|
+
: isImageFail ? 'image_manipulation_failure'
|
|
299
|
+
: 'unknown_error', { component: 'scanner', error });
|
|
84
300
|
// Reset after a brief pause
|
|
85
301
|
setTimeout(() => setStatus('idle'), 2000);
|
|
86
302
|
}
|
|
87
|
-
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
|
|
303
|
+
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
|
|
88
304
|
// Expose capture to parent via ref
|
|
89
305
|
if (captureRef) {
|
|
90
306
|
captureRef.current = handleCapture;
|
|
@@ -96,7 +312,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
96
312
|
return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
|
|
97
313
|
}
|
|
98
314
|
const showBottomCard = status === 'success' || status === 'error';
|
|
99
|
-
return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: enableTorch, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
|
|
315
|
+
return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: !terminated && enableTorch, onCameraReady: onCameraReady, onMountError: onMountError, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
|
|
100
316
|
styles.guideFrame,
|
|
101
317
|
overlay.guideFrameAspectRatio
|
|
102
318
|
? { aspectRatio: overlay.guideFrameAspectRatio }
|
|
@@ -123,15 +339,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
123
339
|
errorMessage = template.replace('{remaining}', String(remaining));
|
|
124
340
|
}
|
|
125
341
|
else {
|
|
126
|
-
|
|
127
|
-
|
|
342
|
+
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
343
|
+
errorTitle = display.title;
|
|
344
|
+
errorMessage = display.message;
|
|
128
345
|
}
|
|
129
346
|
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
|
|
130
347
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: styles.instructionsText, children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
|
|
131
348
|
styles.captureButton,
|
|
132
|
-
(status === 'capturing' || status === 'processing') &&
|
|
349
|
+
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
133
350
|
styles.captureButtonDisabled,
|
|
134
|
-
], onPress: handleCapture, disabled: status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }) }));
|
|
351
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }) }));
|
|
135
352
|
}
|
|
136
353
|
const CORNER_SIZE = 30;
|
|
137
354
|
const CORNER_THICKNESS = 3;
|