@ateam-ai-mobile/expo 0.1.5 → 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,299 @@
1
+ /**
2
+ * Health Collector — reads HealthKit aggregates via @kingstinct/react-native-healthkit
3
+ * (TurboModule / Nitro-Modules based; works in React Native's New Architecture).
4
+ *
5
+ * iOS only. On Android the functions resolve with zeros so the caller doesn't
6
+ * need to platform-check.
7
+ *
8
+ * Public:
9
+ * - hasPermission() — returns true once authorization has been requested.
10
+ * - requestPermission() — triggers the iOS HealthKit permission sheet (red heart).
11
+ * - collectToday() — one HealthDayMetrics for today.
12
+ * - collectHistory(days) — array of HealthDayMetrics, newest-first.
13
+ */
14
+ import { Platform } from 'react-native';
15
+ import type { HealthDayMetrics } from '../types';
16
+
17
+ // Lazy-loaded so the module is harmless on Android / Expo Go / non-iOS.
18
+ // In Expo Go the JS module resolves but the native bridge is missing —
19
+ // resulting object exists yet methods like requestAuthorization are undefined.
20
+ let _HK: any = null;
21
+ let _hkProbed = false;
22
+ function getHK(): any | null {
23
+ if (Platform.OS !== 'ios') return null;
24
+ if (_hkProbed) return _HK;
25
+ _hkProbed = true;
26
+ try {
27
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
28
+ const mod = require('@kingstinct/react-native-healthkit');
29
+ if (mod && typeof mod.requestAuthorization === 'function') {
30
+ _HK = mod;
31
+ } else {
32
+ console.warn('[health] @kingstinct/react-native-healthkit native bridge unavailable — HealthKit disabled (Expo Go?)');
33
+ }
34
+ } catch (e) {
35
+ console.warn('[health] @kingstinct/react-native-healthkit require failed:', e);
36
+ }
37
+ return _HK;
38
+ }
39
+
40
+ // Last error from requestAuthorization — surfaced to UI so we don't fail silently.
41
+ export let lastHealthInitError: string | null = null;
42
+
43
+ const READ_TYPES = [
44
+ 'HKQuantityTypeIdentifierStepCount',
45
+ 'HKQuantityTypeIdentifierFlightsClimbed',
46
+ 'HKQuantityTypeIdentifierActiveEnergyBurned',
47
+ 'HKQuantityTypeIdentifierDistanceWalkingRunning',
48
+ // Added for mood signals — all read-only, covered by the same auth sheet.
49
+ 'HKQuantityTypeIdentifierRestingHeartRate',
50
+ 'HKQuantityTypeIdentifierAppleExerciseTime',
51
+ 'HKCategoryTypeIdentifierSleepAnalysis',
52
+ ] as const;
53
+
54
+ let _initPromise: Promise<boolean> | null = null;
55
+
56
+ /** Request HealthKit authorization once; subsequent calls reuse the result. */
57
+ async function ensureInit(): Promise<boolean> {
58
+ const HK = getHK();
59
+ if (!HK) {
60
+ lastHealthInitError = '@kingstinct/react-native-healthkit native module not loaded';
61
+ return false;
62
+ }
63
+ if (_initPromise) return _initPromise;
64
+ _initPromise = (async () => {
65
+ try {
66
+ // requestAuthorization triggers the iOS HealthKit permission sheet.
67
+ // Resolves once the user dismisses it (regardless of allow/deny).
68
+ // Returns the authorization status — but iOS deliberately does not
69
+ // disclose per-type read permission state for privacy, so we just
70
+ // treat "we asked" as "ready to read" and let reads return 0 if denied.
71
+ await HK.requestAuthorization({ toRead: READ_TYPES, toWrite: [] });
72
+ lastHealthInitError = null;
73
+ return true;
74
+ } catch (e: any) {
75
+ lastHealthInitError = `requestAuthorization threw: ${e?.message || String(e)}`;
76
+ console.warn('[health]', lastHealthInitError);
77
+ _initPromise = null;
78
+ return false;
79
+ }
80
+ })();
81
+ return _initPromise;
82
+ }
83
+
84
+ export async function hasPermission(): Promise<boolean> {
85
+ return ensureInit();
86
+ }
87
+
88
+ export async function requestPermission(): Promise<boolean> {
89
+ return ensureInit();
90
+ }
91
+
92
+ // ─── Helpers ────────────────────────────────────────────────
93
+
94
+ function startOfDay(d: Date): Date {
95
+ const out = new Date(d);
96
+ out.setHours(0, 0, 0, 0);
97
+ return out;
98
+ }
99
+
100
+ function dateKey(d: Date): string {
101
+ // Local-date YYYY-MM-DD
102
+ const y = d.getFullYear();
103
+ const m = String(d.getMonth() + 1).padStart(2, '0');
104
+ const day = String(d.getDate()).padStart(2, '0');
105
+ return `${y}-${m}-${day}`;
106
+ }
107
+
108
+ /**
109
+ * Read a single daily-aggregate sum for [start, end) in the given unit.
110
+ * Returns 0 when no data, HealthKit unavailable, or the call throws.
111
+ *
112
+ * `queryStatisticsForQuantity` is the modern statistics API; we ask only for
113
+ * the cumulative sum since that's what we need for these data types.
114
+ */
115
+ async function readDailySum(identifier: string, unit: string, start: Date, end: Date): Promise<number> {
116
+ const HK = getHK();
117
+ if (!HK?.queryStatisticsForQuantity) return 0;
118
+ try {
119
+ // The kingstinct API takes the date range nested under filter.date — not
120
+ // top-level from/to. Without filter.date the call returns the cumulative
121
+ // sum across ALL historical data, which is what we hit before this fix
122
+ // (millions of steps, thousands of km, hundreds of thousands of kcal).
123
+ const result: any = await HK.queryStatisticsForQuantity(
124
+ identifier,
125
+ ['cumulativeSum'],
126
+ {
127
+ unit,
128
+ filter: { date: { startDate: start, endDate: end } },
129
+ },
130
+ );
131
+ const q = result?.sumQuantity?.quantity;
132
+ return typeof q === 'number' ? q : 0;
133
+ } catch {
134
+ return 0;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Daily discrete-average for a quantity (e.g. resting heart rate). Returns
140
+ * null when no sample exists for the day so the backend can tell "no data"
141
+ * apart from a real zero.
142
+ */
143
+ async function readDailyAverage(identifier: string, unit: string, start: Date, end: Date): Promise<number | null> {
144
+ const HK = getHK();
145
+ if (!HK?.queryStatisticsForQuantity) return null;
146
+ try {
147
+ const result: any = await HK.queryStatisticsForQuantity(
148
+ identifier,
149
+ ['discreteAverage'],
150
+ { unit, filter: { date: { startDate: start, endDate: end } } },
151
+ );
152
+ const q = result?.averageQuantity?.quantity;
153
+ return typeof q === 'number' ? q : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Total minutes asleep for [start, end) from HKCategoryTypeIdentifierSleepAnalysis.
161
+ * Sums sample durations that represent actual sleep (any "asleep*" value),
162
+ * excluding inBed/awake. Heavily guarded — sleep-stage enums differ across iOS
163
+ * versions and the kingstinct API surface, so any failure returns null.
164
+ */
165
+ async function readSleepMinutes(start: Date, end: Date): Promise<number | null> {
166
+ const HK = getHK();
167
+ const query = HK?.queryCategorySamples || HK?.queryCategorySamplesAsync || HK?.querySamples;
168
+ if (!query) return null;
169
+ try {
170
+ const samples: any[] = await query.call(
171
+ HK,
172
+ 'HKCategoryTypeIdentifierSleepAnalysis',
173
+ { filter: { date: { startDate: start, endDate: end } } },
174
+ );
175
+ if (!Array.isArray(samples) || samples.length === 0) return null;
176
+ // value semantics: 0 = inBed, 2 = awake; everything else = a sleep stage.
177
+ // Treat anything that isn't explicitly inBed/awake as asleep.
178
+ let ms = 0;
179
+ for (const s of samples) {
180
+ const v = s?.value;
181
+ const isAsleep = v !== 0 && v !== 2; // exclude inBed + awake
182
+ if (!isAsleep) continue;
183
+ const a = new Date(s?.startDate).getTime();
184
+ const b = new Date(s?.endDate).getTime();
185
+ if (Number.isFinite(a) && Number.isFinite(b) && b > a) ms += b - a;
186
+ }
187
+ return ms > 0 ? Math.round(ms / 60000) : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Mood-relevant HealthKit extras for today: sleep minutes, resting heart rate,
195
+ * active energy (kcal), exercise minutes. Each is null when unavailable so the
196
+ * backend weights only present fields.
197
+ */
198
+ export async function collectMoodExtras(): Promise<{
199
+ sleep_min: number | null;
200
+ resting_hr: number | null;
201
+ active_energy_kcal: number | null;
202
+ exercise_min: number | null;
203
+ }> {
204
+ const ok = await ensureInit();
205
+ if (!ok) return { sleep_min: null, resting_hr: null, active_energy_kcal: null, exercise_min: null };
206
+ const start = startOfDay(new Date());
207
+ const end = new Date(start);
208
+ end.setDate(end.getDate() + 1);
209
+ // Sleep window: look back from this morning over the past night (~last 18h)
210
+ // so an evening collection still captures the prior night.
211
+ const sleepStart = new Date(start);
212
+ sleepStart.setHours(sleepStart.getHours() - 6); // include after-midnight + prev evening within today's window
213
+ const [sleep_min, resting_hr, energy, exercise] = await Promise.all([
214
+ readSleepMinutes(sleepStart, end),
215
+ readDailyAverage('HKQuantityTypeIdentifierRestingHeartRate', 'count/min', start, end),
216
+ readDailySum('HKQuantityTypeIdentifierActiveEnergyBurned', 'kcal', start, end),
217
+ readDailySum('HKQuantityTypeIdentifierAppleExerciseTime', 'min', start, end),
218
+ ]);
219
+ return {
220
+ sleep_min,
221
+ resting_hr: resting_hr != null ? Math.round(resting_hr) : null,
222
+ active_energy_kcal: energy ? Math.round(energy) : null,
223
+ exercise_min: exercise ? Math.round(exercise) : null,
224
+ };
225
+ }
226
+
227
+ async function readDay(date: Date): Promise<HealthDayMetrics> {
228
+ const start = startOfDay(date);
229
+ const end = new Date(start);
230
+ end.setDate(end.getDate() + 1);
231
+
232
+ const [steps, floors, energy, distance] = await Promise.all([
233
+ readDailySum('HKQuantityTypeIdentifierStepCount', 'count', start, end),
234
+ readDailySum('HKQuantityTypeIdentifierFlightsClimbed', 'count', start, end),
235
+ readDailySum('HKQuantityTypeIdentifierActiveEnergyBurned', 'kcal', start, end),
236
+ // Ask for kilometers directly so we don't have to convert from meters.
237
+ readDailySum('HKQuantityTypeIdentifierDistanceWalkingRunning', 'km', start, end),
238
+ ]);
239
+
240
+ return {
241
+ date: dateKey(start),
242
+ available: true,
243
+ steps: Math.round(steps),
244
+ floors: Math.round(floors),
245
+ active_energy_kcal: Math.round(energy),
246
+ distance_km: Math.round(distance * 100) / 100,
247
+ };
248
+ }
249
+
250
+ // ─── Public API ─────────────────────────────────────────────
251
+
252
+ /**
253
+ * A day we could not read: metrics null, availability stated.
254
+ *
255
+ * The distinction this preserves is the whole point — "no data" and "a day
256
+ * with no activity" look identical once you write a zero, and only one of
257
+ * them is something to tell the user about.
258
+ */
259
+ function unavailableDay(date: Date): HealthDayMetrics {
260
+ return {
261
+ date: dateKey(date),
262
+ available: false,
263
+ steps: null,
264
+ floors: null,
265
+ active_energy_kcal: null,
266
+ distance_km: null,
267
+ };
268
+ }
269
+
270
+ export async function collectToday(): Promise<HealthDayMetrics> {
271
+ const ok = await ensureInit();
272
+ if (!ok) {
273
+ // HealthKit unavailable or access denied. NOT a day with no activity —
274
+ // reporting zeros here is how "you have taken 0 steps today" reached the
275
+ // user as a fact, with a coaching skill ready to act on it.
276
+ return unavailableDay(new Date());
277
+ }
278
+ return readDay(new Date());
279
+ }
280
+
281
+ /**
282
+ * Last `days` days, newest-first. Index 0 is today.
283
+ * Caller passes 1..30 — we clamp defensively.
284
+ */
285
+ export async function collectHistory(days: number): Promise<HealthDayMetrics[]> {
286
+ const n = Math.max(1, Math.min(30, Math.floor(days)));
287
+ const ok = await ensureInit();
288
+ if (!ok) return [];
289
+
290
+ const today = startOfDay(new Date());
291
+ const out: HealthDayMetrics[] = [];
292
+ // Sequential to avoid hammering HealthKit with 30 parallel queries.
293
+ for (let i = 0; i < n; i++) {
294
+ const d = new Date(today);
295
+ d.setDate(d.getDate() - i);
296
+ out.push(await readDay(d));
297
+ }
298
+ return out;
299
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Collectors — one per device data domain.
3
+ * Each collector reads from a specific Expo API and returns data
4
+ * matching the exact schema from device-mock-mcp.
5
+ */
6
+
7
+ export * as calendar from './calendar';
8
+ export * as contacts from './contacts';
9
+ export * as location from './location';
10
+ export * as battery from './battery';
11
+ export * as connectivity from './connectivity';
12
+ export * as notifications from './notifications';
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Location Collector — reads device location via expo-location.
3
+ * Output format matches device-mock-mcp's location.current schema.
4
+ */
5
+
6
+ import * as ExpoLocation from 'expo-location';
7
+ // Type-only alias: the value namespace above cannot be used in type
8
+ // position, so `ExpoLocation.Calendar`-style annotations need this.
9
+ // Not re-exported from the package entry — derive from the API that
10
+ // returns it, so the shape follows the installed expo-location.
11
+ type LocationGeocodedAddress = Awaited<ReturnType<typeof ExpoLocation.reverseGeocodeAsync>>[number];
12
+ import type { LocationData } from '../types';
13
+
14
+ /**
15
+ * Request location permissions (foreground + background).
16
+ */
17
+ export async function requestPermission(): Promise<boolean> {
18
+ const { status: fg } = await ExpoLocation.requestForegroundPermissionsAsync();
19
+ if (fg !== 'granted') return false;
20
+
21
+ // Background is optional — skip in Expo Go (crashes without native module)
22
+ try {
23
+ await ExpoLocation.requestBackgroundPermissionsAsync();
24
+ } catch {
25
+ // Expected in Expo Go — foreground is enough for basic functionality
26
+ }
27
+ return fg === 'granted';
28
+ }
29
+
30
+ /**
31
+ * Check if location permission is granted.
32
+ */
33
+ export async function hasPermission(): Promise<boolean> {
34
+ const { status } = await ExpoLocation.getForegroundPermissionsAsync();
35
+ return status === 'granted';
36
+ }
37
+
38
+ /**
39
+ * Collect current location.
40
+ * Matches the device.location.current tool output schema.
41
+ */
42
+ export async function collectCurrent(): Promise<LocationData> {
43
+ const location = await ExpoLocation.getCurrentPositionAsync({
44
+ accuracy: ExpoLocation.Accuracy.Balanced,
45
+ });
46
+
47
+ // Reverse geocode to get address
48
+ let place = 'Current Location';
49
+ let address = `${location.coords.latitude.toFixed(4)}, ${location.coords.longitude.toFixed(4)}`;
50
+ let locationType = 'unknown';
51
+
52
+ try {
53
+ const [geocoded] = await ExpoLocation.reverseGeocodeAsync({
54
+ latitude: location.coords.latitude,
55
+ longitude: location.coords.longitude,
56
+ });
57
+
58
+ if (geocoded) {
59
+ place = geocoded.name || geocoded.street || place;
60
+ address = [
61
+ geocoded.streetNumber,
62
+ geocoded.street,
63
+ geocoded.city,
64
+ geocoded.region,
65
+ ]
66
+ .filter(Boolean)
67
+ .join(', ') || address;
68
+
69
+ // Attempt to classify location type
70
+ locationType = classifyLocation(geocoded);
71
+ }
72
+ } catch {
73
+ // Reverse geocoding can fail — use coordinates only
74
+ }
75
+
76
+ return {
77
+ ok: true,
78
+ timestamp: new Date().toISOString(),
79
+ location: {
80
+ place,
81
+ address,
82
+ lat: location.coords.latitude,
83
+ lng: location.coords.longitude,
84
+ type: locationType,
85
+ },
86
+ };
87
+ }
88
+
89
+ // ─── Plugin-facing position API ───
90
+ // `collectCurrent` above answers the device-context question ("where am I, in
91
+ // words") and reverse-geocodes. A plugin tool usually wants the raw fix and
92
+ // nothing else, so these stay separate: no geocoding round trip, no address.
93
+
94
+ export interface Position {
95
+ latitude: number;
96
+ longitude: number;
97
+ altitude?: number;
98
+ accuracy?: number;
99
+ timestamp: number;
100
+ /**
101
+ * Metres per second. `undefined` = the OS gave no estimate.
102
+ *
103
+ * iOS reports -1 rather than omitting it, and -1 m/s passed through as a
104
+ * speed would read as "moving backwards"; it is normalised away here so no
105
+ * caller has to know the platform's sentinel.
106
+ */
107
+ speed?: number;
108
+ /**
109
+ * COURSE OVER GROUND in degrees — the direction the user is TRAVELLING.
110
+ *
111
+ * Deliberately not called `heading`: this and the compass answer different
112
+ * questions and disagree constantly. Walking backwards down a corridor gives
113
+ * a course of south and a compass heading of north; a phone held sideways
114
+ * while walking forward gives the same course and a heading 90° off.
115
+ * "Turn right" needs the compass. "You are heading north on Main Street"
116
+ * needs this. The old code assigned one to the other, which is only ever
117
+ * right by accident.
118
+ *
119
+ * `undefined` when not moving or not estimated — iOS reports -1 for both.
120
+ */
121
+ course?: number;
122
+ }
123
+
124
+ /** Ensure foreground permission, asking once if we do not have it yet. */
125
+ async function ensurePermission(): Promise<void> {
126
+ if (await hasPermission()) return;
127
+ const granted = await requestPermission();
128
+ if (!granted) {
129
+ // Callers surface this verbatim to the user (a voice assistant reads it
130
+ // out), so it has to say what the user can do about it.
131
+ throw new Error(
132
+ 'Location permission was not granted. Enable it for this app in iOS Settings > Privacy > Location Services.',
133
+ );
134
+ }
135
+ }
136
+
137
+ /** One position fix. `accuracy: "high"` costs battery and time — default low. */
138
+ export async function getPosition(
139
+ opts: { accuracy?: 'low' | 'high' } = {},
140
+ ): Promise<Position> {
141
+ await ensurePermission();
142
+ const fix = await ExpoLocation.getCurrentPositionAsync({
143
+ accuracy:
144
+ opts.accuracy === 'high'
145
+ ? ExpoLocation.Accuracy.High
146
+ : ExpoLocation.Accuracy.Balanced,
147
+ });
148
+ return toPosition(fix);
149
+ }
150
+
151
+ /**
152
+ * One place that turns an expo fix into a `Position`.
153
+ *
154
+ * It exists because the sentinel handling has to be identical on the one-shot
155
+ * and the streamed path. It was not: `watchPosition` passed lat/lon only, so
156
+ * the semantic Spatial domain could never report speed or course no matter
157
+ * what the OS supplied, and reported `null` as if the phone had no GPS.
158
+ */
159
+ function toPosition(fix: any): Position {
160
+ return {
161
+ latitude: fix.coords.latitude,
162
+ longitude: fix.coords.longitude,
163
+ altitude: fix.coords.altitude ?? undefined,
164
+ accuracy: nonNegative(fix.coords.accuracy),
165
+ timestamp: fix.timestamp ?? Date.now(),
166
+ speed: nonNegative(fix.coords.speed),
167
+ course: nonNegative(fix.coords.heading),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * iOS uses -1 for "no estimate" on accuracy, speed and course alike. Dropping
173
+ * the field says "not measured"; passing -1 through says "measured, and the
174
+ * answer is minus one", which a model will happily repeat.
175
+ */
176
+ function nonNegative(v: unknown): number | undefined {
177
+ return typeof v === 'number' && v >= 0 ? v : undefined;
178
+ }
179
+
180
+ /**
181
+ * Stream position updates. Returns an unsubscribe function — call it, or the
182
+ * GPS keeps running and drains the battery.
183
+ */
184
+ export function watchPosition(cb: (pos: Position) => void): () => void {
185
+ let subscription: { remove(): void } | null = null;
186
+ let cancelled = false;
187
+
188
+ (async () => {
189
+ try {
190
+ await ensurePermission();
191
+ if (cancelled) return;
192
+ subscription = await ExpoLocation.watchPositionAsync(
193
+ { accuracy: ExpoLocation.Accuracy.Balanced },
194
+ fix => cb(toPosition(fix)),
195
+ );
196
+ // Unsubscribed while we were still asking for permission.
197
+ if (cancelled) { subscription?.remove(); subscription = null; }
198
+ } catch (err: any) {
199
+ console.warn('[location] watchPosition failed:', err?.message || String(err));
200
+ }
201
+ })();
202
+
203
+ return () => {
204
+ cancelled = true;
205
+ subscription?.remove();
206
+ subscription = null;
207
+ };
208
+ }
209
+
210
+ // ─── Helpers ───
211
+
212
+ function classifyLocation(geocoded: LocationGeocodedAddress): string {
213
+ // Simple heuristic — can be enhanced with user-labeled places from memory
214
+ const name = (geocoded.name || '').toLowerCase();
215
+ const street = (geocoded.street || '').toLowerCase();
216
+
217
+ if (name.includes('home') || name.includes('residence')) return 'home';
218
+ if (name.includes('office') || name.includes('workplace') || name.includes('tech park')) return 'work';
219
+ if (name.includes('school') || name.includes('elementary') || name.includes('university')) return 'school';
220
+ if (name.includes('gym') || name.includes('fitness')) return 'fitness';
221
+ if (name.includes('hospital') || name.includes('clinic') || name.includes('dental')) return 'medical';
222
+
223
+ return 'other';
224
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Mood Signals Collector — assembles the daily behavioral-signal summary the
3
+ * Mood Intelligence Kit's backend expects (MoodSignalToday), and emits it as
4
+ * a compact daily object. It does NOT call the kit's HTTP client; transport is
5
+ * the existing device-bridge relay path (pushed as the `mood` domain), same as
6
+ * health/location/etc. The backend reads the relay domain and runs analysis.
7
+ *
8
+ * Philosophy (from the kit's DESIGN): send a concise daily SUMMARY, never raw
9
+ * event logs. We fill the fields we can source today from existing collectors
10
+ * and leave the rest null — the backend's baseline/fallback tolerates nulls.
11
+ *
12
+ * EXACT per-field contract + where each value comes from on THIS platform
13
+ * (iOS). "blocked" = Apple does not expose it to third-party apps, so it is
14
+ * permanently null on iOS; "addable" = we have a source but haven't wired it.
15
+ *
16
+ * steps ← health (HealthKit) ✅ sourced now
17
+ * distance_km ← health (HealthKit) ✅ sourced now
18
+ * sleep_min ← health (HealthKit sleep) ✅ sourced now
19
+ * app_sessions ← our own app analytics ✅ sourced now
20
+ * app_active_min ← our own app analytics ✅ sourced now
21
+ * resting_hr (extra) ← health (HealthKit) ✅ sourced now
22
+ * active_energy_kcal(x) ← health (HealthKit) ✅ sourced now
23
+ * exercise_min (extra) ← health (HealthKit) ✅ sourced now
24
+ * places_count ← location collector (distinct) ⏳ addable (not wired)
25
+ * checkin_mood ← user self-report UI ⏳ addable (no UI yet)
26
+ * screen_min ← iOS Screen Time ❌ blocked by iOS
27
+ * night_screen_min ← iOS Screen Time ❌ blocked by iOS
28
+ * social_app_min ← other apps' usage ❌ blocked by iOS
29
+ * video_app_min ← other apps' usage ❌ blocked by iOS
30
+ * productivity_app_min ← other apps' usage ❌ blocked by iOS
31
+ * messages_sent ← Messages content/metadata ❌ blocked by iOS
32
+ * calls_count ← call log ❌ blocked by iOS
33
+ *
34
+ * (extra) = not in the kit's MoodSignalToday but sent alongside; the backend
35
+ * tolerates extra fields and these are strong mood proxies.
36
+ *
37
+ * The kit's spec listed many sources because several exist on Android
38
+ * (UsageStatsManager etc.); on iOS most are sandbox-blocked. We send what we
39
+ * truly have and leave the rest null — the backend baseline/fallback tolerates
40
+ * nulls and weights only present fields.
41
+ */
42
+ import * as health from './health';
43
+ import * as appEngagement from './appEngagement';
44
+
45
+ // Mirrors the kit's MoodSignalToday (packages/mood-client-rn/src/types.ts).
46
+ export interface MoodSignalToday {
47
+ steps: number | null;
48
+ sleep_min: number | null;
49
+ screen_min: number | null;
50
+ night_screen_min: number | null;
51
+ places_count: number | null;
52
+ distance_km: number | null;
53
+ social_app_min: number | null;
54
+ video_app_min: number | null;
55
+ productivity_app_min: number | null;
56
+ messages_sent: number | null;
57
+ calls_count: number | null;
58
+ app_sessions: number | null;
59
+ app_active_min: number | null;
60
+ checkin_mood?: 'great' | 'ok' | 'low' | 'bad' | null;
61
+ }
62
+
63
+ function emptySignals(): MoodSignalToday {
64
+ return {
65
+ steps: null,
66
+ sleep_min: null,
67
+ screen_min: null,
68
+ night_screen_min: null,
69
+ places_count: null,
70
+ distance_km: null,
71
+ social_app_min: null,
72
+ video_app_min: null,
73
+ productivity_app_min: null,
74
+ messages_sent: null,
75
+ calls_count: null,
76
+ app_sessions: null,
77
+ app_active_min: null,
78
+ checkin_mood: null,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Build today's mood signal summary from available collectors. Returns the
84
+ * full MoodSignalToday plus collection metadata the backend wants
85
+ * (window, timezone, collected_at) so it can run analyze without guessing.
86
+ */
87
+ export async function collectToday(): Promise<{
88
+ window: 'today';
89
+ timezone: string;
90
+ collected_at: string;
91
+ today: MoodSignalToday;
92
+ }> {
93
+ const today = emptySignals();
94
+
95
+ // Health-sourced fields (best-effort; collector returns zeros if HK denied).
96
+ try {
97
+ const h = await health.collectToday();
98
+ if (h) {
99
+ today.steps = typeof h.steps === 'number' ? h.steps : null;
100
+ today.distance_km = typeof h.distance_km === 'number' ? h.distance_km : null;
101
+ }
102
+ } catch {
103
+ // leave nulls — backend baseline handles missing fields
104
+ }
105
+
106
+ // HealthKit mood extras: sleep, resting HR, active energy, exercise minutes.
107
+ try {
108
+ const x = await health.collectMoodExtras();
109
+ today.sleep_min = x.sleep_min;
110
+ // resting_hr / active_energy_kcal / exercise_min aren't in the kit's
111
+ // MoodSignalToday contract, but the backend tolerates extra fields and
112
+ // they're strong mood proxies — include them alongside.
113
+ (today as any).resting_hr = x.resting_hr;
114
+ (today as any).active_energy_kcal = x.active_energy_kcal;
115
+ (today as any).exercise_min = x.exercise_min;
116
+ } catch {
117
+ // leave nulls
118
+ }
119
+
120
+ // Our own app-engagement analytics (no OS permission; the only on-iOS source
121
+ // for app_sessions / app_active_min).
122
+ try {
123
+ const e = await appEngagement.getToday();
124
+ today.app_sessions = e.app_sessions;
125
+ today.app_active_min = e.app_active_min;
126
+ } catch {
127
+ // leave nulls
128
+ }
129
+
130
+ let timezone = 'UTC';
131
+ try {
132
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
133
+ } catch { /* RN without full ICU — fall back to UTC */ }
134
+
135
+ return {
136
+ window: 'today',
137
+ timezone,
138
+ collected_at: new Date().toISOString(),
139
+ today,
140
+ };
141
+ }