@ateam-ai-mobile/expo 0.1.4 → 0.1.7
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/android/build.gradle +16 -0
- package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
- package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
- package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
- package/dist/bootstrap.d.mts +95 -0
- package/dist/bootstrap.d.ts +95 -0
- package/dist/bootstrap.js +2473 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bootstrap.mjs +64 -0
- package/dist/bootstrap.mjs.map +1 -0
- package/dist/chunk-4BMWVN25.mjs +91 -0
- package/dist/chunk-4BMWVN25.mjs.map +1 -0
- package/dist/chunk-4XYXJYN5.mjs +1513 -0
- package/dist/chunk-4XYXJYN5.mjs.map +1 -0
- package/dist/chunk-5HM6UA2K.mjs +571 -0
- package/dist/chunk-5HM6UA2K.mjs.map +1 -0
- package/dist/chunk-6LXF3JVX.mjs +96 -0
- package/dist/chunk-6LXF3JVX.mjs.map +1 -0
- package/dist/chunk-FF6ZZZXH.mjs +71 -0
- package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
- package/dist/chunk-KOXF2FZM.mjs +92 -0
- package/dist/chunk-KOXF2FZM.mjs.map +1 -0
- package/dist/chunk-L4V7OCJ3.mjs +71 -0
- package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
- package/dist/chunk-TZXWVYYR.mjs +67 -0
- package/dist/chunk-TZXWVYYR.mjs.map +1 -0
- package/dist/chunk-VQJPSLHP.mjs +1801 -0
- package/dist/chunk-VQJPSLHP.mjs.map +1 -0
- package/dist/chunk-WMJKH4XE.mjs +34 -0
- package/dist/chunk-WMJKH4XE.mjs.map +1 -0
- package/dist/chunk-XQSY6MHP.mjs +213 -0
- package/dist/chunk-XQSY6MHP.mjs.map +1 -0
- package/dist/createBridge.mjs +2 -0
- package/dist/createBridge.mjs.map +1 -1
- package/dist/device/createDeviceProvider.d.mts +23 -0
- package/dist/device/createDeviceProvider.d.ts +23 -0
- package/dist/device/createDeviceProvider.js +967 -0
- package/dist/device/createDeviceProvider.js.map +1 -0
- package/dist/device/createDeviceProvider.mjs +17 -0
- package/dist/device/createDeviceProvider.mjs.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
- package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
- package/dist/device-bridge/collectors/appEngagement.js +128 -0
- package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
- package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
- package/dist/device-bridge/collectors/health.d.mts +24 -0
- package/dist/device-bridge/collectors/health.d.ts +24 -0
- package/dist/device-bridge/collectors/health.js +238 -0
- package/dist/device-bridge/collectors/health.js.map +1 -0
- package/dist/device-bridge/collectors/health.mjs +18 -0
- package/dist/device-bridge/collectors/health.mjs.map +1 -0
- package/dist/device-bridge/cookies.d.mts +49 -0
- package/dist/device-bridge/cookies.d.ts +49 -0
- package/dist/device-bridge/cookies.js +90 -0
- package/dist/device-bridge/cookies.js.map +1 -0
- package/dist/device-bridge/cookies.mjs +14 -0
- package/dist/device-bridge/cookies.mjs.map +1 -0
- package/dist/device-bridge/index.d.mts +483 -0
- package/dist/device-bridge/index.d.ts +483 -0
- package/dist/device-bridge/index.js +2084 -0
- package/dist/device-bridge/index.js.map +1 -0
- package/dist/device-bridge/index.mjs +32 -0
- package/dist/device-bridge/index.mjs.map +1 -0
- package/dist/device-bridge/nfc.d.mts +30 -0
- package/dist/device-bridge/nfc.d.ts +30 -0
- package/dist/device-bridge/nfc.js +109 -0
- package/dist/device-bridge/nfc.js.map +1 -0
- package/dist/device-bridge/nfc.mjs +14 -0
- package/dist/device-bridge/nfc.mjs.map +1 -0
- package/dist/index.d.mts +493 -18
- package/dist/index.d.ts +493 -18
- package/dist/index.js +1409 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -194
- package/dist/index.mjs.map +1 -1
- package/dist/oauth/expoOAuth.d.mts +18 -0
- package/dist/oauth/expoOAuth.d.ts +18 -0
- package/dist/oauth/expoOAuth.js +102 -0
- package/dist/oauth/expoOAuth.js.map +1 -0
- package/dist/oauth/expoOAuth.mjs +8 -0
- package/dist/oauth/expoOAuth.mjs.map +1 -0
- package/dist/types-BRanJYTn.d.mts +138 -0
- package/dist/types-BRanJYTn.d.ts +138 -0
- package/dist/vision/index.d.mts +81 -0
- package/dist/vision/index.d.ts +81 -0
- package/dist/vision/index.js +101 -0
- package/dist/vision/index.js.map +1 -0
- package/dist/vision/index.mjs +28 -0
- package/dist/vision/index.mjs.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AdaVision.podspec +28 -0
- package/ios/AdaVisionModule.swift +432 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
- package/package.json +107 -7
- package/src/bootstrap.ts +159 -0
- package/src/camera/CameraTaskHost.tsx +175 -0
- package/src/camera/cameraTasks.ts +129 -0
- package/src/device/createDeviceProvider.ts +832 -0
- package/src/device-bridge/actions/calendar-actions.ts +217 -0
- package/src/device-bridge/actions/dnd.ts +74 -0
- package/src/device-bridge/actions/health-actions.ts +58 -0
- package/src/device-bridge/actions/index.ts +12 -0
- package/src/device-bridge/actions/messaging.ts +74 -0
- package/src/device-bridge/actions/navigation.ts +88 -0
- package/src/device-bridge/actions/queue-poller.ts +151 -0
- package/src/device-bridge/background.ts +137 -0
- package/src/device-bridge/biometrics.ts +64 -0
- package/src/device-bridge/capabilities.ts +218 -0
- package/src/device-bridge/collectors/appEngagement.ts +120 -0
- package/src/device-bridge/collectors/battery.ts +83 -0
- package/src/device-bridge/collectors/calendar.ts +162 -0
- package/src/device-bridge/collectors/camera.ts +27 -0
- package/src/device-bridge/collectors/connectivity.ts +60 -0
- package/src/device-bridge/collectors/contacts.ts +200 -0
- package/src/device-bridge/collectors/health.ts +299 -0
- package/src/device-bridge/collectors/index.ts +12 -0
- package/src/device-bridge/collectors/location.ts +224 -0
- package/src/device-bridge/collectors/mood.ts +141 -0
- package/src/device-bridge/collectors/notifications.ts +70 -0
- package/src/device-bridge/cookies.ts +89 -0
- package/src/device-bridge/dynamic-connector.ts +422 -0
- package/src/device-bridge/index.ts +467 -0
- package/src/device-bridge/nfc.ts +85 -0
- package/src/device-bridge/plugin.ts +71 -0
- package/src/device-bridge/relay-client.ts +237 -0
- package/src/device-bridge/scanner.ts +42 -0
- package/src/device-bridge/sharing.ts +35 -0
- package/src/device-bridge/types.ts +326 -0
- package/src/device-state/activityClassifier.ts +151 -0
- package/src/device-state/activityEngine.ts +252 -0
- package/src/device-state/collectors.ts +31 -0
- package/src/device-state/domains/activity.ts +93 -0
- package/src/device-state/domains/capability.ts +60 -0
- package/src/device-state/domains/connectivity.ts +60 -0
- package/src/device-state/domains/deviceContext.ts +55 -0
- package/src/device-state/domains/interaction.ts +59 -0
- package/src/device-state/domains/spatial.ts +283 -0
- package/src/device-state/domains/visual.ts +100 -0
- package/src/device-state/headingSource.ts +84 -0
- package/src/device-state/index.ts +77 -0
- package/src/device-state/quality.ts +60 -0
- package/src/device-state/replay.ts +220 -0
- package/src/device-state/turnDetector.ts +121 -0
- package/src/device-state/visualEngine.ts +341 -0
- package/src/haptics.ts +4 -0
- package/src/index.ts +37 -0
- package/src/oauth/expoOAuth.tsx +113 -0
- package/src/vision/analysis.js +80 -0
- package/src/vision/index.ts +144 -0
- package/src/vision/ring.js +35 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK device-primitive provider (Expo-backed).
|
|
3
|
+
*
|
|
4
|
+
* `createDeviceProvider()` builds the FULL, UNGATED `PluginNative` implementation
|
|
5
|
+
* a host registers once via `setPluginNativeProvider`. Per-plugin capability
|
|
6
|
+
* gating and per-plugin filesystem scoping are applied downstream by
|
|
7
|
+
* plugin-runtime's `gatePluginNative` — this module does not gate.
|
|
8
|
+
*
|
|
9
|
+
* Not a React hook: it holds no React state and must be safe to build at module
|
|
10
|
+
* scope, including on a headless background wake before any UI exists.
|
|
11
|
+
*/
|
|
12
|
+
import * as Haptics from 'expo-haptics';
|
|
13
|
+
// Both are already app dependencies and already used by the device-bridge
|
|
14
|
+
// collectors — safe for eager import (unlike cookies/nfc, which need native
|
|
15
|
+
// modules absent from Expo Go).
|
|
16
|
+
import * as ExpoLocation from 'expo-location';
|
|
17
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
18
|
+
import * as Notifications from 'expo-notifications';
|
|
19
|
+
import * as DocumentPicker from 'expo-document-picker';
|
|
20
|
+
import * as FS from 'expo-file-system';
|
|
21
|
+
import * as SQLite from 'expo-sqlite';
|
|
22
|
+
// The broker behind scanner.scanOnce() and camera.recordVideo(): both need a
|
|
23
|
+
// MOUNTED camera, which a function call cannot provide.
|
|
24
|
+
import { cameraTasks } from '../camera/cameraTasks';
|
|
25
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
26
|
+
// native.vision: the on-device analyzer (ada-vision module) — resolve a frame id
|
|
27
|
+
// to its local pixels, measure/read/classify on-device, never off the phone.
|
|
28
|
+
import * as adaVision from '../vision';
|
|
29
|
+
// Device-bridge owns every native module. plugin-sdk delegates so RN
|
|
30
|
+
// plugins never import native modules directly. When adding a new
|
|
31
|
+
// capability: add it to device-bridge, import here, declare in types.ts,
|
|
32
|
+
// wire in the factory below.
|
|
33
|
+
//
|
|
34
|
+
// IMPORTANT: cookies and nfc use native modules NOT available in Expo Go.
|
|
35
|
+
// We lazy-import them so the app doesn't crash at load time.
|
|
36
|
+
// Only biometrics (expo-local-authentication), sharing (expo-sharing),
|
|
37
|
+
// and scanner (expo-camera) are safe for eager import.
|
|
38
|
+
import {
|
|
39
|
+
biometrics as deviceBiometrics,
|
|
40
|
+
sharing as deviceSharing,
|
|
41
|
+
scanner as deviceScanner,
|
|
42
|
+
} from '../device-bridge';
|
|
43
|
+
|
|
44
|
+
// Lazy loaders for native modules not in Expo Go
|
|
45
|
+
function getDeviceCookies() {
|
|
46
|
+
try { return require('../device-bridge/cookies'); } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
function getDeviceNfc() {
|
|
49
|
+
try { return require('../device-bridge/nfc'); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
import type {
|
|
52
|
+
NativeCapabilities,
|
|
53
|
+
PluginNative,
|
|
54
|
+
CameraAPI,
|
|
55
|
+
LocationAPI,
|
|
56
|
+
StorageAPI,
|
|
57
|
+
BiometricsAPI,
|
|
58
|
+
BiometricsAPIv2,
|
|
59
|
+
NotificationsAPI,
|
|
60
|
+
HapticsAPI,
|
|
61
|
+
ConnectivityAPI,
|
|
62
|
+
FileSystemAPI,
|
|
63
|
+
CookiesAPI,
|
|
64
|
+
WebViewCookie,
|
|
65
|
+
SharingAPI,
|
|
66
|
+
ScannerAPI,
|
|
67
|
+
NfcAPI,
|
|
68
|
+
BluetoothAPI,
|
|
69
|
+
VisionAPI,
|
|
70
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
71
|
+
|
|
72
|
+
class CapabilityUnavailableError extends Error {
|
|
73
|
+
code = 'UNAVAILABLE' as const;
|
|
74
|
+
constructor(capability: string, reason?: string) {
|
|
75
|
+
super(reason || `${capability} is not yet implemented. Coming in Phase 2.`);
|
|
76
|
+
this.name = 'CapabilityUnavailableError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class CapabilityNotDeclaredError extends Error {
|
|
81
|
+
code = 'NOT_DECLARED' as const;
|
|
82
|
+
constructor(capability: string) {
|
|
83
|
+
super(`Capability '${capability}' not declared in plugin manifest. Add it to the native.${capability} field.`);
|
|
84
|
+
this.name = 'CapabilityNotDeclaredError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stubApi<T extends object>(capability: string): T {
|
|
89
|
+
return new Proxy({} as T, {
|
|
90
|
+
get(_target, prop) {
|
|
91
|
+
if (typeof prop === 'string') {
|
|
92
|
+
return () => {
|
|
93
|
+
throw new CapabilityUnavailableError(capability);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Location (expo-location — was stubbed, is real now) ───────────
|
|
101
|
+
//
|
|
102
|
+
// `location` was wired to stubApi, so a plugin that correctly DECLARED
|
|
103
|
+
// `location: true` still got "location is not yet implemented. Coming in
|
|
104
|
+
// Phase 2." on every call. expo-location has been a dependency all along and
|
|
105
|
+
// the device-bridge collectors use it; only the plugin-facing API was missing.
|
|
106
|
+
|
|
107
|
+
function createLocationAPI(): LocationAPI {
|
|
108
|
+
/** Ask once, and say what the user can do about a refusal. */
|
|
109
|
+
const ensurePermission = async (): Promise<void> => {
|
|
110
|
+
const existing = await ExpoLocation.getForegroundPermissionsAsync();
|
|
111
|
+
if (existing.status === 'granted') return;
|
|
112
|
+
const asked = await ExpoLocation.requestForegroundPermissionsAsync();
|
|
113
|
+
if (asked.status !== 'granted') {
|
|
114
|
+
// A voice assistant reads this out, so it has to be actionable rather
|
|
115
|
+
// than a status code.
|
|
116
|
+
throw new Error(
|
|
117
|
+
'Location permission was not granted. Enable it for this app in Settings > Privacy > Location Services.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
async getCurrent(opts) {
|
|
124
|
+
await ensurePermission();
|
|
125
|
+
// 'high' costs battery and a second or two of settling; default balanced,
|
|
126
|
+
// which is plenty for "which street am I on".
|
|
127
|
+
const fix = await ExpoLocation.getCurrentPositionAsync({
|
|
128
|
+
accuracy:
|
|
129
|
+
opts?.accuracy === 'high'
|
|
130
|
+
? ExpoLocation.Accuracy.High
|
|
131
|
+
: ExpoLocation.Accuracy.Balanced,
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
latitude: fix.coords.latitude,
|
|
135
|
+
longitude: fix.coords.longitude,
|
|
136
|
+
altitude: fix.coords.altitude ?? undefined,
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
watchPosition(cb) {
|
|
141
|
+
let subscription: { remove(): void } | null = null;
|
|
142
|
+
let cancelled = false;
|
|
143
|
+
|
|
144
|
+
void (async () => {
|
|
145
|
+
try {
|
|
146
|
+
await ensurePermission();
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
subscription = await ExpoLocation.watchPositionAsync(
|
|
149
|
+
{ accuracy: ExpoLocation.Accuracy.Balanced },
|
|
150
|
+
fix => cb({ latitude: fix.coords.latitude, longitude: fix.coords.longitude }),
|
|
151
|
+
);
|
|
152
|
+
// Unsubscribed while we were still asking for permission — otherwise
|
|
153
|
+
// the GPS stays on with nobody listening.
|
|
154
|
+
if (cancelled) { subscription?.remove(); subscription = null; }
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
console.warn('[location] watchPosition failed:', err?.message || String(err));
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
subscription?.remove();
|
|
163
|
+
subscription = null;
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Camera (expo-image-picker — was stubbed, is real now) ─────────
|
|
170
|
+
//
|
|
171
|
+
// Every method here opens the SYSTEM camera or picker UI, which is why they
|
|
172
|
+
// are honest one-shot actions rather than a frame source: expo-camera can
|
|
173
|
+
// deliver a live feed, but only through a MOUNTED CameraView component, which
|
|
174
|
+
// a headless tool call has no way to mount. A continuous visual feed is the
|
|
175
|
+
// semantic Visual domain's job (Device Bridge v2), not this API's.
|
|
176
|
+
|
|
177
|
+
function createCameraAPI(): CameraAPI {
|
|
178
|
+
const ensurePermission = async (): Promise<void> => {
|
|
179
|
+
const existing = await ImagePicker.getCameraPermissionsAsync();
|
|
180
|
+
if (existing.granted) return;
|
|
181
|
+
const asked = await ImagePicker.requestCameraPermissionsAsync();
|
|
182
|
+
if (!asked.granted) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
'Camera permission was not granted. Enable it for this app in Settings > Privacy > Camera.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
async takePhoto(opts) {
|
|
191
|
+
await ensurePermission();
|
|
192
|
+
const res = await ImagePicker.launchCameraAsync({
|
|
193
|
+
quality: opts?.quality ?? 0.7,
|
|
194
|
+
cameraType:
|
|
195
|
+
opts?.facing === 'front'
|
|
196
|
+
? ImagePicker.CameraType.front
|
|
197
|
+
: ImagePicker.CameraType.back,
|
|
198
|
+
});
|
|
199
|
+
// The user backing out is not an error — but it is also not a photo, so
|
|
200
|
+
// it must not come back as an empty-looking success.
|
|
201
|
+
if (res.canceled || !res.assets?.[0]) {
|
|
202
|
+
throw new Error('No photo taken — the camera was dismissed.');
|
|
203
|
+
}
|
|
204
|
+
const a = res.assets[0];
|
|
205
|
+
return { uri: a.uri, width: a.width, height: a.height };
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async scanBarcode(opts) {
|
|
209
|
+
// Kept refusing, and now the refusal has somewhere to send you. Scanning
|
|
210
|
+
// is a mounted camera lifecycle, which is why it lives on `scanner`.
|
|
211
|
+
// Silently forwarding would leave a misleading name in every plugin.
|
|
212
|
+
throw new CapabilityUnavailableError(
|
|
213
|
+
'camera.scanBarcode',
|
|
214
|
+
'Use native.scanner.scanOnce() instead — scanning needs a mounted camera, ' +
|
|
215
|
+
'which this call cannot provide. Same arguments, and it returns { data, format }.' +
|
|
216
|
+
(opts?.formats?.length ? ' Pass your formats through to scanOnce.' : ''),
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async recordVideo(opts) {
|
|
221
|
+
// A FILE the user deliberately makes — not the Visual domain's frame
|
|
222
|
+
// ring, which is ephemeral and nobody keeps. Runs on the shared camera
|
|
223
|
+
// surface, so it throws the same coded failures as scanOnce.
|
|
224
|
+
return cameraTasks.run('record', {
|
|
225
|
+
maxDurationMs: opts?.maxDurationMs,
|
|
226
|
+
withAudio: opts?.withAudio ?? false,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async pickImage(opts) {
|
|
231
|
+
const existing = await ImagePicker.getMediaLibraryPermissionsAsync();
|
|
232
|
+
if (!existing.granted) {
|
|
233
|
+
const asked = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
234
|
+
if (!asked.granted) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
'Photo library permission was not granted. Enable it for this app in Settings > Privacy > Photos.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const res = await ImagePicker.launchImageLibraryAsync({
|
|
241
|
+
allowsMultipleSelection: !!opts?.multiple,
|
|
242
|
+
});
|
|
243
|
+
if (res.canceled) return [];
|
|
244
|
+
return (res.assets || []).map(a => ({ uri: a.uri }));
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Storage (AsyncStorage — was stubbed) ──────────────────────────
|
|
250
|
+
//
|
|
251
|
+
// KV is real. `db.execute` is NOT: expo-sqlite is not installed, and a fake
|
|
252
|
+
// SQL layer over AsyncStorage would be a worse lie than an error — it would
|
|
253
|
+
// accept a query and return an empty rowset, which reads as "no rows matched".
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One SQLite file for plugin storage, opened lazily and once.
|
|
257
|
+
*
|
|
258
|
+
* Lazy because opening a database at module load costs startup time for every
|
|
259
|
+
* user who never touches a plugin that needs one; once because two handles to
|
|
260
|
+
* the same file is how you get a locked database under concurrent writes.
|
|
261
|
+
*/
|
|
262
|
+
// Typed `any` for the handle: expo-sqlite is an any-shim in this SDK repo's
|
|
263
|
+
// typecheck (the host supplies the real types), so `SQLite.SQLiteDatabase` is
|
|
264
|
+
// not a nameable type here. Runtime is unchanged — SQLite.openDatabaseAsync
|
|
265
|
+
// still returns a real SQLiteDatabase on device.
|
|
266
|
+
// One handle PER plugin database file, opened lazily and once each. A Map (not a
|
|
267
|
+
// single promise) because storage is now per-plugin: each plugin gets its own
|
|
268
|
+
// file, and two handles to the same file is how you get a locked database.
|
|
269
|
+
const _pluginDbs = new Map<string, Promise<any>>();
|
|
270
|
+
function openPluginDb(file: string): Promise<any> {
|
|
271
|
+
const existing = _pluginDbs.get(file);
|
|
272
|
+
if (existing) return existing;
|
|
273
|
+
const opened: Promise<any> = SQLite.openDatabaseAsync(file);
|
|
274
|
+
_pluginDbs.set(file, opened);
|
|
275
|
+
return opened;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The leading SQL keyword, uppercased, after stripping leading whitespace and
|
|
279
|
+
// leading `-- line` / `/* block */` comments — so a comment cannot smuggle the
|
|
280
|
+
// verb past the cross-file check (e.g. `/* */ ATTACH …`).
|
|
281
|
+
function sqlLeadingKeyword(sql: string): string {
|
|
282
|
+
let s = String(sql);
|
|
283
|
+
for (;;) {
|
|
284
|
+
const before = s;
|
|
285
|
+
s = s.replace(/^\s+/, '');
|
|
286
|
+
if (s.startsWith('--')) { const nl = s.indexOf('\n'); s = nl === -1 ? '' : s.slice(nl + 1); }
|
|
287
|
+
else if (s.startsWith('/*')) { const end = s.indexOf('*/'); s = end === -1 ? '' : s.slice(end + 2); }
|
|
288
|
+
if (s === before) break;
|
|
289
|
+
}
|
|
290
|
+
const m = s.match(/^[A-Za-z]+/);
|
|
291
|
+
return m ? m[0].toUpperCase() : '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// R3 SECURITY — per-plugin storage isolation. Both the KV namespace and the
|
|
295
|
+
// SQLite database are scoped to the plugin's OWN id, so Plugin A cannot read,
|
|
296
|
+
// overwrite, or query Plugin B's entries/tables. The id is threaded in from the
|
|
297
|
+
// TRUSTED gate (storageFor(pluginId)) — never a caller-supplied prefix.
|
|
298
|
+
//
|
|
299
|
+
// The id is encoded with encodeURIComponent (not used raw) precisely because KV
|
|
300
|
+
// is a FLAT concatenated keyspace with no structural boundary: a raw id like
|
|
301
|
+
// `mcp:conn:plug` shares the `:` delimiter, so `@plugin:mcp:conn:plug:<k>` from
|
|
302
|
+
// one plugin could collide with another plugin's id+key split. Encoding escapes
|
|
303
|
+
// `:` (→ %3A), making the id segment unambiguous and the mapping injective, so
|
|
304
|
+
// distinct ids can never produce overlapping namespaces regardless of the key.
|
|
305
|
+
// (The filesystem sandbox can use the raw id as a directory name because a
|
|
306
|
+
// directory boundary is structural, not delimiter-based.) The same encoded
|
|
307
|
+
// token names the plugin's database file, giving each plugin a distinct DB.
|
|
308
|
+
function createStorageAPI(pluginId: string): StorageAPI {
|
|
309
|
+
const ns = encodeURIComponent(pluginId);
|
|
310
|
+
const key = (k: string) => `@plugin:${ns}:${k}`;
|
|
311
|
+
const dbFile = `plugin-storage-${ns}.db`;
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
get: (k) => AsyncStorage.getItem(key(k)),
|
|
315
|
+
set: (k, v) => AsyncStorage.setItem(key(k), v),
|
|
316
|
+
delete: (k) => AsyncStorage.removeItem(key(k)),
|
|
317
|
+
db: {
|
|
318
|
+
async execute(sql: string, params?: any[]) {
|
|
319
|
+
const verb = sqlLeadingKeyword(sql);
|
|
320
|
+
// Per-plugin storage isolation rests on each plugin owning its OWN
|
|
321
|
+
// database FILE. SQLite can cross files — ATTACH mounts another db,
|
|
322
|
+
// VACUUM INTO writes one — so the file boundary only holds if those are
|
|
323
|
+
// refused. Otherwise a plugin could `ATTACH '.../plugin-storage-<other
|
|
324
|
+
// id>.db'` (the id is public and the filename deterministic) and read or
|
|
325
|
+
// overwrite another plugin's tables. Refuse cross-file statements at the
|
|
326
|
+
// trusted gate; a plugin never needs them for its own single database.
|
|
327
|
+
// (runAsync/getAllAsync compile only the FIRST statement, so a trailing
|
|
328
|
+
// `; ATTACH …` never runs — the leading verb is the whole surface.)
|
|
329
|
+
// ATTACH/DETACH/VACUUM are the only cross-FILE statements in the standard
|
|
330
|
+
// expo-sqlite build: loadable extensions are disabled and the file-vtab
|
|
331
|
+
// modules (fileio/csv/zipfile) are not compiled in, so there is no other
|
|
332
|
+
// way for a plugin's own connection to reach another plugin's file. If a
|
|
333
|
+
// future SQLite build enables those, revisit (an allowlist would be the
|
|
334
|
+
// durable form).
|
|
335
|
+
if (verb === 'ATTACH' || verb === 'DETACH' || verb === 'VACUUM') {
|
|
336
|
+
throw new CapabilityUnavailableError(
|
|
337
|
+
'storage.db',
|
|
338
|
+
`SQL '${verb}' is not permitted: a plugin's storage is confined to its own database and cannot reach another file.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const db = await openPluginDb(dbFile);
|
|
342
|
+
// A statement either RETURNS ROWS or CHANGES THEM, and the two need
|
|
343
|
+
// different calls. Deciding by the leading keyword rather than by
|
|
344
|
+
// trying one and falling back: a failed INSERT retried as a query
|
|
345
|
+
// would run it twice.
|
|
346
|
+
const returnsRows = verb === 'SELECT' || verb === 'PRAGMA' || verb === 'WITH';
|
|
347
|
+
|
|
348
|
+
if (returnsRows) {
|
|
349
|
+
const rows = await db.getAllAsync(sql, (params ?? []) as any);
|
|
350
|
+
// changes: 0 is the truth for a SELECT, not a placeholder.
|
|
351
|
+
return { rows, changes: 0 };
|
|
352
|
+
}
|
|
353
|
+
const res = await db.runAsync(sql, (params ?? []) as any);
|
|
354
|
+
// rows: [] is likewise true for a write — it did not select anything.
|
|
355
|
+
return { rows: [], changes: res.changes ?? 0 };
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Wipe ALL plugin storage — KV, SQLite, and the file sandbox — across EVERY
|
|
363
|
+
* plugin. Per-plugin isolation (R3) keeps plugins apart from each other; this is
|
|
364
|
+
* the USER boundary: on sign-out it must run so the NEXT user/tenant cannot read
|
|
365
|
+
* the previous one's plugin data. Best-effort per store — a hiccup clearing one
|
|
366
|
+
* must never block sign-out — so each part is guarded independently.
|
|
367
|
+
*/
|
|
368
|
+
export async function clearAllPluginData(): Promise<void> {
|
|
369
|
+
// 1. KV — every `@plugin:` namespace (all plugins, all keys).
|
|
370
|
+
try {
|
|
371
|
+
const keys = await AsyncStorage.getAllKeys();
|
|
372
|
+
const mine = keys.filter((k: string) => k.startsWith('@plugin:'));
|
|
373
|
+
if (mine.length) await AsyncStorage.multiRemove(mine);
|
|
374
|
+
} catch (e: any) {
|
|
375
|
+
console.warn('[adas] clearAllPluginData: KV clear failed:', e?.message || e);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 2. SQLite — close any open per-plugin handles, then delete the db files.
|
|
379
|
+
try {
|
|
380
|
+
for (const [, p] of _pluginDbs) {
|
|
381
|
+
try { const db = await p; await db.closeAsync?.(); } catch { /* already closed */ }
|
|
382
|
+
}
|
|
383
|
+
_pluginDbs.clear();
|
|
384
|
+
const sqliteDir = new FS.Directory(FS.Paths.document, 'SQLite');
|
|
385
|
+
if (sqliteDir.exists) {
|
|
386
|
+
for (const entry of sqliteDir.list()) {
|
|
387
|
+
if (/^plugin-storage-.*\.db$/.test(String(entry.name ?? ''))) {
|
|
388
|
+
try { entry.delete(); } catch { /* best effort */ }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} catch (e: any) {
|
|
393
|
+
console.warn('[adas] clearAllPluginData: SQLite clear failed:', e?.message || e);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 3. Files — the whole plugin-files/ sandbox tree (all plugins).
|
|
397
|
+
try {
|
|
398
|
+
const dir = new FS.Directory(FS.Paths.document, 'plugin-files');
|
|
399
|
+
if (dir.exists) dir.delete();
|
|
400
|
+
} catch (e: any) {
|
|
401
|
+
console.warn('[adas] clearAllPluginData: file sandbox clear failed:', e?.message || e);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Notifications (expo-notifications — was stubbed) ──────────────
|
|
406
|
+
|
|
407
|
+
function createNotificationsAPI(): NotificationsAPI {
|
|
408
|
+
return {
|
|
409
|
+
async schedule({ title, body, trigger }) {
|
|
410
|
+
const perm = await Notifications.getPermissionsAsync();
|
|
411
|
+
if (!perm.granted) {
|
|
412
|
+
const asked = await Notifications.requestPermissionsAsync();
|
|
413
|
+
if (!asked.granted) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
'Notification permission was not granted. Enable it for this app in Settings > Notifications.',
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// `seconds` and `date` are the two the contract declares; anything else
|
|
420
|
+
// would be this API inventing a scheduling model of its own.
|
|
421
|
+
const when: any =
|
|
422
|
+
typeof trigger?.seconds === 'number'
|
|
423
|
+
? { type: 'timeInterval', seconds: trigger.seconds, repeats: false }
|
|
424
|
+
: trigger?.date
|
|
425
|
+
? { type: 'date', date: new Date(trigger.date) }
|
|
426
|
+
: null;
|
|
427
|
+
if (!when) {
|
|
428
|
+
throw new Error('schedule() needs trigger.seconds or trigger.date.');
|
|
429
|
+
}
|
|
430
|
+
return Notifications.scheduleNotificationAsync({
|
|
431
|
+
content: { title, body },
|
|
432
|
+
trigger: when,
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
cancel: (id) => Notifications.cancelScheduledNotificationAsync(id),
|
|
437
|
+
|
|
438
|
+
onReceived(cb) {
|
|
439
|
+
const sub = Notifications.addNotificationReceivedListener(n => {
|
|
440
|
+
cb({
|
|
441
|
+
id: n.request.identifier,
|
|
442
|
+
title: n.request.content.title || '',
|
|
443
|
+
body: n.request.content.body || '',
|
|
444
|
+
data: n.request.content.data,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
return () => sub.remove();
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── File system (expo-document-picker — partially real) ───────────
|
|
453
|
+
//
|
|
454
|
+
// pickDocument works. read/write do NOT: expo-file-system is not installed.
|
|
455
|
+
// Named honestly rather than returning empty strings, which would read as an
|
|
456
|
+
// empty file rather than a missing capability.
|
|
457
|
+
|
|
458
|
+
function createFileSystemAPI(pluginId: string): FileSystemAPI {
|
|
459
|
+
/**
|
|
460
|
+
* Every path a plugin gives us is resolved INSIDE its own sandbox directory.
|
|
461
|
+
*
|
|
462
|
+
* A plugin is code a solution ships that runs on someone's phone, so
|
|
463
|
+
* `readFile('/etc/passwd')` — or, more realistically, reading the app's own
|
|
464
|
+
* auth store or another solution's data — must not be reachable. This is the
|
|
465
|
+
* same reasoning that took the `sw.*` filesystem tools out of the kernel and
|
|
466
|
+
* anchored them per tenant.
|
|
467
|
+
*
|
|
468
|
+
* The check is on the RESOLVED path, not the input string: `..` segments are
|
|
469
|
+
* what a blocklist of prefixes misses, and `plugin-files/../../secrets` looks
|
|
470
|
+
* innocent until it is normalised.
|
|
471
|
+
*/
|
|
472
|
+
/**
|
|
473
|
+
* Resolved on FIRST USE, never at construction.
|
|
474
|
+
*
|
|
475
|
+
* `new FS.Directory(FS.Paths.document, …)` is a SYNCHRONOUS NATIVE CALL, and
|
|
476
|
+
* `createNativeCapabilities({ fileSystem: true, … })` runs at MODULE SCOPE in
|
|
477
|
+
* src/adas.ts. Building it eagerly therefore did native work during startup
|
|
478
|
+
* module evaluation — on every launch, including a headless background wake
|
|
479
|
+
* from a push, before any UI exists. If the native module is missing or
|
|
480
|
+
* `Paths.document` throws there, the whole app dies with no screen to show it
|
|
481
|
+
* on. Nothing in this factory may touch native until a method is called.
|
|
482
|
+
*/
|
|
483
|
+
let _root: InstanceType<typeof FS.Directory> | null = null;
|
|
484
|
+
const rootDir = () => (_root ??= new FS.Directory(FS.Paths.document, 'plugin-files', pluginId));
|
|
485
|
+
|
|
486
|
+
const resolve = (p: string): InstanceType<typeof FS.File> => {
|
|
487
|
+
const root = rootDir();
|
|
488
|
+
const file = new FS.File(root, p);
|
|
489
|
+
if (!file.uri.startsWith(root.uri)) {
|
|
490
|
+
throw new CapabilityUnavailableError(
|
|
491
|
+
'fileSystem',
|
|
492
|
+
`Path escapes the plugin's storage area: ${p}. Plugin files live under plugin-files/ and paths cannot traverse out of it.`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return file;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
async readFile(path) {
|
|
500
|
+
const file = resolve(path);
|
|
501
|
+
if (!file.exists) {
|
|
502
|
+
// A missing file is not an empty file. Returning '' would read as a
|
|
503
|
+
// document that exists and happens to have nothing in it.
|
|
504
|
+
throw new CapabilityUnavailableError(
|
|
505
|
+
'fileSystem.readFile',
|
|
506
|
+
`No such file: ${path}. Nothing was read.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return file.text();
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async writeFile(path, content) {
|
|
513
|
+
const file = resolve(path);
|
|
514
|
+
// Create parents as needed — a plugin writing 'cache/report.json' should
|
|
515
|
+
// not have to know that directories are a separate concept.
|
|
516
|
+
const parent = file.parentDirectory;
|
|
517
|
+
if (!parent.exists) parent.create({ intermediates: true });
|
|
518
|
+
if (!file.exists) file.create();
|
|
519
|
+
file.write(content);
|
|
520
|
+
},
|
|
521
|
+
async pickDocument(opts) {
|
|
522
|
+
const res = await DocumentPicker.getDocumentAsync({
|
|
523
|
+
type: opts?.types?.length ? opts.types : '*/*',
|
|
524
|
+
copyToCacheDirectory: true,
|
|
525
|
+
});
|
|
526
|
+
// Backing out is not a file. Returning zeros here would look like an
|
|
527
|
+
// empty document the user actually chose.
|
|
528
|
+
if (res.canceled || !res.assets?.[0]) {
|
|
529
|
+
throw new Error('No document chosen — the picker was dismissed.');
|
|
530
|
+
}
|
|
531
|
+
const a = res.assets[0];
|
|
532
|
+
return { uri: a.uri, name: a.name, size: a.size ?? 0 };
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Haptics (fully implemented — expo-haptics already installed) ───
|
|
538
|
+
|
|
539
|
+
function createHapticsAPI(): HapticsAPI {
|
|
540
|
+
return {
|
|
541
|
+
impact(style: 'light' | 'medium' | 'heavy') {
|
|
542
|
+
const map = {
|
|
543
|
+
light: Haptics.ImpactFeedbackStyle.Light,
|
|
544
|
+
medium: Haptics.ImpactFeedbackStyle.Medium,
|
|
545
|
+
heavy: Haptics.ImpactFeedbackStyle.Heavy,
|
|
546
|
+
};
|
|
547
|
+
Haptics.impactAsync(map[style]);
|
|
548
|
+
},
|
|
549
|
+
notification(type: 'success' | 'warning' | 'error') {
|
|
550
|
+
const map = {
|
|
551
|
+
success: Haptics.NotificationFeedbackType.Success,
|
|
552
|
+
warning: Haptics.NotificationFeedbackType.Warning,
|
|
553
|
+
error: Haptics.NotificationFeedbackType.Error,
|
|
554
|
+
};
|
|
555
|
+
Haptics.notificationAsync(map[type]);
|
|
556
|
+
},
|
|
557
|
+
// Shorthand for notification('error'). Plugins call this; it was never
|
|
558
|
+
// implemented, and optional chaining meant the failure the user was meant
|
|
559
|
+
// to FEEL simply did not happen.
|
|
560
|
+
error() {
|
|
561
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
562
|
+
},
|
|
563
|
+
selection() {
|
|
564
|
+
Haptics.selectionAsync();
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── Connectivity (implemented using basic RN fetch check) ──────
|
|
570
|
+
|
|
571
|
+
function createConnectivityAPI(reachabilityUrl?: string): ConnectivityAPI {
|
|
572
|
+
let online = true;
|
|
573
|
+
const listeners = new Set<(online: boolean) => void>();
|
|
574
|
+
// Lazy + re-armable polling: the instance is now a singleton shared by ALL
|
|
575
|
+
// plugins (injected once via setPluginNativeProvider), so the interval must
|
|
576
|
+
// (a) not run at app boot when nobody is listening, and (b) restart when a
|
|
577
|
+
// later plugin subscribes after an earlier one unsubscribed to zero.
|
|
578
|
+
// Pre-singleton, each Plugin mount got its own instance and clearInterval
|
|
579
|
+
// was harmless; with a shared instance it would kill polling app-wide.
|
|
580
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
581
|
+
|
|
582
|
+
// Simple connectivity check — upgrade to @react-native-community/netinfo in Phase 2.
|
|
583
|
+
// NO hardcoded URL: the host injects its own health endpoint. With none given
|
|
584
|
+
// we assume reachable (return true) rather than pin an A-Team-specific default.
|
|
585
|
+
const checkOnline = async () => {
|
|
586
|
+
if (!reachabilityUrl) return true;
|
|
587
|
+
try {
|
|
588
|
+
const res = await fetch(reachabilityUrl, {
|
|
589
|
+
method: 'HEAD',
|
|
590
|
+
signal: AbortSignal.timeout(5000),
|
|
591
|
+
});
|
|
592
|
+
return res.ok;
|
|
593
|
+
} catch {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const startPolling = () => {
|
|
599
|
+
// Nothing to poll without a reachability URL: `online` stays constant-true,
|
|
600
|
+
// so an interval would only wake the app to learn the same answer.
|
|
601
|
+
if (!reachabilityUrl) return;
|
|
602
|
+
if (interval) return;
|
|
603
|
+
interval = setInterval(async () => {
|
|
604
|
+
const newState = await checkOnline();
|
|
605
|
+
if (newState !== online) {
|
|
606
|
+
online = newState;
|
|
607
|
+
listeners.forEach(cb => cb(online));
|
|
608
|
+
}
|
|
609
|
+
}, 30000);
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
isOnline() {
|
|
614
|
+
return online;
|
|
615
|
+
},
|
|
616
|
+
onChange(cb: (online: boolean) => void) {
|
|
617
|
+
listeners.add(cb);
|
|
618
|
+
startPolling();
|
|
619
|
+
return () => {
|
|
620
|
+
listeners.delete(cb);
|
|
621
|
+
if (listeners.size === 0 && interval) {
|
|
622
|
+
clearInterval(interval);
|
|
623
|
+
interval = null;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Cookies (delegates to device-bridge, lazy-loaded) ──
|
|
631
|
+
function createCookiesAPI(): CookiesAPI {
|
|
632
|
+
return {
|
|
633
|
+
async get(url: string): Promise<WebViewCookie[]> {
|
|
634
|
+
const mod = getDeviceCookies();
|
|
635
|
+
if (!mod) throw new CapabilityUnavailableError('cookies (native module not available — use a dev build)');
|
|
636
|
+
const list = await mod.get(url, true);
|
|
637
|
+
return list as WebViewCookie[];
|
|
638
|
+
},
|
|
639
|
+
async clear(_url: string): Promise<{ ok: boolean }> {
|
|
640
|
+
const mod = getDeviceCookies();
|
|
641
|
+
if (!mod) throw new CapabilityUnavailableError('cookies (native module not available — use a dev build)');
|
|
642
|
+
const r = await mod.clearAll();
|
|
643
|
+
return { ok: r.ok };
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Biometrics v2 (Face ID / Touch ID / fingerprint) ──
|
|
649
|
+
function createBiometricsAPIv2(): BiometricsAPIv2 {
|
|
650
|
+
return {
|
|
651
|
+
capabilities: () => deviceBiometrics.capabilities(),
|
|
652
|
+
authenticate: (opts) => deviceBiometrics.authenticate(opts),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── Sharing (native share sheet) ──
|
|
657
|
+
function createSharingAPI(): SharingAPI {
|
|
658
|
+
return {
|
|
659
|
+
isAvailable: () => deviceSharing.isAvailable(),
|
|
660
|
+
shareFile: (fileUri, opts) => deviceSharing.shareFile(fileUri, opts),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Scanner (QR / barcode permission helper) ──
|
|
665
|
+
function createScannerAPI(): ScannerAPI {
|
|
666
|
+
return {
|
|
667
|
+
requestPermissions: () => deviceScanner.requestPermissions(),
|
|
668
|
+
// Raises the shared camera surface and settles on the first code read.
|
|
669
|
+
// The plugin mounts nothing — that lifecycle is owned once, by
|
|
670
|
+
// CameraTaskHost, instead of by every plugin that wants to scan.
|
|
671
|
+
scanOnce: (opts) => cameraTasks.run('scan', {
|
|
672
|
+
formats: opts?.formats,
|
|
673
|
+
timeoutMs: opts?.timeoutMs,
|
|
674
|
+
}),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── NFC (tag read, lazy-loaded) ──
|
|
679
|
+
function createNfcAPI(): NfcAPI {
|
|
680
|
+
return {
|
|
681
|
+
async isSupported() {
|
|
682
|
+
const mod = getDeviceNfc();
|
|
683
|
+
if (!mod) return false;
|
|
684
|
+
return mod.isSupported();
|
|
685
|
+
},
|
|
686
|
+
async isEnabled() {
|
|
687
|
+
const mod = getDeviceNfc();
|
|
688
|
+
if (!mod) return false;
|
|
689
|
+
return mod.isEnabled();
|
|
690
|
+
},
|
|
691
|
+
readOnce(timeoutMs) {
|
|
692
|
+
const mod = getDeviceNfc();
|
|
693
|
+
if (!mod) return Promise.resolve({ ok: false, error: 'nfc_unavailable' } as any);
|
|
694
|
+
return mod.readOnce(timeoutMs);
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── Bluetooth / BLE (SHAPE ONLY — every method refuses) ──
|
|
700
|
+
//
|
|
701
|
+
// Nothing implements this yet, and it is wired up anyway. `bluetooth` has been
|
|
702
|
+
// a manifest flag with no API behind it, so a plugin that correctly declared it
|
|
703
|
+
// got `undefined` and crashed with "cannot read properties of undefined" — a
|
|
704
|
+
// failure that names nothing and teaches nothing.
|
|
705
|
+
//
|
|
706
|
+
// Refusing BY NAME is the whole difference. The rule this layer runs on is that
|
|
707
|
+
// absence is an answer, but only when it says what is absent.
|
|
708
|
+
//
|
|
709
|
+
// KEPT rather than dropped because BLE beacons are one of the few indoor
|
|
710
|
+
// position signals that survive where GPS does not — which is precisely where
|
|
711
|
+
// Ada Guide ends up the moment the user walks into a building.
|
|
712
|
+
function createBluetoothAPI(): BluetoothAPI {
|
|
713
|
+
const refuse = (method: string) =>
|
|
714
|
+
new CapabilityUnavailableError(
|
|
715
|
+
`bluetooth.${method}`,
|
|
716
|
+
'Bluetooth is not implemented in this build — no BLE native module is installed. ' +
|
|
717
|
+
'The capability is declared so it fails by name rather than silently; nothing was scanned.',
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
// These two answer `false` rather than throwing, because "is BLE usable?"
|
|
722
|
+
// is exactly the question a plugin should be allowed to ask before
|
|
723
|
+
// committing to a flow, and `false` is the true answer here.
|
|
724
|
+
async isSupported() { return false; },
|
|
725
|
+
async isEnabled() { return false; },
|
|
726
|
+
// scan() THROWS, because there is no honest empty result: an empty device
|
|
727
|
+
// list is a real finding ("nothing is advertising nearby") and returning
|
|
728
|
+
// one would report a scan that never happened.
|
|
729
|
+
async scan() { throw refuse('scan'); },
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
// ── Vision — on-device image understanding (SHAPE ONLY) ──
|
|
735
|
+
//
|
|
736
|
+
// Nothing implements this yet; every method refuses BY NAME. Declared now
|
|
737
|
+
// because it is the last missing piece of Ada Guide and a solution should be
|
|
738
|
+
// able to design against it before the native side exists.
|
|
739
|
+
//
|
|
740
|
+
// WHY IT IS NOT IN THE VISUAL DOMAIN: the freeze reserves Visual for the frame
|
|
741
|
+
// TIMELINE and puts everything interpretive — what is in the scene, what the
|
|
742
|
+
// sign says — ABOVE Device Bridge. This is that layer.
|
|
743
|
+
//
|
|
744
|
+
// FRAMES MUST NEVER LEAVE THE PHONE (Arie, 2026-08-15). Whoever implements
|
|
745
|
+
// this: iOS Vision (VNRecognizeTextRequest / VNClassifyImageRequest) or
|
|
746
|
+
// Android MLKit, both on-device and free per call. An implementation that
|
|
747
|
+
// POSTs the frame anywhere satisfies these types and violates the design.
|
|
748
|
+
function createVisionAPI(): VisionAPI {
|
|
749
|
+
// All three methods are implemented on-device via the ada-vision native module:
|
|
750
|
+
// assessQuality (pixel statistics), readText (VNRecognizeText), describe
|
|
751
|
+
// (VNClassifyImage). Frames never leave the phone.
|
|
752
|
+
return {
|
|
753
|
+
// "Can I see?" — true once the native module is present in the binary. On a
|
|
754
|
+
// build without it, adaVision.isSupported() answers false, and assessQuality
|
|
755
|
+
// below refuses cleanly (no crash), exactly the pre-module behaviour.
|
|
756
|
+
async isSupported() { return adaVision.isSupported(); },
|
|
757
|
+
// readText: on-device OCR on a published frame (native resolves the id →
|
|
758
|
+
// pixels). [] = looked, found no text (a real finding); throws = could not
|
|
759
|
+
// look (unknown/aged-out id). describe (VNClassifyImage) is the next pass.
|
|
760
|
+
async readText(frameId: string, opts?: { languages?: string[] }) {
|
|
761
|
+
return adaVision.readText(frameId, opts?.languages);
|
|
762
|
+
},
|
|
763
|
+
// describe: on-device labels (VNClassifyImage), not prose — the sentence is
|
|
764
|
+
// the skill's job. [] = looked, recognised nothing.
|
|
765
|
+
async describe(frameId: string, opts?: { maxLabels?: number }) {
|
|
766
|
+
return adaVision.describe(frameId, opts?.maxLabels ?? 5);
|
|
767
|
+
},
|
|
768
|
+
// detectObjects: on-device located objects (bundled detector), each with a
|
|
769
|
+
// box — what a guidance loop reads to say WHERE a thing is, not just that the
|
|
770
|
+
// scene contains it. [] = looked, found nothing; throws = could not look.
|
|
771
|
+
async detectObjects(frameId: string, opts?: { maxItems?: number; minConfidence?: number }) {
|
|
772
|
+
return adaVision.detectObjects(frameId, opts?.maxItems ?? 10, opts?.minConfidence ?? 0.25);
|
|
773
|
+
},
|
|
774
|
+
// assessQuality: resolve the frame id to its LOCAL file, measure on-device,
|
|
775
|
+
// and record the result back onto the frame so a later read of
|
|
776
|
+
// VisualFrameRef.quality reflects it. Throwing here means "could not look"
|
|
777
|
+
// (unknown/aged-out id, or no native module) — never a measured verdict.
|
|
778
|
+
async assessQuality(frameId: string) {
|
|
779
|
+
// Quality was measured at publish; the pixels live in the native ring,
|
|
780
|
+
// resolved by id inside native. Null = the frame is unknown or has aged out
|
|
781
|
+
// of the ring — "could not look", never "looked and found nothing".
|
|
782
|
+
const q = adaVision.qualityById(frameId);
|
|
783
|
+
if (!q) {
|
|
784
|
+
throw new CapabilityUnavailableError(
|
|
785
|
+
'vision.assessQuality',
|
|
786
|
+
`No frame for id "${frameId}" — unknown or aged out of the ring. ` +
|
|
787
|
+
'This is "could not look", not a measurement.',
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
return q;
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ── Provider factory ───────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
export interface DeviceProviderConfig {
|
|
798
|
+
/** Endpoint the connectivity check HEAD-fetches (host-injected, e.g. the app's
|
|
799
|
+
* own health URL). Omitted → connectivity assumes reachable (no hardcoded URL). */
|
|
800
|
+
reachabilityUrl?: string;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The full device-primitive implementation a host registers once via
|
|
805
|
+
* setPluginNativeProvider. It is UNGATED — per-plugin capability gating and
|
|
806
|
+
* per-plugin filesystem scoping are applied by plugin-runtime's gatePluginNative.
|
|
807
|
+
*/
|
|
808
|
+
export function createDeviceProvider(config?: DeviceProviderConfig): PluginNative {
|
|
809
|
+
const connectivity = createConnectivityAPI(config?.reachabilityUrl);
|
|
810
|
+
return {
|
|
811
|
+
camera: createCameraAPI(),
|
|
812
|
+
location: createLocationAPI(),
|
|
813
|
+
// Only the per-plugin storageFor/fileSystemFor are exposed — NO shared
|
|
814
|
+
// `storage`/`fileSystem` bucket. The gate scopes by pluginId and fails
|
|
815
|
+
// closed without one, so a shared instance would be an unreachable
|
|
816
|
+
// (and dangerous) fallback. Each plugin gets its own namespace + db file /
|
|
817
|
+
// sandbox directory, or nothing.
|
|
818
|
+
storageFor: (pluginId: string) => createStorageAPI(pluginId),
|
|
819
|
+
biometrics: stubApi<BiometricsAPI>('biometrics'),
|
|
820
|
+
biometricsV2: createBiometricsAPIv2(),
|
|
821
|
+
notifications: createNotificationsAPI(),
|
|
822
|
+
haptics: createHapticsAPI(),
|
|
823
|
+
connectivity,
|
|
824
|
+
fileSystemFor: (pluginId: string) => createFileSystemAPI(pluginId),
|
|
825
|
+
cookies: createCookiesAPI(),
|
|
826
|
+
sharing: createSharingAPI(),
|
|
827
|
+
scanner: createScannerAPI(),
|
|
828
|
+
nfc: createNfcAPI(),
|
|
829
|
+
bluetooth: createBluetoothAPI(),
|
|
830
|
+
vision: createVisionAPI(),
|
|
831
|
+
};
|
|
832
|
+
}
|