@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.
Files changed (48) hide show
  1. package/README.md +17 -4
  2. package/lib/client/index.d.ts +22 -2
  3. package/lib/client/index.js +132 -19
  4. package/lib/components/VerifyAIScanner.d.ts +7 -4
  5. package/lib/components/VerifyAIScanner.js +235 -18
  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 +24 -0
  28. package/lib/telemetry/TelemetryReporter.js +141 -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 +282 -21
  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 +184 -0
  47. package/src/types/index.ts +20 -0
  48. 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 { 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
  );
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.
@@ -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 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
- const photo = await cameraRef.current.takePictureAsync({
40
- base64: true,
41
- quality: 0.8,
42
- exif: false,
43
- });
44
- if (!photo?.base64) {
45
- throw new Error('Failed to capture photo');
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(photo.base64);
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
- errorTitle = 'Something went wrong';
127
- errorMessage = "We couldn't process your photo. Please try again.";
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;