@switchlabs/verify-ai-react-native 1.0.0 → 1.1.1
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 +11 -2
- package/lib/client/index.d.ts +22 -2
- package/lib/client/index.js +132 -19
- package/lib/components/VerifyAIScanner.d.ts +14 -6
- package/lib/components/VerifyAIScanner.js +157 -15
- 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 +23 -0
- package/lib/telemetry/TelemetryReporter.js +140 -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 +201 -18
- 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 +181 -0
- package/src/types/index.ts +20 -0
- package/src/version.ts +1 -0
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ React Native CLI:
|
|
|
16
16
|
npm install @switchlabs/verify-ai-react-native expo-camera @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,10 +48,15 @@ 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
|
);
|
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 the
|
|
6
|
-
onCapture: (
|
|
7
|
-
/** Called when verification
|
|
6
|
+
/** Called with the image URI when the user captures a photo. */
|
|
7
|
+
onCapture: (imageUri: 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;
|
|
@@ -16,6 +17,10 @@ export interface VerifyAIScannerProps {
|
|
|
16
17
|
showCaptureButton?: boolean;
|
|
17
18
|
/** Ref to imperatively trigger capture from parent. */
|
|
18
19
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
20
|
+
/** Whether to enable the camera torch/flashlight. */
|
|
21
|
+
enableTorch?: boolean;
|
|
22
|
+
/** Optional telemetry reporter (falls back to TelemetryContext). */
|
|
23
|
+
telemetry?: TelemetryReporter | null;
|
|
19
24
|
}
|
|
20
25
|
/**
|
|
21
26
|
* Camera scanner component for capturing verification photos.
|
|
@@ -23,10 +28,13 @@ export interface VerifyAIScannerProps {
|
|
|
23
28
|
*
|
|
24
29
|
* @example
|
|
25
30
|
* ```tsx
|
|
26
|
-
* const {
|
|
31
|
+
* const { verifyMultipart } = useVerifyAI({
|
|
32
|
+
* apiKey: 'vai_...',
|
|
33
|
+
* enableOnDeviceML: true,
|
|
34
|
+
* });
|
|
27
35
|
*
|
|
28
36
|
* <VerifyAIScanner
|
|
29
|
-
* onCapture={(
|
|
37
|
+
* onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
|
|
30
38
|
* onResult={(result) => {
|
|
31
39
|
* if (result.is_compliant) navigation.navigate('Success');
|
|
32
40
|
* }}
|
|
@@ -38,4 +46,4 @@ export interface VerifyAIScannerProps {
|
|
|
38
46
|
* />
|
|
39
47
|
* ```
|
|
40
48
|
*/
|
|
41
|
-
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
49
|
+
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,17 +1,62 @@
|
|
|
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 * as ImageManipulator from 'expo-image-manipulator';
|
|
6
|
+
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
|
+
function getErrorDisplay(error, showTechnicalDetails) {
|
|
8
|
+
if (!error) {
|
|
9
|
+
return {
|
|
10
|
+
title: 'Something went wrong',
|
|
11
|
+
message: "We couldn't process your photo. Please try again.",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const status = error.status ?? error.body?.status;
|
|
15
|
+
const code = error.code ?? error.body?.code;
|
|
16
|
+
const requestId = error.requestId ?? error.body?.request_id;
|
|
17
|
+
let message = error.message?.trim() || "We couldn't process your photo. Please try again.";
|
|
18
|
+
if (code === 'timeout' || status === 408 || error.name === 'AbortError') {
|
|
19
|
+
message = 'Verification timed out. Please try again.';
|
|
20
|
+
}
|
|
21
|
+
else if (code === 'network_error' || status === 0) {
|
|
22
|
+
message = 'Network request failed. Check your connection and try again.';
|
|
23
|
+
}
|
|
24
|
+
else if (status === 401) {
|
|
25
|
+
message = 'Verification is not configured correctly.';
|
|
26
|
+
}
|
|
27
|
+
else if (status === 429) {
|
|
28
|
+
message = 'Verification is temporarily unavailable. Please try again.';
|
|
29
|
+
}
|
|
30
|
+
else if (status !== undefined && status >= 500) {
|
|
31
|
+
message = 'Verify AI is unavailable right now. Please try again.';
|
|
32
|
+
}
|
|
33
|
+
if (showTechnicalDetails) {
|
|
34
|
+
const details = [
|
|
35
|
+
status != null ? `status ${status}` : null,
|
|
36
|
+
requestId ? `request ${requestId}` : null,
|
|
37
|
+
].filter(Boolean).join(' · ');
|
|
38
|
+
if (details) {
|
|
39
|
+
message = `${message}\n\n${details}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
title: 'Verification failed',
|
|
44
|
+
message,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
5
47
|
/**
|
|
6
48
|
* Camera scanner component for capturing verification photos.
|
|
7
49
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
8
50
|
*
|
|
9
51
|
* @example
|
|
10
52
|
* ```tsx
|
|
11
|
-
* const {
|
|
53
|
+
* const { verifyMultipart } = useVerifyAI({
|
|
54
|
+
* apiKey: 'vai_...',
|
|
55
|
+
* enableOnDeviceML: true,
|
|
56
|
+
* });
|
|
12
57
|
*
|
|
13
58
|
* <VerifyAIScanner
|
|
14
|
-
* onCapture={(
|
|
59
|
+
* onCapture={(imageUri) => verifyMultipart({ imageUri, policy: 'scooter_parking' })}
|
|
15
60
|
* onResult={(result) => {
|
|
16
61
|
* if (result.is_compliant) navigation.navigate('Success');
|
|
17
62
|
* }}
|
|
@@ -23,29 +68,108 @@ import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
|
23
68
|
* />
|
|
24
69
|
* ```
|
|
25
70
|
*/
|
|
26
|
-
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, }) {
|
|
71
|
+
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
72
|
+
const contextTelemetry = useTelemetry();
|
|
73
|
+
const telemetry = telemetryProp ?? contextTelemetry;
|
|
27
74
|
const cameraRef = useRef(null);
|
|
28
75
|
const [status, setStatus] = useState('idle');
|
|
29
76
|
const [result, setResult] = useState(null);
|
|
77
|
+
const [lastError, setLastError] = useState(null);
|
|
30
78
|
const [permission, requestPermission] = useCameraPermissions();
|
|
31
79
|
const attemptCountRef = useRef(0);
|
|
32
80
|
const [exhausted, setExhausted] = useState(false);
|
|
81
|
+
const [terminated, setTerminated] = useState(false);
|
|
82
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
83
|
+
const cameraReadyRef = useRef(false);
|
|
84
|
+
const cameraInitFailedRef = useRef(false);
|
|
85
|
+
const permissionDeniedTrackedRef = useRef(false);
|
|
86
|
+
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
87
|
+
const releaseCamera = useCallback(() => {
|
|
88
|
+
setTerminated(true);
|
|
89
|
+
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
90
|
+
}, []);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
return () => {
|
|
93
|
+
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
// Camera init callbacks
|
|
97
|
+
const onCameraReady = useCallback(() => {
|
|
98
|
+
setCameraReady(true);
|
|
99
|
+
cameraReadyRef.current = true;
|
|
100
|
+
cameraInitFailedRef.current = false;
|
|
101
|
+
}, []);
|
|
102
|
+
const onMountError = useCallback((event) => {
|
|
103
|
+
const error = new Error(event.message || 'Camera mount error');
|
|
104
|
+
setResult(null);
|
|
105
|
+
setLastError(error);
|
|
106
|
+
setStatus('error');
|
|
107
|
+
setCameraReady(false);
|
|
108
|
+
cameraReadyRef.current = false;
|
|
109
|
+
cameraInitFailedRef.current = true;
|
|
110
|
+
onError?.(error);
|
|
111
|
+
telemetry?.track('camera_init_failure', {
|
|
112
|
+
component: 'scanner',
|
|
113
|
+
error,
|
|
114
|
+
});
|
|
115
|
+
}, [onError, telemetry]);
|
|
116
|
+
// Startup watchdog — if camera hasn't fired onCameraReady within 5s, report timeout
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!permission?.granted || terminated)
|
|
119
|
+
return;
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
122
|
+
telemetry?.track('camera_preview_timeout', {
|
|
123
|
+
component: 'scanner',
|
|
124
|
+
error: 'Camera did not initialize within 5 seconds',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}, 5000);
|
|
128
|
+
return () => clearTimeout(timer);
|
|
129
|
+
}, [permission?.granted, terminated, telemetry]);
|
|
130
|
+
// Track permission denied
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (permission &&
|
|
133
|
+
!permission.granted &&
|
|
134
|
+
permission.canAskAgain === false &&
|
|
135
|
+
!permissionDeniedTrackedRef.current) {
|
|
136
|
+
permissionDeniedTrackedRef.current = true;
|
|
137
|
+
telemetry?.track('camera_permission_denied', { component: 'scanner' });
|
|
138
|
+
}
|
|
139
|
+
}, [permission, telemetry]);
|
|
33
140
|
const handleCapture = useCallback(async () => {
|
|
34
141
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
35
142
|
return;
|
|
143
|
+
if (!cameraReadyRef.current) {
|
|
144
|
+
const error = new Error('Camera is not ready yet. Please wait a moment and try again.');
|
|
145
|
+
setResult(null);
|
|
146
|
+
setLastError(error);
|
|
147
|
+
setStatus('error');
|
|
148
|
+
onError?.(error);
|
|
149
|
+
telemetry?.track('camera_not_ready', {
|
|
150
|
+
component: 'scanner',
|
|
151
|
+
error,
|
|
152
|
+
});
|
|
153
|
+
setTimeout(() => setStatus('idle'), 2000);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
36
156
|
setStatus('capturing');
|
|
37
157
|
setResult(null);
|
|
158
|
+
setLastError(null);
|
|
38
159
|
try {
|
|
39
160
|
const photo = await cameraRef.current.takePictureAsync({
|
|
40
|
-
base64: true,
|
|
41
161
|
quality: 0.8,
|
|
42
162
|
exif: false,
|
|
43
163
|
});
|
|
44
|
-
if (!photo?.
|
|
164
|
+
if (!photo?.uri) {
|
|
45
165
|
throw new Error('Failed to capture photo');
|
|
46
166
|
}
|
|
167
|
+
// Normalize EXIF orientation into actual pixels so the server
|
|
168
|
+
// receives an upright image (Gemini and other vision APIs ignore EXIF).
|
|
169
|
+
// An empty actions array makes ImageManipulator apply EXIF rotation only.
|
|
170
|
+
const normalized = await ImageManipulator.manipulateAsync(photo.uri, [], { compress: 0.8, format: ImageManipulator.SaveFormat.JPEG });
|
|
47
171
|
setStatus('processing');
|
|
48
|
-
const verificationResult = await onCapture(
|
|
172
|
+
const verificationResult = await onCapture(normalized.uri);
|
|
49
173
|
attemptCountRef.current++;
|
|
50
174
|
if (verificationResult) {
|
|
51
175
|
const maxAttempts = overlay?.maxAttempts;
|
|
@@ -55,6 +179,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
55
179
|
maxAttempts != null &&
|
|
56
180
|
attemptCountRef.current >= maxAttempts) {
|
|
57
181
|
setExhausted(true);
|
|
182
|
+
releaseCamera();
|
|
58
183
|
if (autoApprove) {
|
|
59
184
|
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
60
185
|
setResult(approvedResult);
|
|
@@ -64,13 +189,22 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
64
189
|
else {
|
|
65
190
|
setResult(verificationResult);
|
|
66
191
|
setStatus('error');
|
|
192
|
+
onResult?.(verificationResult);
|
|
67
193
|
}
|
|
68
194
|
return;
|
|
69
195
|
}
|
|
196
|
+
if (!verificationResult.is_compliant &&
|
|
197
|
+
maxAttempts != null &&
|
|
198
|
+
attemptCountRef.current < maxAttempts) {
|
|
199
|
+
setResult(verificationResult);
|
|
200
|
+
setStatus('error');
|
|
201
|
+
setTimeout(() => setStatus('idle'), 3000);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
releaseCamera();
|
|
70
205
|
setResult(verificationResult);
|
|
71
206
|
setStatus('success');
|
|
72
207
|
onResult?.(verificationResult);
|
|
73
|
-
setTimeout(() => setStatus('idle'), 3000);
|
|
74
208
|
}
|
|
75
209
|
else {
|
|
76
210
|
// null result means queued for offline
|
|
@@ -78,13 +212,20 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
78
212
|
}
|
|
79
213
|
}
|
|
80
214
|
catch (err) {
|
|
81
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
215
|
+
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
216
|
+
setLastError(error);
|
|
82
217
|
setStatus('error');
|
|
83
218
|
onError?.(error);
|
|
219
|
+
// Track the error
|
|
220
|
+
const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
|
|
221
|
+
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
222
|
+
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
223
|
+
: isImageFail ? 'image_manipulation_failure'
|
|
224
|
+
: 'unknown_error', { component: 'scanner', error });
|
|
84
225
|
// Reset after a brief pause
|
|
85
226
|
setTimeout(() => setStatus('idle'), 2000);
|
|
86
227
|
}
|
|
87
|
-
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
|
|
228
|
+
}, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
|
|
88
229
|
// Expose capture to parent via ref
|
|
89
230
|
if (captureRef) {
|
|
90
231
|
captureRef.current = handleCapture;
|
|
@@ -96,7 +237,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
96
237
|
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
238
|
}
|
|
98
239
|
const showBottomCard = status === 'success' || status === 'error';
|
|
99
|
-
return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", 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: [
|
|
240
|
+
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
241
|
styles.guideFrame,
|
|
101
242
|
overlay.guideFrameAspectRatio
|
|
102
243
|
? { aspectRatio: overlay.guideFrameAspectRatio }
|
|
@@ -123,15 +264,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
123
264
|
errorMessage = template.replace('{remaining}', String(remaining));
|
|
124
265
|
}
|
|
125
266
|
else {
|
|
126
|
-
|
|
127
|
-
|
|
267
|
+
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
268
|
+
errorTitle = display.title;
|
|
269
|
+
errorMessage = display.message;
|
|
128
270
|
}
|
|
129
271
|
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
272
|
})(), !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
273
|
styles.captureButton,
|
|
132
|
-
(status === 'capturing' || status === 'processing') &&
|
|
274
|
+
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
133
275
|
styles.captureButtonDisabled,
|
|
134
|
-
], onPress: handleCapture, disabled: status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }) }));
|
|
276
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }) }));
|
|
135
277
|
}
|
|
136
278
|
const CORNER_SIZE = 30;
|
|
137
279
|
const CORNER_THICKNESS = 3;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import { VerifyAIClient } from '../client';
|
|
2
3
|
import { OfflineQueue } from '../storage/offlineQueue';
|
|
3
|
-
import type {
|
|
4
|
+
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
5
|
+
import type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListParams, VerificationListResponse, VerifyOptions } from '../types';
|
|
6
|
+
export interface UseVerifyAIConfig extends VerifyAIConfig {
|
|
7
|
+
/** Enable on-device ML inference for faster, offline-capable verification. */
|
|
8
|
+
enableOnDeviceML?: boolean;
|
|
9
|
+
/** Minimum confidence threshold for on-device results. Below this, falls back to cloud. Default: 0.7 */
|
|
10
|
+
onDeviceConfidenceThreshold?: number;
|
|
11
|
+
}
|
|
4
12
|
export interface UseVerifyAIReturn {
|
|
5
|
-
/** Submit a verification. Uses
|
|
6
|
-
verify: (request: VerificationRequest) => Promise<VerificationResult | null>;
|
|
13
|
+
/** Submit a verification. Uses on-device ML if enabled and model is cached. */
|
|
14
|
+
verify: (request: VerificationRequest, options?: VerifyOptions) => Promise<VerificationResult | null>;
|
|
15
|
+
/** Submit a multipart verification. Uses on-device ML if enabled and model is cached. */
|
|
16
|
+
verifyMultipart: (request: MultipartVerificationRequest, options?: VerifyOptions) => Promise<VerificationResult | null>;
|
|
7
17
|
/** List past verifications. */
|
|
8
18
|
listVerifications: (params?: VerificationListParams) => Promise<VerificationListResponse>;
|
|
9
19
|
/** Get a single verification by ID. */
|
|
@@ -22,27 +32,39 @@ export interface UseVerifyAIReturn {
|
|
|
22
32
|
client: VerifyAIClient;
|
|
23
33
|
/** The offline queue instance (null if offlineMode is disabled). */
|
|
24
34
|
offlineQueue: OfflineQueue | null;
|
|
35
|
+
/** Whether an on-device model is loaded and ready. */
|
|
36
|
+
mlModelReady: boolean;
|
|
37
|
+
/** Initialize or update the on-device ML model for a policy. */
|
|
38
|
+
initializeMLModel: (policyId: string) => Promise<void>;
|
|
39
|
+
/** Telemetry reporter instance (null if telemetry is disabled). */
|
|
40
|
+
telemetry: TelemetryReporter | null;
|
|
41
|
+
/** TelemetryProvider component — wrap your scanner tree with this. */
|
|
42
|
+
TelemetryProvider: React.FC<{
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
}>;
|
|
25
45
|
}
|
|
26
46
|
/**
|
|
27
|
-
* React hook for Verify AI
|
|
28
|
-
* loading/error state, and optional offline queue management.
|
|
47
|
+
* React hook for Verify AI with optional on-device ML inference.
|
|
29
48
|
*
|
|
30
49
|
* @example
|
|
31
50
|
* ```tsx
|
|
32
|
-
* const { verify, loading, lastResult,
|
|
51
|
+
* const { verify, loading, lastResult, mlModelReady, initializeMLModel } = useVerifyAI({
|
|
33
52
|
* apiKey: 'vai_your_api_key',
|
|
34
53
|
* offlineMode: true,
|
|
54
|
+
* enableOnDeviceML: true,
|
|
35
55
|
* });
|
|
36
56
|
*
|
|
57
|
+
* // Initialize ML model on mount
|
|
58
|
+
* useEffect(() => {
|
|
59
|
+
* initializeMLModel('scooter_parking');
|
|
60
|
+
* }, []);
|
|
61
|
+
*
|
|
37
62
|
* const handleCapture = async (base64Image: string) => {
|
|
38
63
|
* const result = await verify({
|
|
39
64
|
* image: base64Image,
|
|
40
65
|
* policy: 'scooter_parking',
|
|
41
66
|
* });
|
|
42
|
-
* if (result?.is_compliant) {
|
|
43
|
-
* // Success
|
|
44
|
-
* }
|
|
45
67
|
* };
|
|
46
68
|
* ```
|
|
47
69
|
*/
|
|
48
|
-
export declare function useVerifyAI(config:
|
|
70
|
+
export declare function useVerifyAI(config: UseVerifyAIConfig): UseVerifyAIReturn;
|