@ateam-ai-mobile/expo 0.1.5 → 0.1.8

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