@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.
- package/android/build.gradle +16 -0
- package/android/src/main/java/expo/modules/adavision/AdaVisionModule.kt +75 -0
- package/dist/attachmentCache-DhQTqi2E.d.mts +18 -0
- package/dist/attachmentCache-DhQTqi2E.d.ts +18 -0
- package/dist/bootstrap.d.mts +95 -0
- package/dist/bootstrap.d.ts +95 -0
- package/dist/bootstrap.js +2473 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bootstrap.mjs +64 -0
- package/dist/bootstrap.mjs.map +1 -0
- package/dist/chunk-4BMWVN25.mjs +91 -0
- package/dist/chunk-4BMWVN25.mjs.map +1 -0
- package/dist/chunk-4XYXJYN5.mjs +1513 -0
- package/dist/chunk-4XYXJYN5.mjs.map +1 -0
- package/dist/chunk-5HM6UA2K.mjs +571 -0
- package/dist/chunk-5HM6UA2K.mjs.map +1 -0
- package/dist/chunk-6LXF3JVX.mjs +96 -0
- package/dist/chunk-6LXF3JVX.mjs.map +1 -0
- package/dist/chunk-FF6ZZZXH.mjs +71 -0
- package/dist/chunk-FF6ZZZXH.mjs.map +1 -0
- package/dist/chunk-KOXF2FZM.mjs +92 -0
- package/dist/chunk-KOXF2FZM.mjs.map +1 -0
- package/dist/chunk-L4V7OCJ3.mjs +71 -0
- package/dist/chunk-L4V7OCJ3.mjs.map +1 -0
- package/dist/chunk-TZXWVYYR.mjs +67 -0
- package/dist/chunk-TZXWVYYR.mjs.map +1 -0
- package/dist/chunk-VQJPSLHP.mjs +1801 -0
- package/dist/chunk-VQJPSLHP.mjs.map +1 -0
- package/dist/chunk-WMJKH4XE.mjs +34 -0
- package/dist/chunk-WMJKH4XE.mjs.map +1 -0
- package/dist/chunk-XQSY6MHP.mjs +213 -0
- package/dist/chunk-XQSY6MHP.mjs.map +1 -0
- package/dist/createBridge.mjs +2 -0
- package/dist/createBridge.mjs.map +1 -1
- package/dist/device/createDeviceProvider.d.mts +23 -0
- package/dist/device/createDeviceProvider.d.ts +23 -0
- package/dist/device/createDeviceProvider.js +967 -0
- package/dist/device/createDeviceProvider.js.map +1 -0
- package/dist/device/createDeviceProvider.mjs +17 -0
- package/dist/device/createDeviceProvider.mjs.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.d.mts +10 -0
- package/dist/device-bridge/collectors/appEngagement.d.ts +10 -0
- package/dist/device-bridge/collectors/appEngagement.js +128 -0
- package/dist/device-bridge/collectors/appEngagement.js.map +1 -0
- package/dist/device-bridge/collectors/appEngagement.mjs +12 -0
- package/dist/device-bridge/collectors/appEngagement.mjs.map +1 -0
- package/dist/device-bridge/collectors/health.d.mts +24 -0
- package/dist/device-bridge/collectors/health.d.ts +24 -0
- package/dist/device-bridge/collectors/health.js +238 -0
- package/dist/device-bridge/collectors/health.js.map +1 -0
- package/dist/device-bridge/collectors/health.mjs +18 -0
- package/dist/device-bridge/collectors/health.mjs.map +1 -0
- package/dist/device-bridge/cookies.d.mts +49 -0
- package/dist/device-bridge/cookies.d.ts +49 -0
- package/dist/device-bridge/cookies.js +90 -0
- package/dist/device-bridge/cookies.js.map +1 -0
- package/dist/device-bridge/cookies.mjs +14 -0
- package/dist/device-bridge/cookies.mjs.map +1 -0
- package/dist/device-bridge/index.d.mts +483 -0
- package/dist/device-bridge/index.d.ts +483 -0
- package/dist/device-bridge/index.js +2084 -0
- package/dist/device-bridge/index.js.map +1 -0
- package/dist/device-bridge/index.mjs +32 -0
- package/dist/device-bridge/index.mjs.map +1 -0
- package/dist/device-bridge/nfc.d.mts +30 -0
- package/dist/device-bridge/nfc.d.ts +30 -0
- package/dist/device-bridge/nfc.js +109 -0
- package/dist/device-bridge/nfc.js.map +1 -0
- package/dist/device-bridge/nfc.mjs +14 -0
- package/dist/device-bridge/nfc.mjs.map +1 -0
- package/dist/index.d.mts +493 -18
- package/dist/index.d.ts +493 -18
- package/dist/index.js +1409 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +23 -194
- package/dist/index.mjs.map +1 -1
- package/dist/oauth/expoOAuth.d.mts +18 -0
- package/dist/oauth/expoOAuth.d.ts +18 -0
- package/dist/oauth/expoOAuth.js +102 -0
- package/dist/oauth/expoOAuth.js.map +1 -0
- package/dist/oauth/expoOAuth.mjs +8 -0
- package/dist/oauth/expoOAuth.mjs.map +1 -0
- package/dist/types-BRanJYTn.d.mts +138 -0
- package/dist/types-BRanJYTn.d.ts +138 -0
- package/dist/vision/index.d.mts +81 -0
- package/dist/vision/index.d.ts +81 -0
- package/dist/vision/index.js +101 -0
- package/dist/vision/index.js.map +1 -0
- package/dist/vision/index.mjs +28 -0
- package/dist/vision/index.mjs.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AdaVision.podspec +28 -0
- package/ios/AdaVisionModule.swift +432 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/metadata.json +773 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/analytics/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/coremldata.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/model.mil +720 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model0/weights/weight.bin +0 -0
- package/ios/Resources/yolov8n-oiv7.mlmodelc/model1/coremldata.bin +0 -0
- package/package.json +107 -7
- package/src/bootstrap.ts +159 -0
- package/src/camera/CameraTaskHost.tsx +175 -0
- package/src/camera/cameraTasks.ts +129 -0
- package/src/device/createDeviceProvider.ts +832 -0
- package/src/device-bridge/actions/calendar-actions.ts +217 -0
- package/src/device-bridge/actions/dnd.ts +74 -0
- package/src/device-bridge/actions/health-actions.ts +58 -0
- package/src/device-bridge/actions/index.ts +12 -0
- package/src/device-bridge/actions/messaging.ts +74 -0
- package/src/device-bridge/actions/navigation.ts +88 -0
- package/src/device-bridge/actions/queue-poller.ts +151 -0
- package/src/device-bridge/background.ts +137 -0
- package/src/device-bridge/biometrics.ts +64 -0
- package/src/device-bridge/capabilities.ts +218 -0
- package/src/device-bridge/collectors/appEngagement.ts +120 -0
- package/src/device-bridge/collectors/battery.ts +83 -0
- package/src/device-bridge/collectors/calendar.ts +162 -0
- package/src/device-bridge/collectors/camera.ts +27 -0
- package/src/device-bridge/collectors/connectivity.ts +60 -0
- package/src/device-bridge/collectors/contacts.ts +200 -0
- package/src/device-bridge/collectors/health.ts +299 -0
- package/src/device-bridge/collectors/index.ts +12 -0
- package/src/device-bridge/collectors/location.ts +224 -0
- package/src/device-bridge/collectors/mood.ts +141 -0
- package/src/device-bridge/collectors/notifications.ts +70 -0
- package/src/device-bridge/cookies.ts +89 -0
- package/src/device-bridge/dynamic-connector.ts +422 -0
- package/src/device-bridge/index.ts +467 -0
- package/src/device-bridge/nfc.ts +85 -0
- package/src/device-bridge/plugin.ts +71 -0
- package/src/device-bridge/relay-client.ts +237 -0
- package/src/device-bridge/scanner.ts +42 -0
- package/src/device-bridge/sharing.ts +35 -0
- package/src/device-bridge/types.ts +326 -0
- package/src/device-state/activityClassifier.ts +151 -0
- package/src/device-state/activityEngine.ts +252 -0
- package/src/device-state/collectors.ts +31 -0
- package/src/device-state/domains/activity.ts +93 -0
- package/src/device-state/domains/capability.ts +60 -0
- package/src/device-state/domains/connectivity.ts +60 -0
- package/src/device-state/domains/deviceContext.ts +55 -0
- package/src/device-state/domains/interaction.ts +59 -0
- package/src/device-state/domains/spatial.ts +283 -0
- package/src/device-state/domains/visual.ts +100 -0
- package/src/device-state/headingSource.ts +84 -0
- package/src/device-state/index.ts +77 -0
- package/src/device-state/quality.ts +60 -0
- package/src/device-state/replay.ts +220 -0
- package/src/device-state/turnDetector.ts +121 -0
- package/src/device-state/visualEngine.ts +341 -0
- package/src/haptics.ts +4 -0
- package/src/index.ts +37 -0
- package/src/oauth/expoOAuth.tsx +113 -0
- package/src/vision/analysis.js +80 -0
- package/src/vision/index.ts +144 -0
- package/src/vision/ring.js +35 -0
|
@@ -0,0 +1,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
|
+
}
|