@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.
Files changed (158) 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 +2473 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/bootstrap.mjs +64 -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-4XYXJYN5.mjs +1513 -0
  14. package/dist/chunk-4XYXJYN5.mjs.map +1 -0
  15. package/dist/chunk-5HM6UA2K.mjs +571 -0
  16. package/dist/chunk-5HM6UA2K.mjs.map +1 -0
  17. package/dist/chunk-6LXF3JVX.mjs +96 -0
  18. package/dist/chunk-6LXF3JVX.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-KOXF2FZM.mjs +92 -0
  22. package/dist/chunk-KOXF2FZM.mjs.map +1 -0
  23. package/dist/chunk-L4V7OCJ3.mjs +71 -0
  24. package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
  25. package/dist/chunk-TZXWVYYR.mjs +67 -0
  26. package/dist/chunk-TZXWVYYR.mjs.map +1 -0
  27. package/dist/chunk-VQJPSLHP.mjs +1801 -0
  28. package/dist/chunk-VQJPSLHP.mjs.map +1 -0
  29. package/dist/chunk-WMJKH4XE.mjs +34 -0
  30. package/dist/chunk-WMJKH4XE.mjs.map +1 -0
  31. package/dist/chunk-XQSY6MHP.mjs +213 -0
  32. package/dist/chunk-XQSY6MHP.mjs.map +1 -0
  33. package/dist/createBridge.mjs +2 -0
  34. package/dist/createBridge.mjs.map +1 -1
  35. package/dist/device/createDeviceProvider.d.mts +23 -0
  36. package/dist/device/createDeviceProvider.d.ts +23 -0
  37. package/dist/device/createDeviceProvider.js +967 -0
  38. package/dist/device/createDeviceProvider.js.map +1 -0
  39. package/dist/device/createDeviceProvider.mjs +17 -0
  40. package/dist/device/createDeviceProvider.mjs.map +1 -0
  41. package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
  42. package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
  43. package/dist/device-bridge/collectors/appEngagement.js +128 -0
  44. package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
  45. package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
  46. package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
  47. package/dist/device-bridge/collectors/health.d.mts +24 -0
  48. package/dist/device-bridge/collectors/health.d.ts +24 -0
  49. package/dist/device-bridge/collectors/health.js +238 -0
  50. package/dist/device-bridge/collectors/health.js.map +1 -0
  51. package/dist/device-bridge/collectors/health.mjs +18 -0
  52. package/dist/device-bridge/collectors/health.mjs.map +1 -0
  53. package/dist/device-bridge/cookies.d.mts +49 -0
  54. package/dist/device-bridge/cookies.d.ts +49 -0
  55. package/dist/device-bridge/cookies.js +90 -0
  56. package/dist/device-bridge/cookies.js.map +1 -0
  57. package/dist/device-bridge/cookies.mjs +14 -0
  58. package/dist/device-bridge/cookies.mjs.map +1 -0
  59. package/dist/device-bridge/index.d.mts +483 -0
  60. package/dist/device-bridge/index.d.ts +483 -0
  61. package/dist/device-bridge/index.js +2084 -0
  62. package/dist/device-bridge/index.js.map +1 -0
  63. package/dist/device-bridge/index.mjs +32 -0
  64. package/dist/device-bridge/index.mjs.map +1 -0
  65. package/dist/device-bridge/nfc.d.mts +30 -0
  66. package/dist/device-bridge/nfc.d.ts +30 -0
  67. package/dist/device-bridge/nfc.js +109 -0
  68. package/dist/device-bridge/nfc.js.map +1 -0
  69. package/dist/device-bridge/nfc.mjs +14 -0
  70. package/dist/device-bridge/nfc.mjs.map +1 -0
  71. package/dist/index.d.mts +493 -18
  72. package/dist/index.d.ts +493 -18
  73. package/dist/index.js +1409 -6
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.mjs +23 -194
  76. package/dist/index.mjs.map +1 -1
  77. package/dist/oauth/expoOAuth.d.mts +18 -0
  78. package/dist/oauth/expoOAuth.d.ts +18 -0
  79. package/dist/oauth/expoOAuth.js +102 -0
  80. package/dist/oauth/expoOAuth.js.map +1 -0
  81. package/dist/oauth/expoOAuth.mjs +8 -0
  82. package/dist/oauth/expoOAuth.mjs.map +1 -0
  83. package/dist/types-BRanJYTn.d.mts +138 -0
  84. package/dist/types-BRanJYTn.d.ts +138 -0
  85. package/dist/vision/index.d.mts +81 -0
  86. package/dist/vision/index.d.ts +81 -0
  87. package/dist/vision/index.js +101 -0
  88. package/dist/vision/index.js.map +1 -0
  89. package/dist/vision/index.mjs +28 -0
  90. package/dist/vision/index.mjs.map +1 -0
  91. package/expo-module.config.json +9 -0
  92. package/ios/AdaVision.podspec +28 -0
  93. package/ios/AdaVisionModule.swift +432 -0
  94. package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
  95. package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
  96. package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
  97. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
  98. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
  99. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
  100. package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
  101. package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
  102. package/package.json +107 -7
  103. package/src/bootstrap.ts +159 -0
  104. package/src/camera/CameraTaskHost.tsx +175 -0
  105. package/src/camera/cameraTasks.ts +129 -0
  106. package/src/device/createDeviceProvider.ts +832 -0
  107. package/src/device-bridge/actions/calendar-actions.ts +217 -0
  108. package/src/device-bridge/actions/dnd.ts +74 -0
  109. package/src/device-bridge/actions/health-actions.ts +58 -0
  110. package/src/device-bridge/actions/index.ts +12 -0
  111. package/src/device-bridge/actions/messaging.ts +74 -0
  112. package/src/device-bridge/actions/navigation.ts +88 -0
  113. package/src/device-bridge/actions/queue-poller.ts +151 -0
  114. package/src/device-bridge/background.ts +137 -0
  115. package/src/device-bridge/biometrics.ts +64 -0
  116. package/src/device-bridge/capabilities.ts +218 -0
  117. package/src/device-bridge/collectors/appEngagement.ts +120 -0
  118. package/src/device-bridge/collectors/battery.ts +83 -0
  119. package/src/device-bridge/collectors/calendar.ts +162 -0
  120. package/src/device-bridge/collectors/camera.ts +27 -0
  121. package/src/device-bridge/collectors/connectivity.ts +60 -0
  122. package/src/device-bridge/collectors/contacts.ts +200 -0
  123. package/src/device-bridge/collectors/health.ts +299 -0
  124. package/src/device-bridge/collectors/index.ts +12 -0
  125. package/src/device-bridge/collectors/location.ts +224 -0
  126. package/src/device-bridge/collectors/mood.ts +141 -0
  127. package/src/device-bridge/collectors/notifications.ts +70 -0
  128. package/src/device-bridge/cookies.ts +89 -0
  129. package/src/device-bridge/dynamic-connector.ts +422 -0
  130. package/src/device-bridge/index.ts +467 -0
  131. package/src/device-bridge/nfc.ts +85 -0
  132. package/src/device-bridge/plugin.ts +71 -0
  133. package/src/device-bridge/relay-client.ts +237 -0
  134. package/src/device-bridge/scanner.ts +42 -0
  135. package/src/device-bridge/sharing.ts +35 -0
  136. package/src/device-bridge/types.ts +326 -0
  137. package/src/device-state/activityClassifier.ts +151 -0
  138. package/src/device-state/activityEngine.ts +252 -0
  139. package/src/device-state/collectors.ts +31 -0
  140. package/src/device-state/domains/activity.ts +93 -0
  141. package/src/device-state/domains/capability.ts +60 -0
  142. package/src/device-state/domains/connectivity.ts +60 -0
  143. package/src/device-state/domains/deviceContext.ts +55 -0
  144. package/src/device-state/domains/interaction.ts +59 -0
  145. package/src/device-state/domains/spatial.ts +283 -0
  146. package/src/device-state/domains/visual.ts +100 -0
  147. package/src/device-state/headingSource.ts +84 -0
  148. package/src/device-state/index.ts +77 -0
  149. package/src/device-state/quality.ts +60 -0
  150. package/src/device-state/replay.ts +220 -0
  151. package/src/device-state/turnDetector.ts +121 -0
  152. package/src/device-state/visualEngine.ts +341 -0
  153. package/src/haptics.ts +4 -0
  154. package/src/index.ts +37 -0
  155. package/src/oauth/expoOAuth.tsx +113 -0
  156. package/src/vision/analysis.js +80 -0
  157. package/src/vision/index.ts +144 -0
  158. 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
+ }