@switchlabs/verify-ai-react-native 2.3.2 → 2.4.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/lib/client/index.js +8 -1
- package/lib/components/VerifyAIScanner.js +48 -13
- package/lib/index.d.ts +1 -1
- package/lib/telemetry/TelemetryReporter.d.ts +17 -0
- package/lib/telemetry/TelemetryReporter.js +136 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -8
- package/src/client/index.ts +9 -2
- package/src/components/VerifyAIScanner.tsx +60 -16
- package/src/index.ts +1 -0
- package/src/telemetry/TelemetryReporter.ts +139 -0
- package/src/types/index.ts +26 -0
- package/src/version.ts +1 -1
package/lib/client/index.js
CHANGED
|
@@ -118,9 +118,12 @@ export class VerifyAIClient {
|
|
|
118
118
|
: normalized.status >= 400 && normalized.status < 500
|
|
119
119
|
? 'request_error'
|
|
120
120
|
: 'server_error';
|
|
121
|
+
const telemetryError = eventType === 'request_timeout'
|
|
122
|
+
? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
|
|
123
|
+
: normalized;
|
|
121
124
|
this.telemetry.track(eventType, {
|
|
122
125
|
component: 'client',
|
|
123
|
-
error:
|
|
126
|
+
error: telemetryError,
|
|
124
127
|
errorCode,
|
|
125
128
|
});
|
|
126
129
|
}
|
|
@@ -188,6 +191,9 @@ export class VerifyAIClient {
|
|
|
188
191
|
if (request.provider) {
|
|
189
192
|
formData.append('provider', request.provider);
|
|
190
193
|
}
|
|
194
|
+
if (request.include_image_data) {
|
|
195
|
+
formData.append('include_image_data', 'true');
|
|
196
|
+
}
|
|
191
197
|
const headers = {
|
|
192
198
|
'X-API-Key': this.apiKey,
|
|
193
199
|
// Do NOT set Content-Type — fetch auto-sets multipart boundary
|
|
@@ -217,6 +223,7 @@ export class VerifyAIClient {
|
|
|
217
223
|
policy: request.policy,
|
|
218
224
|
metadata: request.metadata,
|
|
219
225
|
provider: request.provider,
|
|
226
|
+
include_image_data: request.include_image_data,
|
|
220
227
|
});
|
|
221
228
|
}
|
|
222
229
|
catch {
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
3
|
-
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, } from 'react-native';
|
|
3
|
+
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState, useWindowDimensions, } from 'react-native';
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
5
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
6
6
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
7
|
-
const FALLBACK_QUALITY = 0.
|
|
7
|
+
const FALLBACK_QUALITY = 0.65;
|
|
8
8
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
9
|
-
const MANIPULATOR_QUALITY = 0.
|
|
9
|
+
const MANIPULATOR_QUALITY = 0.8;
|
|
10
10
|
/** Max dimension (px) on longest side when resize is available. */
|
|
11
|
-
const MAX_DIMENSION =
|
|
11
|
+
const MAX_DIMENSION = 1600;
|
|
12
12
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
13
13
|
if (!error) {
|
|
14
14
|
return {
|
|
@@ -89,16 +89,46 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
89
89
|
const cameraReadyRef = useRef(false);
|
|
90
90
|
const cameraInitFailedRef = useRef(false);
|
|
91
91
|
const permissionDeniedTrackedRef = useRef(false);
|
|
92
|
+
// Track dimensions to detect orientation changes and remount camera
|
|
93
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
94
|
+
const prevDimensionsRef = useRef({ width: windowWidth, height: windowHeight });
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const prev = prevDimensionsRef.current;
|
|
97
|
+
const orientationChanged = (prev.width > prev.height) !== (windowWidth > windowHeight);
|
|
98
|
+
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
99
|
+
if (orientationChanged && !terminated) {
|
|
100
|
+
// Force camera remount to fix preview distortion on iOS
|
|
101
|
+
setCameraReady(false);
|
|
102
|
+
cameraReadyRef.current = false;
|
|
103
|
+
setCameraKey((k) => k + 1);
|
|
104
|
+
}
|
|
105
|
+
}, [windowWidth, windowHeight, terminated]);
|
|
106
|
+
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (terminated)
|
|
109
|
+
return;
|
|
110
|
+
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
111
|
+
if (nextState === 'active') {
|
|
112
|
+
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
113
|
+
// its preview layer after returning from the notification bar or control center.
|
|
114
|
+
setCameraReady(false);
|
|
115
|
+
cameraReadyRef.current = false;
|
|
116
|
+
setCameraKey((k) => k + 1);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return () => subscription.remove();
|
|
120
|
+
}, [terminated]);
|
|
121
|
+
const pausePreview = useCallback(() => {
|
|
122
|
+
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
123
|
+
}, []);
|
|
92
124
|
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
93
125
|
const releaseCamera = useCallback(() => {
|
|
94
126
|
setTerminated(true);
|
|
95
|
-
|
|
96
|
-
}, []);
|
|
127
|
+
pausePreview();
|
|
128
|
+
}, [pausePreview]);
|
|
97
129
|
useEffect(() => {
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
};
|
|
101
|
-
}, []);
|
|
130
|
+
return pausePreview;
|
|
131
|
+
}, [pausePreview]);
|
|
102
132
|
// Camera init callbacks
|
|
103
133
|
const onCameraReady = useCallback(() => {
|
|
104
134
|
setCameraReady(true);
|
|
@@ -325,12 +355,16 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
325
355
|
overlay.guideFrameAspectRatio
|
|
326
356
|
? { aspectRatio: overlay.guideFrameAspectRatio }
|
|
327
357
|
: undefined,
|
|
328
|
-
], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }), overlay.guideCaption && (_jsx(Text, { style: styles.guideCaptionText, children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: styles.bottomArea, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
|
|
358
|
+
], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined] })] }), overlay.guideCaption && (_jsx(Text, { style: styles.guideCaptionText, children: overlay.guideCaption }))] })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: styles.bottomArea, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
|
|
329
359
|
styles.resultIconCircle,
|
|
330
360
|
result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
|
|
361
|
+
result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
|
|
362
|
+
!result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
|
|
331
363
|
], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
|
|
332
364
|
styles.resultLabel,
|
|
333
365
|
result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
|
|
366
|
+
result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
|
|
367
|
+
!result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
|
|
334
368
|
], children: result.is_compliant
|
|
335
369
|
? (overlay?.successMessage || 'Verified')
|
|
336
370
|
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
|
|
@@ -358,12 +392,13 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
358
392
|
errorTitle = display.title;
|
|
359
393
|
errorMessage = display.message;
|
|
360
394
|
}
|
|
361
|
-
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 })] }));
|
|
395
|
+
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
|
|
362
396
|
})(), !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: [
|
|
363
397
|
styles.captureButton,
|
|
398
|
+
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
364
399
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
365
400
|
styles.captureButtonDisabled,
|
|
366
|
-
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: styles.captureButtonInner }) }) }))] }))] })] }) }, cameraKey) }));
|
|
401
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] })] }) }, cameraKey) }));
|
|
367
402
|
}
|
|
368
403
|
const CORNER_SIZE = 30;
|
|
369
404
|
const CORNER_THICKNESS = 3;
|
package/lib/index.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export { TelemetryReporter } from './telemetry/TelemetryReporter';
|
|
|
5
5
|
export { TelemetryContext } from './telemetry/TelemetryContext';
|
|
6
6
|
export { OfflineQueue } from './storage/offlineQueue';
|
|
7
7
|
export type { BundleManifest, ModelArtifact, FeatureVector, Detection as MLDetection, PolicyAST, PolicyResult, RuleResult, } from './ml/types';
|
|
8
|
-
export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, PolicyConfigResponse, } from './types';
|
|
8
|
+
export type { VerifyAIConfig, VerificationRequest, MultipartVerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, ScannerTheme, PolicyConfigResponse, } from './types';
|
|
@@ -6,6 +6,8 @@ export declare class TelemetryReporter {
|
|
|
6
6
|
private apiKey;
|
|
7
7
|
private disposed;
|
|
8
8
|
private flushing;
|
|
9
|
+
private loadedPersisted;
|
|
10
|
+
private loadPersistedPromise;
|
|
9
11
|
constructor(apiKey: string, baseUrl: string);
|
|
10
12
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
11
13
|
track(eventType: string, opts?: {
|
|
@@ -21,4 +23,19 @@ export declare class TelemetryReporter {
|
|
|
21
23
|
private scheduleFlush;
|
|
22
24
|
private flushNow;
|
|
23
25
|
private clearFlushTimer;
|
|
26
|
+
/**
|
|
27
|
+
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
28
|
+
* Fire-and-forget — never throws or blocks.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
31
|
+
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
32
|
+
*/
|
|
33
|
+
private persistBuffer;
|
|
34
|
+
/**
|
|
35
|
+
* Load persisted events from a previous session and merge into current buffer.
|
|
36
|
+
* Runs once per instance. If orphaned events are found, emits an
|
|
37
|
+
* `sdk_session_recovered` event so monitoring can flag that the prior
|
|
38
|
+
* session ended without a clean telemetry flush.
|
|
39
|
+
*/
|
|
40
|
+
private loadPersistedBuffer;
|
|
24
41
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
import { SDK_VERSION } from '../version';
|
|
3
|
+
let _storage = null;
|
|
4
|
+
async function getStorage() {
|
|
5
|
+
if (!_storage) {
|
|
6
|
+
const mod = await import('@react-native-async-storage/async-storage');
|
|
7
|
+
_storage = mod.default;
|
|
8
|
+
}
|
|
9
|
+
return _storage;
|
|
10
|
+
}
|
|
11
|
+
const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
3
12
|
/** Event types that flush immediately (critical init failures). */
|
|
4
13
|
const CRITICAL_EVENTS = new Set([
|
|
5
14
|
'camera_init_failure',
|
|
@@ -12,9 +21,13 @@ export class TelemetryReporter {
|
|
|
12
21
|
this.flushTimer = null;
|
|
13
22
|
this.disposed = false;
|
|
14
23
|
this.flushing = false;
|
|
24
|
+
this.loadedPersisted = false;
|
|
25
|
+
this.loadPersistedPromise = null;
|
|
15
26
|
this.apiKey = apiKey;
|
|
16
27
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
17
28
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
29
|
+
// Load any persisted events left behind by a previous session
|
|
30
|
+
this.loadPersistedBuffer();
|
|
18
31
|
}
|
|
19
32
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
20
33
|
track(eventType, opts = {}) {
|
|
@@ -59,6 +72,18 @@ export class TelemetryReporter {
|
|
|
59
72
|
else {
|
|
60
73
|
this.scheduleFlush();
|
|
61
74
|
}
|
|
75
|
+
// Persist immediately once prior-session recovery has completed. If startup
|
|
76
|
+
// recovery is still in flight, write the merged buffer back afterwards.
|
|
77
|
+
if (this.loadedPersisted) {
|
|
78
|
+
this.persistBuffer();
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
void this.loadPersistedBuffer().then(() => {
|
|
82
|
+
if (!this.disposed && this.buffer.size > 0) {
|
|
83
|
+
this.persistBuffer();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
62
87
|
}
|
|
63
88
|
catch {
|
|
64
89
|
// Never throw from telemetry
|
|
@@ -66,6 +91,8 @@ export class TelemetryReporter {
|
|
|
66
91
|
}
|
|
67
92
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
68
93
|
async flush() {
|
|
94
|
+
// Merge any persisted events from a previous session before flushing
|
|
95
|
+
await this.loadPersistedBuffer();
|
|
69
96
|
if (this.buffer.size === 0 || this.flushing)
|
|
70
97
|
return;
|
|
71
98
|
this.flushing = true;
|
|
@@ -88,6 +115,8 @@ export class TelemetryReporter {
|
|
|
88
115
|
if (!response.ok) {
|
|
89
116
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
90
117
|
}
|
|
118
|
+
// Success — clear persisted buffer since events are now server-side
|
|
119
|
+
this.persistBuffer(); // buffer is empty at this point, so this clears the key
|
|
91
120
|
}
|
|
92
121
|
catch {
|
|
93
122
|
for (const [dedupKey, event] of bufferedEntries) {
|
|
@@ -138,4 +167,111 @@ export class TelemetryReporter {
|
|
|
138
167
|
this.flushTimer = null;
|
|
139
168
|
}
|
|
140
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
172
|
+
* Fire-and-forget — never throws or blocks.
|
|
173
|
+
*
|
|
174
|
+
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
175
|
+
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
176
|
+
*/
|
|
177
|
+
persistBuffer() {
|
|
178
|
+
if (!this.loadedPersisted)
|
|
179
|
+
return; // Don't overwrite orphaned prior-session data before it's loaded
|
|
180
|
+
try {
|
|
181
|
+
const entries = {};
|
|
182
|
+
for (const [key, event] of this.buffer) {
|
|
183
|
+
entries[key] = event;
|
|
184
|
+
}
|
|
185
|
+
getStorage().then(s => {
|
|
186
|
+
if (Object.keys(entries).length > 0) {
|
|
187
|
+
s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
s.removeItem(TELEMETRY_PERSIST_KEY);
|
|
191
|
+
}
|
|
192
|
+
}).catch(() => { });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Never throw from telemetry
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Load persisted events from a previous session and merge into current buffer.
|
|
200
|
+
* Runs once per instance. If orphaned events are found, emits an
|
|
201
|
+
* `sdk_session_recovered` event so monitoring can flag that the prior
|
|
202
|
+
* session ended without a clean telemetry flush.
|
|
203
|
+
*/
|
|
204
|
+
async loadPersistedBuffer() {
|
|
205
|
+
if (this.loadedPersisted)
|
|
206
|
+
return;
|
|
207
|
+
if (this.loadPersistedPromise) {
|
|
208
|
+
await this.loadPersistedPromise;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.loadPersistedPromise = (async () => {
|
|
212
|
+
try {
|
|
213
|
+
const storage = await getStorage();
|
|
214
|
+
const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
|
|
215
|
+
if (!raw)
|
|
216
|
+
return;
|
|
217
|
+
const entries = JSON.parse(raw);
|
|
218
|
+
let orphanedCount = 0;
|
|
219
|
+
const orphanedTypes = [];
|
|
220
|
+
for (const [key, event] of Object.entries(entries)) {
|
|
221
|
+
if (event.session_id === this.sessionId)
|
|
222
|
+
continue; // Same session, skip
|
|
223
|
+
orphanedCount++;
|
|
224
|
+
orphanedTypes.push(event.event_type);
|
|
225
|
+
// Merge orphaned events into the current buffer
|
|
226
|
+
const existing = this.buffer.get(key);
|
|
227
|
+
if (existing) {
|
|
228
|
+
existing.event_count += event.event_count;
|
|
229
|
+
if (event.first_occurred_at < existing.first_occurred_at) {
|
|
230
|
+
existing.first_occurred_at = event.first_occurred_at;
|
|
231
|
+
}
|
|
232
|
+
if (event.last_occurred_at > existing.last_occurred_at) {
|
|
233
|
+
existing.last_occurred_at = event.last_occurred_at;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
this.buffer.set(key, event);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Emit sdk_session_recovered if we recovered orphaned events from a previous session
|
|
241
|
+
if (orphanedCount > 0) {
|
|
242
|
+
const now = new Date().toISOString();
|
|
243
|
+
const uniqueTypes = [...new Set(orphanedTypes)];
|
|
244
|
+
const crashEvent = {
|
|
245
|
+
event_type: 'sdk_session_recovered',
|
|
246
|
+
component: 'TelemetryReporter',
|
|
247
|
+
error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
|
|
248
|
+
sdk_platform: Platform.OS,
|
|
249
|
+
sdk_version: SDK_VERSION,
|
|
250
|
+
os_name: Platform.OS,
|
|
251
|
+
os_version: String(Platform.Version),
|
|
252
|
+
session_id: this.sessionId,
|
|
253
|
+
event_count: 1,
|
|
254
|
+
first_occurred_at: now,
|
|
255
|
+
last_occurred_at: now,
|
|
256
|
+
};
|
|
257
|
+
this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
|
|
258
|
+
}
|
|
259
|
+
// Clear persisted data now that it's loaded into memory. The merged
|
|
260
|
+
// buffer will be re-persisted below until a flush succeeds.
|
|
261
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Never throw from telemetry
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
this.loadedPersisted = true;
|
|
268
|
+
this.loadPersistedPromise = null;
|
|
269
|
+
if (this.buffer.size > 0 && !this.disposed) {
|
|
270
|
+
this.persistBuffer();
|
|
271
|
+
this.scheduleFlush();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
})();
|
|
275
|
+
await this.loadPersistedPromise;
|
|
276
|
+
}
|
|
141
277
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -12,12 +12,16 @@ export interface VerificationRequest {
|
|
|
12
12
|
policy: string;
|
|
13
13
|
metadata?: Record<string, unknown>;
|
|
14
14
|
provider?: 'openai' | 'anthropic' | 'gemini';
|
|
15
|
+
/** When true, the response includes the image as a base64 data URI in `image_data`. */
|
|
16
|
+
include_image_data?: boolean;
|
|
15
17
|
}
|
|
16
18
|
export interface MultipartVerificationRequest {
|
|
17
19
|
imageUri: string;
|
|
18
20
|
policy: string;
|
|
19
21
|
metadata?: Record<string, unknown>;
|
|
20
22
|
provider?: 'openai' | 'anthropic' | 'gemini';
|
|
23
|
+
/** When true, the response includes the image as a base64 data URI in `image_data`. */
|
|
24
|
+
include_image_data?: boolean;
|
|
21
25
|
}
|
|
22
26
|
export interface VerificationResult {
|
|
23
27
|
id: string;
|
|
@@ -30,6 +34,8 @@ export interface VerificationResult {
|
|
|
30
34
|
feedback: string;
|
|
31
35
|
metadata: Record<string, unknown>;
|
|
32
36
|
image_url: string | null;
|
|
37
|
+
/** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
|
|
38
|
+
image_data?: string;
|
|
33
39
|
/** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
|
|
34
40
|
category?: string;
|
|
35
41
|
}
|
|
@@ -70,6 +76,23 @@ export interface VerifyAIError {
|
|
|
70
76
|
method?: string;
|
|
71
77
|
}
|
|
72
78
|
export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
|
|
79
|
+
/**
|
|
80
|
+
* Theme customization for the scanner overlay.
|
|
81
|
+
*
|
|
82
|
+
* All properties are optional — unset values fall back to built-in defaults.
|
|
83
|
+
* Pass a `ScannerTheme` to `ScannerOverlayConfig.theme` to match the scanner
|
|
84
|
+
* to your app's brand.
|
|
85
|
+
*/
|
|
86
|
+
export interface ScannerTheme {
|
|
87
|
+
/** Color for success states (checkmark circle, success card accent). Default: '#22C55E'. */
|
|
88
|
+
successColor?: string;
|
|
89
|
+
/** Color for failure/error states (warning icon, error card accent). Default: '#F59E0B'. */
|
|
90
|
+
failureColor?: string;
|
|
91
|
+
/** Color for the capture button circle. Default: '#FFFFFF'. */
|
|
92
|
+
captureButtonColor?: string;
|
|
93
|
+
/** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
|
|
94
|
+
cornerColor?: string;
|
|
95
|
+
}
|
|
73
96
|
export interface ScannerOverlayConfig {
|
|
74
97
|
title?: string;
|
|
75
98
|
instructions?: string;
|
|
@@ -99,6 +122,8 @@ export interface ScannerOverlayConfig {
|
|
|
99
122
|
maxAttempts?: number;
|
|
100
123
|
autoApproveOnExhaust?: boolean;
|
|
101
124
|
showTechnicalErrorDetails?: boolean;
|
|
125
|
+
/** Custom theme for scanner colors. */
|
|
126
|
+
theme?: ScannerTheme;
|
|
102
127
|
}
|
|
103
128
|
export interface PolicyConfigResponse {
|
|
104
129
|
maxAttempts: number;
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.
|
|
1
|
+
export declare const SDK_VERSION = "2.4.1";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.4.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchlabs/verify-ai-react-native",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "React Native SDK for Verify AI - photo verification with AI vision processing",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"types": "./lib/index.d.ts",
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
"jpeg-js": "^0.4.4"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"react": ">=
|
|
43
|
-
"react-native": ">=0.72.0",
|
|
42
|
+
"@react-native-async-storage/async-storage": ">=1.19.0",
|
|
44
43
|
"expo-camera": ">=15.0.0",
|
|
44
|
+
"expo-file-system": ">=17.0.0",
|
|
45
45
|
"expo-image-manipulator": ">=12.0.0",
|
|
46
|
-
"
|
|
47
|
-
"react-native
|
|
48
|
-
"
|
|
46
|
+
"react": ">=18.0.0",
|
|
47
|
+
"react-native": ">=0.72.0",
|
|
48
|
+
"react-native-fast-tflite": ">=1.0.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependenciesMeta": {
|
|
51
51
|
"expo-camera": {
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@types/jpeg-js": "^0.3.7",
|
|
69
68
|
"@react-native-async-storage/async-storage": "^2.1.0",
|
|
69
|
+
"@types/jpeg-js": "^0.3.7",
|
|
70
70
|
"@types/react": "^18.2.0",
|
|
71
71
|
"expo-camera": "^16.0.0",
|
|
72
72
|
"expo-file-system": "^18.0.12",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"react": "^18.2.0",
|
|
75
75
|
"react-native": "^0.76.0",
|
|
76
76
|
"react-native-fast-tflite": "^1.6.1",
|
|
77
|
-
"typescript": "^5.5.0"
|
|
77
|
+
"typescript": "^5.5.0",
|
|
78
|
+
"vitest": "^4.1.0"
|
|
78
79
|
}
|
|
79
80
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -173,13 +173,16 @@ export class VerifyAIClient {
|
|
|
173
173
|
: normalized.status === 429
|
|
174
174
|
? 'rate_limited'
|
|
175
175
|
: normalized.status >= 400 && normalized.status < 500
|
|
176
|
-
|
|
176
|
+
? 'request_error'
|
|
177
177
|
: 'server_error';
|
|
178
|
+
const telemetryError = eventType === 'request_timeout'
|
|
179
|
+
? `${normalized.message} [${context.method} ${context.path}; timeout=${this.timeout}ms]`
|
|
180
|
+
: normalized;
|
|
178
181
|
this.telemetry.track(
|
|
179
182
|
eventType,
|
|
180
183
|
{
|
|
181
184
|
component: 'client',
|
|
182
|
-
error:
|
|
185
|
+
error: telemetryError,
|
|
183
186
|
errorCode,
|
|
184
187
|
},
|
|
185
188
|
);
|
|
@@ -253,6 +256,9 @@ export class VerifyAIClient {
|
|
|
253
256
|
if (request.provider) {
|
|
254
257
|
formData.append('provider', request.provider);
|
|
255
258
|
}
|
|
259
|
+
if (request.include_image_data) {
|
|
260
|
+
formData.append('include_image_data', 'true');
|
|
261
|
+
}
|
|
256
262
|
|
|
257
263
|
const headers: Record<string, string> = {
|
|
258
264
|
'X-API-Key': this.apiKey,
|
|
@@ -283,6 +289,7 @@ export class VerifyAIClient {
|
|
|
283
289
|
policy: request.policy,
|
|
284
290
|
metadata: request.metadata,
|
|
285
291
|
provider: request.provider,
|
|
292
|
+
include_image_data: request.include_image_data,
|
|
286
293
|
});
|
|
287
294
|
} catch {
|
|
288
295
|
// If the retry itself fails, throw the original error
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
TouchableOpacity,
|
|
6
6
|
StyleSheet,
|
|
7
7
|
ActivityIndicator,
|
|
8
|
+
AppState,
|
|
9
|
+
useWindowDimensions,
|
|
8
10
|
type ViewStyle,
|
|
9
11
|
} from 'react-native';
|
|
10
12
|
import {
|
|
@@ -20,11 +22,11 @@ import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
|
20
22
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
21
23
|
|
|
22
24
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
23
|
-
const FALLBACK_QUALITY = 0.
|
|
25
|
+
const FALLBACK_QUALITY = 0.65;
|
|
24
26
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
25
|
-
const MANIPULATOR_QUALITY = 0.
|
|
27
|
+
const MANIPULATOR_QUALITY = 0.8;
|
|
26
28
|
/** Max dimension (px) on longest side when resize is available. */
|
|
27
|
-
const MAX_DIMENSION =
|
|
29
|
+
const MAX_DIMENSION = 1600;
|
|
28
30
|
|
|
29
31
|
export interface VerifyAIScannerProps {
|
|
30
32
|
/** Called with base64 image data when the user captures a photo. */
|
|
@@ -152,17 +154,54 @@ export function VerifyAIScanner({
|
|
|
152
154
|
const cameraInitFailedRef = useRef(false);
|
|
153
155
|
const permissionDeniedTrackedRef = useRef(false);
|
|
154
156
|
|
|
157
|
+
// Track dimensions to detect orientation changes and remount camera
|
|
158
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
159
|
+
const prevDimensionsRef = useRef({ width: windowWidth, height: windowHeight });
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const prev = prevDimensionsRef.current;
|
|
163
|
+
const orientationChanged =
|
|
164
|
+
(prev.width > prev.height) !== (windowWidth > windowHeight);
|
|
165
|
+
prevDimensionsRef.current = { width: windowWidth, height: windowHeight };
|
|
166
|
+
|
|
167
|
+
if (orientationChanged && !terminated) {
|
|
168
|
+
// Force camera remount to fix preview distortion on iOS
|
|
169
|
+
setCameraReady(false);
|
|
170
|
+
cameraReadyRef.current = false;
|
|
171
|
+
setCameraKey((k) => k + 1);
|
|
172
|
+
}
|
|
173
|
+
}, [windowWidth, windowHeight, terminated]);
|
|
174
|
+
|
|
175
|
+
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (terminated) return;
|
|
178
|
+
|
|
179
|
+
const subscription = AppState.addEventListener('change', (nextState) => {
|
|
180
|
+
if (nextState === 'active') {
|
|
181
|
+
// Force camera remount — on iOS, AVCaptureSession often fails to resume
|
|
182
|
+
// its preview layer after returning from the notification bar or control center.
|
|
183
|
+
setCameraReady(false);
|
|
184
|
+
cameraReadyRef.current = false;
|
|
185
|
+
setCameraKey((k) => k + 1);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return () => subscription.remove();
|
|
190
|
+
}, [terminated]);
|
|
191
|
+
|
|
192
|
+
const pausePreview = useCallback(() => {
|
|
193
|
+
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
155
196
|
// Release camera (and torch) when a terminal result is reached or on unmount.
|
|
156
197
|
const releaseCamera = useCallback(() => {
|
|
157
198
|
setTerminated(true);
|
|
158
|
-
|
|
159
|
-
}, []);
|
|
199
|
+
pausePreview();
|
|
200
|
+
}, [pausePreview]);
|
|
160
201
|
|
|
161
202
|
useEffect(() => {
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
};
|
|
165
|
-
}, []);
|
|
203
|
+
return pausePreview;
|
|
204
|
+
}, [pausePreview]);
|
|
166
205
|
|
|
167
206
|
// Camera init callbacks
|
|
168
207
|
const onCameraReady = useCallback(() => {
|
|
@@ -453,10 +492,10 @@ export function VerifyAIScanner({
|
|
|
453
492
|
</View>
|
|
454
493
|
)}
|
|
455
494
|
{/* Corner brackets */}
|
|
456
|
-
<View style={[styles.corner, styles.cornerTopLeft]} />
|
|
457
|
-
<View style={[styles.corner, styles.cornerTopRight]} />
|
|
458
|
-
<View style={[styles.corner, styles.cornerBottomLeft]} />
|
|
459
|
-
<View style={[styles.corner, styles.cornerBottomRight]} />
|
|
495
|
+
<View style={[styles.corner, styles.cornerTopLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
|
|
496
|
+
<View style={[styles.corner, styles.cornerTopRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
|
|
497
|
+
<View style={[styles.corner, styles.cornerBottomLeft, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
|
|
498
|
+
<View style={[styles.corner, styles.cornerBottomRight, overlay.theme?.cornerColor ? { borderColor: overlay.theme.cornerColor } : undefined]} />
|
|
460
499
|
</View>
|
|
461
500
|
{overlay.guideCaption && (
|
|
462
501
|
<Text style={styles.guideCaptionText}>{overlay.guideCaption}</Text>
|
|
@@ -486,6 +525,8 @@ export function VerifyAIScanner({
|
|
|
486
525
|
style={[
|
|
487
526
|
styles.resultIconCircle,
|
|
488
527
|
result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
|
|
528
|
+
result.is_compliant && overlay?.theme?.successColor ? { backgroundColor: overlay.theme.successColor } : undefined,
|
|
529
|
+
!result.is_compliant && overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined,
|
|
489
530
|
]}
|
|
490
531
|
>
|
|
491
532
|
<Text style={styles.resultIcon}>
|
|
@@ -496,6 +537,8 @@ export function VerifyAIScanner({
|
|
|
496
537
|
style={[
|
|
497
538
|
styles.resultLabel,
|
|
498
539
|
result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
|
|
540
|
+
result.is_compliant && overlay?.theme?.successColor ? { color: overlay.theme.successColor } : undefined,
|
|
541
|
+
!result.is_compliant && overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined,
|
|
499
542
|
]}
|
|
500
543
|
>
|
|
501
544
|
{result.is_compliant
|
|
@@ -534,10 +577,10 @@ export function VerifyAIScanner({
|
|
|
534
577
|
return (
|
|
535
578
|
<View style={styles.resultCard}>
|
|
536
579
|
<View style={styles.resultCardHeader}>
|
|
537
|
-
<View style={[styles.resultIconCircle, styles.resultIconError]}>
|
|
580
|
+
<View style={[styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined]}>
|
|
538
581
|
<Text style={styles.resultIcon}>!</Text>
|
|
539
582
|
</View>
|
|
540
|
-
<Text style={[styles.resultLabel, styles.resultLabelError]}>
|
|
583
|
+
<Text style={[styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined]}>
|
|
541
584
|
{errorTitle}
|
|
542
585
|
</Text>
|
|
543
586
|
</View>
|
|
@@ -558,6 +601,7 @@ export function VerifyAIScanner({
|
|
|
558
601
|
<TouchableOpacity
|
|
559
602
|
style={[
|
|
560
603
|
styles.captureButton,
|
|
604
|
+
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
561
605
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
562
606
|
styles.captureButtonDisabled,
|
|
563
607
|
]}
|
|
@@ -565,7 +609,7 @@ export function VerifyAIScanner({
|
|
|
565
609
|
disabled={!cameraReady || status === 'capturing' || status === 'processing'}
|
|
566
610
|
activeOpacity={0.7}
|
|
567
611
|
>
|
|
568
|
-
<View style={styles.captureButtonInner} />
|
|
612
|
+
<View style={[styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined]} />
|
|
569
613
|
</TouchableOpacity>
|
|
570
614
|
</View>
|
|
571
615
|
)}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
import { SDK_VERSION } from '../version';
|
|
3
3
|
|
|
4
|
+
let _storage: typeof import('@react-native-async-storage/async-storage').default | null = null;
|
|
5
|
+
async function getStorage() {
|
|
6
|
+
if (!_storage) {
|
|
7
|
+
const mod = await import('@react-native-async-storage/async-storage');
|
|
8
|
+
_storage = mod.default;
|
|
9
|
+
}
|
|
10
|
+
return _storage;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
14
|
+
|
|
4
15
|
interface TelemetryEvent {
|
|
5
16
|
event_type: string;
|
|
6
17
|
component?: string;
|
|
@@ -38,11 +49,15 @@ export class TelemetryReporter {
|
|
|
38
49
|
private apiKey: string;
|
|
39
50
|
private disposed = false;
|
|
40
51
|
private flushing = false;
|
|
52
|
+
private loadedPersisted = false;
|
|
53
|
+
private loadPersistedPromise: Promise<void> | null = null;
|
|
41
54
|
|
|
42
55
|
constructor(apiKey: string, baseUrl: string) {
|
|
43
56
|
this.apiKey = apiKey;
|
|
44
57
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
45
58
|
this.sessionId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
59
|
+
// Load any persisted events left behind by a previous session
|
|
60
|
+
this.loadPersistedBuffer();
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
/** Track an error event. Fire-and-forget — never throws. */
|
|
@@ -100,6 +115,18 @@ export class TelemetryReporter {
|
|
|
100
115
|
} else {
|
|
101
116
|
this.scheduleFlush();
|
|
102
117
|
}
|
|
118
|
+
|
|
119
|
+
// Persist immediately once prior-session recovery has completed. If startup
|
|
120
|
+
// recovery is still in flight, write the merged buffer back afterwards.
|
|
121
|
+
if (this.loadedPersisted) {
|
|
122
|
+
this.persistBuffer();
|
|
123
|
+
} else {
|
|
124
|
+
void this.loadPersistedBuffer().then(() => {
|
|
125
|
+
if (!this.disposed && this.buffer.size > 0) {
|
|
126
|
+
this.persistBuffer();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
103
130
|
} catch {
|
|
104
131
|
// Never throw from telemetry
|
|
105
132
|
}
|
|
@@ -107,6 +134,9 @@ export class TelemetryReporter {
|
|
|
107
134
|
|
|
108
135
|
/** Flush all buffered events immediately. Returns a promise but never rejects. */
|
|
109
136
|
async flush(): Promise<void> {
|
|
137
|
+
// Merge any persisted events from a previous session before flushing
|
|
138
|
+
await this.loadPersistedBuffer();
|
|
139
|
+
|
|
110
140
|
if (this.buffer.size === 0 || this.flushing) return;
|
|
111
141
|
|
|
112
142
|
this.flushing = true;
|
|
@@ -131,6 +161,9 @@ export class TelemetryReporter {
|
|
|
131
161
|
if (!response.ok) {
|
|
132
162
|
throw new Error(`Telemetry request failed with status ${response.status}`);
|
|
133
163
|
}
|
|
164
|
+
|
|
165
|
+
// Success — clear persisted buffer since events are now server-side
|
|
166
|
+
this.persistBuffer(); // buffer is empty at this point, so this clears the key
|
|
134
167
|
} catch {
|
|
135
168
|
for (const [dedupKey, event] of bufferedEntries) {
|
|
136
169
|
const existing = this.buffer.get(dedupKey);
|
|
@@ -181,4 +214,110 @@ export class TelemetryReporter {
|
|
|
181
214
|
this.flushTimer = null;
|
|
182
215
|
}
|
|
183
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Persist the current buffer to AsyncStorage so events survive abrupt app exits.
|
|
220
|
+
* Fire-and-forget — never throws or blocks.
|
|
221
|
+
*
|
|
222
|
+
* IMPORTANT: skips persist if loadPersistedBuffer() hasn't completed yet,
|
|
223
|
+
* to avoid overwriting orphaned events from a previous session before recovery.
|
|
224
|
+
*/
|
|
225
|
+
private persistBuffer(): void {
|
|
226
|
+
if (!this.loadedPersisted) return; // Don't overwrite orphaned prior-session data before it's loaded
|
|
227
|
+
try {
|
|
228
|
+
const entries: Record<string, TelemetryEvent> = {};
|
|
229
|
+
for (const [key, event] of this.buffer) {
|
|
230
|
+
entries[key] = event;
|
|
231
|
+
}
|
|
232
|
+
getStorage().then(s => {
|
|
233
|
+
if (Object.keys(entries).length > 0) {
|
|
234
|
+
s.setItem(TELEMETRY_PERSIST_KEY, JSON.stringify(entries));
|
|
235
|
+
} else {
|
|
236
|
+
s.removeItem(TELEMETRY_PERSIST_KEY);
|
|
237
|
+
}
|
|
238
|
+
}).catch(() => { /* never throw from telemetry */ });
|
|
239
|
+
} catch {
|
|
240
|
+
// Never throw from telemetry
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Load persisted events from a previous session and merge into current buffer.
|
|
246
|
+
* Runs once per instance. If orphaned events are found, emits an
|
|
247
|
+
* `sdk_session_recovered` event so monitoring can flag that the prior
|
|
248
|
+
* session ended without a clean telemetry flush.
|
|
249
|
+
*/
|
|
250
|
+
private async loadPersistedBuffer(): Promise<void> {
|
|
251
|
+
if (this.loadedPersisted) return;
|
|
252
|
+
if (this.loadPersistedPromise) {
|
|
253
|
+
await this.loadPersistedPromise;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.loadPersistedPromise = (async () => {
|
|
258
|
+
try {
|
|
259
|
+
const storage = await getStorage();
|
|
260
|
+
const raw = await storage.getItem(TELEMETRY_PERSIST_KEY);
|
|
261
|
+
if (!raw) return;
|
|
262
|
+
|
|
263
|
+
const entries = JSON.parse(raw) as Record<string, TelemetryEvent>;
|
|
264
|
+
let orphanedCount = 0;
|
|
265
|
+
const orphanedTypes: string[] = [];
|
|
266
|
+
|
|
267
|
+
for (const [key, event] of Object.entries(entries)) {
|
|
268
|
+
if (event.session_id === this.sessionId) continue; // Same session, skip
|
|
269
|
+
orphanedCount++;
|
|
270
|
+
orphanedTypes.push(event.event_type);
|
|
271
|
+
// Merge orphaned events into the current buffer
|
|
272
|
+
const existing = this.buffer.get(key);
|
|
273
|
+
if (existing) {
|
|
274
|
+
existing.event_count += event.event_count;
|
|
275
|
+
if (event.first_occurred_at < existing.first_occurred_at) {
|
|
276
|
+
existing.first_occurred_at = event.first_occurred_at;
|
|
277
|
+
}
|
|
278
|
+
if (event.last_occurred_at > existing.last_occurred_at) {
|
|
279
|
+
existing.last_occurred_at = event.last_occurred_at;
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
this.buffer.set(key, event);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Emit sdk_session_recovered if we recovered orphaned events from a previous session
|
|
287
|
+
if (orphanedCount > 0) {
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
const uniqueTypes = [...new Set(orphanedTypes)];
|
|
290
|
+
const crashEvent: TelemetryEvent = {
|
|
291
|
+
event_type: 'sdk_session_recovered',
|
|
292
|
+
component: 'TelemetryReporter',
|
|
293
|
+
error_message: `Recovered ${orphanedCount} unflushed event(s) from a previous session that ended without a clean telemetry flush: ${uniqueTypes.join(', ')}`,
|
|
294
|
+
sdk_platform: Platform.OS,
|
|
295
|
+
sdk_version: SDK_VERSION,
|
|
296
|
+
os_name: Platform.OS,
|
|
297
|
+
os_version: String(Platform.Version),
|
|
298
|
+
session_id: this.sessionId,
|
|
299
|
+
event_count: 1,
|
|
300
|
+
first_occurred_at: now,
|
|
301
|
+
last_occurred_at: now,
|
|
302
|
+
};
|
|
303
|
+
this.buffer.set(`sdk_session_recovered|recovered|${this.sessionId}`, crashEvent);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Clear persisted data now that it's loaded into memory. The merged
|
|
307
|
+
// buffer will be re-persisted below until a flush succeeds.
|
|
308
|
+
await storage.removeItem(TELEMETRY_PERSIST_KEY);
|
|
309
|
+
} catch {
|
|
310
|
+
// Never throw from telemetry
|
|
311
|
+
} finally {
|
|
312
|
+
this.loadedPersisted = true;
|
|
313
|
+
this.loadPersistedPromise = null;
|
|
314
|
+
if (this.buffer.size > 0 && !this.disposed) {
|
|
315
|
+
this.persistBuffer();
|
|
316
|
+
this.scheduleFlush();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
})();
|
|
320
|
+
|
|
321
|
+
await this.loadPersistedPromise;
|
|
322
|
+
}
|
|
184
323
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface VerificationRequest {
|
|
|
14
14
|
policy: string;
|
|
15
15
|
metadata?: Record<string, unknown>;
|
|
16
16
|
provider?: 'openai' | 'anthropic' | 'gemini';
|
|
17
|
+
/** When true, the response includes the image as a base64 data URI in `image_data`. */
|
|
18
|
+
include_image_data?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface MultipartVerificationRequest {
|
|
@@ -21,6 +23,8 @@ export interface MultipartVerificationRequest {
|
|
|
21
23
|
policy: string;
|
|
22
24
|
metadata?: Record<string, unknown>;
|
|
23
25
|
provider?: 'openai' | 'anthropic' | 'gemini';
|
|
26
|
+
/** When true, the response includes the image as a base64 data URI in `image_data`. */
|
|
27
|
+
include_image_data?: boolean;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface VerificationResult {
|
|
@@ -34,6 +38,8 @@ export interface VerificationResult {
|
|
|
34
38
|
feedback: string;
|
|
35
39
|
metadata: Record<string, unknown>;
|
|
36
40
|
image_url: string | null;
|
|
41
|
+
/** Base64-encoded image data URI. Only present when `include_image_data` is requested. */
|
|
42
|
+
image_data?: string;
|
|
37
43
|
/** Classification category (e.g. good_parking, no_vehicle, poor_photo). */
|
|
38
44
|
category?: string;
|
|
39
45
|
}
|
|
@@ -81,6 +87,24 @@ export interface VerifyAIError {
|
|
|
81
87
|
|
|
82
88
|
export type ScannerStatus = 'idle' | 'capturing' | 'processing' | 'success' | 'error';
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Theme customization for the scanner overlay.
|
|
92
|
+
*
|
|
93
|
+
* All properties are optional — unset values fall back to built-in defaults.
|
|
94
|
+
* Pass a `ScannerTheme` to `ScannerOverlayConfig.theme` to match the scanner
|
|
95
|
+
* to your app's brand.
|
|
96
|
+
*/
|
|
97
|
+
export interface ScannerTheme {
|
|
98
|
+
/** Color for success states (checkmark circle, success card accent). Default: '#22C55E'. */
|
|
99
|
+
successColor?: string;
|
|
100
|
+
/** Color for failure/error states (warning icon, error card accent). Default: '#F59E0B'. */
|
|
101
|
+
failureColor?: string;
|
|
102
|
+
/** Color for the capture button circle. Default: '#FFFFFF'. */
|
|
103
|
+
captureButtonColor?: string;
|
|
104
|
+
/** Color for the guide frame corner brackets. Default: 'rgba(255, 255, 255, 0.7)'. */
|
|
105
|
+
cornerColor?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
export interface ScannerOverlayConfig {
|
|
85
109
|
title?: string;
|
|
86
110
|
instructions?: string;
|
|
@@ -110,6 +134,8 @@ export interface ScannerOverlayConfig {
|
|
|
110
134
|
maxAttempts?: number;
|
|
111
135
|
autoApproveOnExhaust?: boolean;
|
|
112
136
|
showTechnicalErrorDetails?: boolean;
|
|
137
|
+
/** Custom theme for scanner colors. */
|
|
138
|
+
theme?: ScannerTheme;
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
export interface PolicyConfigResponse {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.4.1';
|