@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.
Files changed (48) hide show
  1. package/README.md +11 -2
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +14 -6
  5. package/lib/components/VerifyAIScanner.js +157 -15
  6. package/lib/hooks/useVerifyAI.d.ts +32 -10
  7. package/lib/hooks/useVerifyAI.js +246 -14
  8. package/lib/index.d.ts +5 -2
  9. package/lib/index.js +3 -0
  10. package/lib/ml/featureExtractor.d.ts +16 -0
  11. package/lib/ml/featureExtractor.js +123 -0
  12. package/lib/ml/imagePreprocessor.d.ts +2 -0
  13. package/lib/ml/imagePreprocessor.js +48 -0
  14. package/lib/ml/index.d.ts +5 -0
  15. package/lib/ml/index.js +4 -0
  16. package/lib/ml/inferenceEngine.d.ts +24 -0
  17. package/lib/ml/inferenceEngine.js +156 -0
  18. package/lib/ml/modelManager.d.ts +26 -0
  19. package/lib/ml/modelManager.js +207 -0
  20. package/lib/ml/policyEngine.d.ts +14 -0
  21. package/lib/ml/policyEngine.js +161 -0
  22. package/lib/ml/types.d.ts +84 -0
  23. package/lib/ml/types.js +4 -0
  24. package/lib/storage/offlineQueue.js +1 -1
  25. package/lib/telemetry/TelemetryContext.d.ts +4 -0
  26. package/lib/telemetry/TelemetryContext.js +5 -0
  27. package/lib/telemetry/TelemetryReporter.d.ts +23 -0
  28. package/lib/telemetry/TelemetryReporter.js +140 -0
  29. package/lib/types/index.d.ts +18 -0
  30. package/lib/version.d.ts +1 -0
  31. package/lib/version.js +1 -0
  32. package/package.json +23 -2
  33. package/src/client/index.ts +176 -25
  34. package/src/components/VerifyAIScanner.tsx +201 -18
  35. package/src/hooks/useVerifyAI.ts +332 -18
  36. package/src/index.ts +20 -1
  37. package/src/ml/featureExtractor.ts +160 -0
  38. package/src/ml/imagePreprocessor.ts +72 -0
  39. package/src/ml/index.ts +14 -0
  40. package/src/ml/inferenceEngine.ts +200 -0
  41. package/src/ml/modelManager.ts +265 -0
  42. package/src/ml/policyEngine.ts +201 -0
  43. package/src/ml/types.ts +104 -0
  44. package/src/storage/offlineQueue.ts +1 -1
  45. package/src/telemetry/TelemetryContext.tsx +8 -0
  46. package/src/telemetry/TelemetryReporter.ts +181 -0
  47. package/src/types/index.ts +20 -0
  48. 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 { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
51
+ const { verifyMultipart } = useVerifyAI({
52
+ apiKey: 'vai_your_api_key',
53
+ enableOnDeviceML: true,
54
+ });
48
55
  return (
49
56
  <VerifyAIScanner
50
- onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
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
  );
@@ -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
  }
@@ -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
- async request(path, options = {}) {
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(`${this.baseUrl}${path}`, {
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
- let body = null;
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
- const error = {
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
- throw new VerifyAIRequestError(error.error, response.status, error);
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 new Error('VerifyAI: Invalid response payload');
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: { 'Content-Type': 'application/json' },
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 base64 image data when the user captures a photo. */
6
- onCapture: (base64Image: string) => Promise<VerificationResult | null>;
7
- /** Called when verification completes successfully. */
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 { verify } = useVerifyAI({ apiKey: 'vai_...' });
31
+ * const { verifyMultipart } = useVerifyAI({
32
+ * apiKey: 'vai_...',
33
+ * enableOnDeviceML: true,
34
+ * });
27
35
  *
28
36
  * <VerifyAIScanner
29
- * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
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 { verify } = useVerifyAI({ apiKey: 'vai_...' });
53
+ * const { verifyMultipart } = useVerifyAI({
54
+ * apiKey: 'vai_...',
55
+ * enableOnDeviceML: true,
56
+ * });
12
57
  *
13
58
  * <VerifyAIScanner
14
- * onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
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?.base64) {
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(photo.base64);
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
- errorTitle = 'Something went wrong';
127
- errorMessage = "We couldn't process your photo. Please try again.";
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 { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListParams, VerificationListResponse } from '../types';
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 offline queue if offlineMode is enabled and request fails. */
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. Provides verification methods,
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, error } = useVerifyAI({
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: VerifyAIConfig): UseVerifyAIReturn;
70
+ export declare function useVerifyAI(config: UseVerifyAIConfig): UseVerifyAIReturn;