@robinhealth/health-sdk 0.0.1
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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +3 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +13 -0
- package/dist/provider.js.map +1 -0
- package/dist/providers/healthconnect.d.ts +16 -0
- package/dist/providers/healthconnect.d.ts.map +1 -0
- package/dist/providers/healthconnect.js +601 -0
- package/dist/providers/healthconnect.js.map +1 -0
- package/dist/providers/healthkit.d.ts +15 -0
- package/dist/providers/healthkit.d.ts.map +1 -0
- package/dist/providers/healthkit.js +392 -0
- package/dist/providers/healthkit.js.map +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +71 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +122 -0
- package/dist/types.js.map +1 -0
- package/package.json +70 -0
- package/src/index.ts +15 -0
- package/src/provider.ts +18 -0
- package/src/providers/healthconnect.ts +725 -0
- package/src/providers/healthkit.ts +499 -0
- package/src/storage.ts +85 -0
- package/src/types.ts +189 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import Healthkit, {
|
|
2
|
+
type QuantityTypeIdentifier,
|
|
3
|
+
type CategoryTypeIdentifier,
|
|
4
|
+
type StatisticsOptions,
|
|
5
|
+
WorkoutActivityType as HKWorkoutActivityType,
|
|
6
|
+
} from '@kingstinct/react-native-healthkit';
|
|
7
|
+
import {
|
|
8
|
+
HealthDataType,
|
|
9
|
+
WorkoutActivityType,
|
|
10
|
+
PermissionStatus,
|
|
11
|
+
SleepStage,
|
|
12
|
+
type HealthProvider,
|
|
13
|
+
type HealthSample,
|
|
14
|
+
type HealthAggregate,
|
|
15
|
+
type Workout,
|
|
16
|
+
type SleepStageSample,
|
|
17
|
+
type SleepSession,
|
|
18
|
+
} from '../types';
|
|
19
|
+
import { getPermissionsRequested, setPermissionsRequested } from '../storage';
|
|
20
|
+
|
|
21
|
+
type HKIdentifier = QuantityTypeIdentifier | CategoryTypeIdentifier;
|
|
22
|
+
|
|
23
|
+
interface TypeMapping {
|
|
24
|
+
identifier: HKIdentifier;
|
|
25
|
+
unit: string;
|
|
26
|
+
isCategory?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TYPE_MAP: Partial<Record<HealthDataType, TypeMapping>> = {
|
|
30
|
+
[HealthDataType.Steps]: {
|
|
31
|
+
identifier: 'HKQuantityTypeIdentifierStepCount',
|
|
32
|
+
unit: 'count',
|
|
33
|
+
},
|
|
34
|
+
[HealthDataType.DistanceWalkingRunning]: {
|
|
35
|
+
identifier: 'HKQuantityTypeIdentifierDistanceWalkingRunning',
|
|
36
|
+
unit: 'm',
|
|
37
|
+
},
|
|
38
|
+
[HealthDataType.ActiveEnergyBurned]: {
|
|
39
|
+
identifier: 'HKQuantityTypeIdentifierActiveEnergyBurned',
|
|
40
|
+
unit: 'kcal',
|
|
41
|
+
},
|
|
42
|
+
[HealthDataType.FlightsClimbed]: {
|
|
43
|
+
identifier: 'HKQuantityTypeIdentifierFlightsClimbed',
|
|
44
|
+
unit: 'count',
|
|
45
|
+
},
|
|
46
|
+
[HealthDataType.HeartRate]: {
|
|
47
|
+
identifier: 'HKQuantityTypeIdentifierHeartRate',
|
|
48
|
+
unit: 'bpm',
|
|
49
|
+
},
|
|
50
|
+
[HealthDataType.RestingHeartRate]: {
|
|
51
|
+
identifier: 'HKQuantityTypeIdentifierRestingHeartRate',
|
|
52
|
+
unit: 'bpm',
|
|
53
|
+
},
|
|
54
|
+
[HealthDataType.BloodPressureSystolic]: {
|
|
55
|
+
identifier: 'HKQuantityTypeIdentifierBloodPressureSystolic',
|
|
56
|
+
unit: 'mmHg',
|
|
57
|
+
},
|
|
58
|
+
[HealthDataType.BloodPressureDiastolic]: {
|
|
59
|
+
identifier: 'HKQuantityTypeIdentifierBloodPressureDiastolic',
|
|
60
|
+
unit: 'mmHg',
|
|
61
|
+
},
|
|
62
|
+
[HealthDataType.BloodOxygen]: {
|
|
63
|
+
identifier: 'HKQuantityTypeIdentifierOxygenSaturation',
|
|
64
|
+
unit: '%',
|
|
65
|
+
},
|
|
66
|
+
[HealthDataType.RespiratoryRate]: {
|
|
67
|
+
identifier: 'HKQuantityTypeIdentifierRespiratoryRate',
|
|
68
|
+
unit: 'breaths/min',
|
|
69
|
+
},
|
|
70
|
+
[HealthDataType.SleepAnalysis]: {
|
|
71
|
+
identifier: 'HKCategoryTypeIdentifierSleepAnalysis',
|
|
72
|
+
unit: 'hr',
|
|
73
|
+
isCategory: true,
|
|
74
|
+
},
|
|
75
|
+
[HealthDataType.BodyFatPercentage]: {
|
|
76
|
+
identifier: 'HKQuantityTypeIdentifierBodyFatPercentage',
|
|
77
|
+
unit: '%',
|
|
78
|
+
},
|
|
79
|
+
[HealthDataType.BodyMassIndex]: {
|
|
80
|
+
identifier: 'HKQuantityTypeIdentifierBodyMassIndex',
|
|
81
|
+
unit: 'kg/m²',
|
|
82
|
+
},
|
|
83
|
+
[HealthDataType.BasalEnergyBurned]: {
|
|
84
|
+
identifier: 'HKQuantityTypeIdentifierBasalEnergyBurned',
|
|
85
|
+
unit: 'kcal',
|
|
86
|
+
},
|
|
87
|
+
[HealthDataType.Water]: {
|
|
88
|
+
identifier: 'HKQuantityTypeIdentifierDietaryWater',
|
|
89
|
+
unit: 'mL',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const WORKOUT_TYPE_MAP: Record<number, WorkoutActivityType> = {
|
|
94
|
+
[HKWorkoutActivityType.americanFootball]: WorkoutActivityType.americanFootball,
|
|
95
|
+
[HKWorkoutActivityType.archery]: WorkoutActivityType.archery,
|
|
96
|
+
[HKWorkoutActivityType.australianFootball]: WorkoutActivityType.australianFootball,
|
|
97
|
+
[HKWorkoutActivityType.badminton]: WorkoutActivityType.badminton,
|
|
98
|
+
[HKWorkoutActivityType.baseball]: WorkoutActivityType.baseball,
|
|
99
|
+
[HKWorkoutActivityType.basketball]: WorkoutActivityType.basketball,
|
|
100
|
+
[HKWorkoutActivityType.bowling]: WorkoutActivityType.bowling,
|
|
101
|
+
[HKWorkoutActivityType.boxing]: WorkoutActivityType.boxing,
|
|
102
|
+
[HKWorkoutActivityType.climbing]: WorkoutActivityType.climbing,
|
|
103
|
+
[HKWorkoutActivityType.cricket]: WorkoutActivityType.cricket,
|
|
104
|
+
[HKWorkoutActivityType.crossTraining]: WorkoutActivityType.crossTraining,
|
|
105
|
+
[HKWorkoutActivityType.curling]: WorkoutActivityType.curling,
|
|
106
|
+
[HKWorkoutActivityType.cycling]: WorkoutActivityType.cycling,
|
|
107
|
+
[HKWorkoutActivityType.dance]: WorkoutActivityType.dance,
|
|
108
|
+
[HKWorkoutActivityType.danceInspiredTraining]: WorkoutActivityType.danceInspiredTraining,
|
|
109
|
+
[HKWorkoutActivityType.elliptical]: WorkoutActivityType.elliptical,
|
|
110
|
+
[HKWorkoutActivityType.equestrianSports]: WorkoutActivityType.equestrianSports,
|
|
111
|
+
[HKWorkoutActivityType.fencing]: WorkoutActivityType.fencing,
|
|
112
|
+
[HKWorkoutActivityType.fishing]: WorkoutActivityType.fishing,
|
|
113
|
+
[HKWorkoutActivityType.functionalStrengthTraining]: WorkoutActivityType.functionalStrengthTraining,
|
|
114
|
+
[HKWorkoutActivityType.golf]: WorkoutActivityType.golf,
|
|
115
|
+
[HKWorkoutActivityType.gymnastics]: WorkoutActivityType.gymnastics,
|
|
116
|
+
[HKWorkoutActivityType.handball]: WorkoutActivityType.handball,
|
|
117
|
+
[HKWorkoutActivityType.hiking]: WorkoutActivityType.hiking,
|
|
118
|
+
[HKWorkoutActivityType.hockey]: WorkoutActivityType.hockey,
|
|
119
|
+
[HKWorkoutActivityType.hunting]: WorkoutActivityType.hunting,
|
|
120
|
+
[HKWorkoutActivityType.lacrosse]: WorkoutActivityType.lacrosse,
|
|
121
|
+
[HKWorkoutActivityType.martialArts]: WorkoutActivityType.martialArts,
|
|
122
|
+
[HKWorkoutActivityType.mindAndBody]: WorkoutActivityType.mindAndBody,
|
|
123
|
+
[HKWorkoutActivityType.mixedMetabolicCardioTraining]: WorkoutActivityType.mixedMetabolicCardioTraining,
|
|
124
|
+
[HKWorkoutActivityType.paddleSports]: WorkoutActivityType.paddleSports,
|
|
125
|
+
[HKWorkoutActivityType.play]: WorkoutActivityType.play,
|
|
126
|
+
[HKWorkoutActivityType.preparationAndRecovery]: WorkoutActivityType.preparationAndRecovery,
|
|
127
|
+
[HKWorkoutActivityType.racquetball]: WorkoutActivityType.racquetball,
|
|
128
|
+
[HKWorkoutActivityType.rowing]: WorkoutActivityType.rowing,
|
|
129
|
+
[HKWorkoutActivityType.rugby]: WorkoutActivityType.rugby,
|
|
130
|
+
[HKWorkoutActivityType.running]: WorkoutActivityType.running,
|
|
131
|
+
[HKWorkoutActivityType.sailing]: WorkoutActivityType.sailing,
|
|
132
|
+
[HKWorkoutActivityType.skatingSports]: WorkoutActivityType.skatingSports,
|
|
133
|
+
[HKWorkoutActivityType.snowSports]: WorkoutActivityType.snowSports,
|
|
134
|
+
[HKWorkoutActivityType.soccer]: WorkoutActivityType.soccer,
|
|
135
|
+
[HKWorkoutActivityType.softball]: WorkoutActivityType.softball,
|
|
136
|
+
[HKWorkoutActivityType.squash]: WorkoutActivityType.squash,
|
|
137
|
+
[HKWorkoutActivityType.stairClimbing]: WorkoutActivityType.stairClimbing,
|
|
138
|
+
[HKWorkoutActivityType.surfingSports]: WorkoutActivityType.surfingSports,
|
|
139
|
+
[HKWorkoutActivityType.swimming]: WorkoutActivityType.swimming,
|
|
140
|
+
[HKWorkoutActivityType.tableTennis]: WorkoutActivityType.tableTennis,
|
|
141
|
+
[HKWorkoutActivityType.tennis]: WorkoutActivityType.tennis,
|
|
142
|
+
[HKWorkoutActivityType.trackAndField]: WorkoutActivityType.trackAndField,
|
|
143
|
+
[HKWorkoutActivityType.traditionalStrengthTraining]: WorkoutActivityType.traditionalStrengthTraining,
|
|
144
|
+
[HKWorkoutActivityType.volleyball]: WorkoutActivityType.volleyball,
|
|
145
|
+
[HKWorkoutActivityType.walking]: WorkoutActivityType.walking,
|
|
146
|
+
[HKWorkoutActivityType.waterFitness]: WorkoutActivityType.waterFitness,
|
|
147
|
+
[HKWorkoutActivityType.waterPolo]: WorkoutActivityType.waterPolo,
|
|
148
|
+
[HKWorkoutActivityType.waterSports]: WorkoutActivityType.waterSports,
|
|
149
|
+
[HKWorkoutActivityType.wrestling]: WorkoutActivityType.wrestling,
|
|
150
|
+
[HKWorkoutActivityType.yoga]: WorkoutActivityType.yoga,
|
|
151
|
+
[HKWorkoutActivityType.barre]: WorkoutActivityType.barre,
|
|
152
|
+
[HKWorkoutActivityType.coreTraining]: WorkoutActivityType.coreTraining,
|
|
153
|
+
[HKWorkoutActivityType.crossCountrySkiing]: WorkoutActivityType.crossCountrySkiing,
|
|
154
|
+
[HKWorkoutActivityType.downhillSkiing]: WorkoutActivityType.downhillSkiing,
|
|
155
|
+
[HKWorkoutActivityType.flexibility]: WorkoutActivityType.flexibility,
|
|
156
|
+
[HKWorkoutActivityType.highIntensityIntervalTraining]: WorkoutActivityType.highIntensityIntervalTraining,
|
|
157
|
+
[HKWorkoutActivityType.jumpRope]: WorkoutActivityType.jumpRope,
|
|
158
|
+
[HKWorkoutActivityType.kickboxing]: WorkoutActivityType.kickboxing,
|
|
159
|
+
[HKWorkoutActivityType.pilates]: WorkoutActivityType.pilates,
|
|
160
|
+
[HKWorkoutActivityType.snowboarding]: WorkoutActivityType.snowboarding,
|
|
161
|
+
[HKWorkoutActivityType.stairs]: WorkoutActivityType.stairs,
|
|
162
|
+
[HKWorkoutActivityType.stepTraining]: WorkoutActivityType.stepTraining,
|
|
163
|
+
[HKWorkoutActivityType.wheelchairWalkPace]: WorkoutActivityType.wheelchairWalkPace,
|
|
164
|
+
[HKWorkoutActivityType.wheelchairRunPace]: WorkoutActivityType.wheelchairRunPace,
|
|
165
|
+
[HKWorkoutActivityType.taiChi]: WorkoutActivityType.taiChi,
|
|
166
|
+
[HKWorkoutActivityType.mixedCardio]: WorkoutActivityType.mixedCardio,
|
|
167
|
+
[HKWorkoutActivityType.handCycling]: WorkoutActivityType.handCycling,
|
|
168
|
+
[HKWorkoutActivityType.discSports]: WorkoutActivityType.discSports,
|
|
169
|
+
[HKWorkoutActivityType.fitnessGaming]: WorkoutActivityType.fitnessGaming,
|
|
170
|
+
[HKWorkoutActivityType.cardioDance]: WorkoutActivityType.cardioDance,
|
|
171
|
+
[HKWorkoutActivityType.socialDance]: WorkoutActivityType.socialDance,
|
|
172
|
+
[HKWorkoutActivityType.pickleball]: WorkoutActivityType.pickleball,
|
|
173
|
+
[HKWorkoutActivityType.cooldown]: WorkoutActivityType.cooldown,
|
|
174
|
+
[HKWorkoutActivityType.swimBikeRun]: WorkoutActivityType.swimBikeRun,
|
|
175
|
+
[HKWorkoutActivityType.transition]: WorkoutActivityType.transition,
|
|
176
|
+
[HKWorkoutActivityType.underwaterDiving]: WorkoutActivityType.underwaterDiving,
|
|
177
|
+
[HKWorkoutActivityType.other]: WorkoutActivityType.other,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const SLEEP_STAGE_MAP: Record<number, SleepStage> = {
|
|
181
|
+
0: SleepStage.Unknown, // HKCategoryValueSleepAnalysis.inBed
|
|
182
|
+
1: SleepStage.Unknown, // asleepUnspecified
|
|
183
|
+
2: SleepStage.Awake, // awake
|
|
184
|
+
3: SleepStage.Light, // asleepCore
|
|
185
|
+
4: SleepStage.Deep, // asleepDeep
|
|
186
|
+
5: SleepStage.REM, // asleepREM
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const dateFilter = (startDate: Date, endDate: Date) => ({
|
|
190
|
+
filter: { date: { startDate, endDate } },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export class HealthKitProvider implements HealthProvider {
|
|
194
|
+
async isAvailable(): Promise<boolean> {
|
|
195
|
+
return Healthkit.isHealthDataAvailable();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async shouldRequestAuthorization(dataTypes: HealthDataType[]): Promise<boolean> {
|
|
199
|
+
const readIdentifiers = dataTypes
|
|
200
|
+
.map((dt) => TYPE_MAP[dt]?.identifier)
|
|
201
|
+
.filter((id): id is HKIdentifier => id !== undefined);
|
|
202
|
+
const status = await Healthkit.getRequestStatusForAuthorization({
|
|
203
|
+
toRead: [...readIdentifiers, 'HKWorkoutTypeIdentifier'],
|
|
204
|
+
});
|
|
205
|
+
// status 1 = shouldRequest, 2 = unnecessary (already granted)
|
|
206
|
+
return status === 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async requestAuthorization(dataTypes: HealthDataType[]): Promise<boolean> {
|
|
210
|
+
const readIdentifiers = dataTypes
|
|
211
|
+
.map((dt) => TYPE_MAP[dt]?.identifier)
|
|
212
|
+
.filter((id): id is HKIdentifier => id !== undefined);
|
|
213
|
+
try {
|
|
214
|
+
await Healthkit.requestAuthorization({ toRead: [...readIdentifiers, 'HKWorkoutTypeIdentifier'] });
|
|
215
|
+
return true;
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
} finally {
|
|
219
|
+
await setPermissionsRequested();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async getPermissionStatus(): Promise<PermissionStatus> {
|
|
224
|
+
try {
|
|
225
|
+
const wasRequested = await getPermissionsRequested();
|
|
226
|
+
if (!wasRequested) return PermissionStatus.UNKNOWN;
|
|
227
|
+
|
|
228
|
+
// On iOS, HealthKit does not expose read-permission denial status.
|
|
229
|
+
// getRequestStatusForAuthorization returns status 1 ("shouldRequest") for
|
|
230
|
+
// both genuine denials AND newly added data types that haven't been requested
|
|
231
|
+
// yet. Since we can't distinguish the two, and HealthKit silently returns
|
|
232
|
+
// empty data for denied types (the app still functions), we treat any
|
|
233
|
+
// previously-requested state as GRANTED. This prevents app updates that add
|
|
234
|
+
// new HealthDataTypes from incorrectly blocking all health data access.
|
|
235
|
+
return PermissionStatus.GRANTED;
|
|
236
|
+
} catch {
|
|
237
|
+
return PermissionStatus.UNKNOWN;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async querySamples(
|
|
242
|
+
type: HealthDataType,
|
|
243
|
+
startDate: Date,
|
|
244
|
+
endDate: Date,
|
|
245
|
+
): Promise<HealthSample[]> {
|
|
246
|
+
const mapping = TYPE_MAP[type];
|
|
247
|
+
if (!mapping) return [];
|
|
248
|
+
|
|
249
|
+
if (mapping.isCategory) {
|
|
250
|
+
return this.queryCategorySamples(type, mapping, startDate, endDate);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const results = await Healthkit.queryQuantitySamples(
|
|
254
|
+
mapping.identifier as QuantityTypeIdentifier,
|
|
255
|
+
{ limit: 0, ...dateFilter(startDate, endDate) },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return results.map((sample) => ({
|
|
259
|
+
type,
|
|
260
|
+
value: sample.quantity,
|
|
261
|
+
unit: mapping.unit,
|
|
262
|
+
startDate: new Date(sample.startDate),
|
|
263
|
+
endDate: new Date(sample.endDate),
|
|
264
|
+
sourceName: sample.sourceRevision?.source?.name ?? undefined,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async queryAggregated(
|
|
269
|
+
type: HealthDataType,
|
|
270
|
+
startDate: Date,
|
|
271
|
+
endDate: Date,
|
|
272
|
+
): Promise<HealthAggregate> {
|
|
273
|
+
const mapping = TYPE_MAP[type];
|
|
274
|
+
if (!mapping) return { type, unit: '', startDate, endDate };
|
|
275
|
+
|
|
276
|
+
if (mapping.isCategory) {
|
|
277
|
+
return this.aggregateCategorySamples(type, mapping, startDate, endDate);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const identifier = mapping.identifier as QuantityTypeIdentifier;
|
|
281
|
+
const options: StatisticsOptions[] = [
|
|
282
|
+
'cumulativeSum',
|
|
283
|
+
'discreteAverage',
|
|
284
|
+
'discreteMin',
|
|
285
|
+
'discreteMax',
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const result = await Healthkit.queryStatisticsForQuantity(
|
|
289
|
+
identifier,
|
|
290
|
+
options,
|
|
291
|
+
dateFilter(startDate, endDate),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
type,
|
|
296
|
+
sum: result.sumQuantity?.quantity,
|
|
297
|
+
avg: result.averageQuantity?.quantity,
|
|
298
|
+
min: result.minimumQuantity?.quantity,
|
|
299
|
+
max: result.maximumQuantity?.quantity,
|
|
300
|
+
unit: mapping.unit,
|
|
301
|
+
startDate,
|
|
302
|
+
endDate,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async queryWorkouts(startDate: Date, endDate: Date): Promise<Workout[]> {
|
|
307
|
+
const workoutProxies = await Healthkit.queryWorkoutSamples({
|
|
308
|
+
limit: 0,
|
|
309
|
+
ascending: false,
|
|
310
|
+
filter: { date: { startDate, endDate } },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const workouts = await Promise.all(
|
|
314
|
+
workoutProxies.map(async (proxy) => {
|
|
315
|
+
const workoutType =
|
|
316
|
+
WORKOUT_TYPE_MAP[proxy.workoutActivityType] ?? WorkoutActivityType.other;
|
|
317
|
+
|
|
318
|
+
let heartRateAvg: number | null = null;
|
|
319
|
+
let heartRateMax: number | null = null;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const hrStats = await proxy.getStatistic(
|
|
323
|
+
'HKQuantityTypeIdentifierHeartRate',
|
|
324
|
+
'count/min',
|
|
325
|
+
);
|
|
326
|
+
if (hrStats) {
|
|
327
|
+
heartRateAvg = hrStats.averageQuantity?.quantity ?? null;
|
|
328
|
+
heartRateMax = hrStats.maximumQuantity?.quantity ?? null;
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// HR stats not available for this workout
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const rawEnergy = proxy.totalEnergyBurned?.quantity;
|
|
335
|
+
const rawDistance = proxy.totalDistance?.quantity;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
workoutType,
|
|
339
|
+
duration: proxy.duration.quantity,
|
|
340
|
+
energyBurned: rawEnergy != null && !Number.isNaN(rawEnergy) ? rawEnergy : null,
|
|
341
|
+
distance: rawDistance != null && !Number.isNaN(rawDistance) ? rawDistance : null,
|
|
342
|
+
heartRateAvg,
|
|
343
|
+
heartRateMax,
|
|
344
|
+
startDate: new Date(proxy.startDate),
|
|
345
|
+
endDate: new Date(proxy.endDate),
|
|
346
|
+
sourceName: proxy.sourceRevision?.source?.name ?? 'Unknown',
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return workouts;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async queryCategorySamples(
|
|
355
|
+
type: HealthDataType,
|
|
356
|
+
mapping: TypeMapping,
|
|
357
|
+
startDate: Date,
|
|
358
|
+
endDate: Date,
|
|
359
|
+
): Promise<SleepStageSample[]> {
|
|
360
|
+
const results = await Healthkit.queryCategorySamples(
|
|
361
|
+
mapping.identifier as CategoryTypeIdentifier,
|
|
362
|
+
{ limit: 0, ...dateFilter(startDate, endDate) },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
return results.map((sample) => {
|
|
366
|
+
const start = new Date(sample.startDate);
|
|
367
|
+
const end = new Date(sample.endDate);
|
|
368
|
+
const durationHours =
|
|
369
|
+
(end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
type,
|
|
373
|
+
value: durationHours,
|
|
374
|
+
unit: mapping.unit,
|
|
375
|
+
startDate: start,
|
|
376
|
+
endDate: end,
|
|
377
|
+
sourceName: sample.sourceRevision?.source?.name ?? undefined,
|
|
378
|
+
stage: SLEEP_STAGE_MAP[sample.value] ?? SleepStage.Unknown,
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async querySleepSessions(
|
|
384
|
+
startDate: Date,
|
|
385
|
+
endDate: Date,
|
|
386
|
+
): Promise<SleepSession[]> {
|
|
387
|
+
const mapping = TYPE_MAP[HealthDataType.SleepAnalysis];
|
|
388
|
+
if (!mapping) return [];
|
|
389
|
+
|
|
390
|
+
const samples = await this.queryCategorySamples(
|
|
391
|
+
HealthDataType.SleepAnalysis,
|
|
392
|
+
mapping,
|
|
393
|
+
startDate,
|
|
394
|
+
endDate,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Group samples into sessions: samples within 30 minutes of each other
|
|
398
|
+
// from the same source form one session
|
|
399
|
+
const sessions: SleepSession[] = [];
|
|
400
|
+
const sortedSamples = [...samples].sort(
|
|
401
|
+
(a, b) => a.startDate.getTime() - b.startDate.getTime(),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
let currentGroup: SleepStageSample[] = [];
|
|
405
|
+
let currentSource: string | undefined;
|
|
406
|
+
|
|
407
|
+
for (const sample of sortedSamples) {
|
|
408
|
+
const lastInGroup = currentGroup[currentGroup.length - 1];
|
|
409
|
+
const sameSource = sample.sourceName === currentSource;
|
|
410
|
+
const withinWindow =
|
|
411
|
+
lastInGroup &&
|
|
412
|
+
sample.startDate.getTime() - lastInGroup.endDate.getTime() <=
|
|
413
|
+
30 * 60 * 1000; // 30 minutes
|
|
414
|
+
|
|
415
|
+
if (currentGroup.length === 0 || (sameSource && withinWindow)) {
|
|
416
|
+
currentGroup.push(sample);
|
|
417
|
+
currentSource = sample.sourceName;
|
|
418
|
+
} else {
|
|
419
|
+
sessions.push(this.buildSleepSession(currentGroup));
|
|
420
|
+
currentGroup = [sample];
|
|
421
|
+
currentSource = sample.sourceName;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (currentGroup.length > 0) {
|
|
426
|
+
sessions.push(this.buildSleepSession(currentGroup));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return sessions;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private buildSleepSession(samples: SleepStageSample[]): SleepSession {
|
|
433
|
+
const stages = {
|
|
434
|
+
awake: 0,
|
|
435
|
+
light: 0,
|
|
436
|
+
deep: 0,
|
|
437
|
+
rem: 0,
|
|
438
|
+
unknown: 0,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const sample of samples) {
|
|
442
|
+
const hours = sample.value;
|
|
443
|
+
switch (sample.stage) {
|
|
444
|
+
case SleepStage.Awake:
|
|
445
|
+
stages.awake += hours;
|
|
446
|
+
break;
|
|
447
|
+
case SleepStage.Light:
|
|
448
|
+
stages.light += hours;
|
|
449
|
+
break;
|
|
450
|
+
case SleepStage.Deep:
|
|
451
|
+
stages.deep += hours;
|
|
452
|
+
break;
|
|
453
|
+
case SleepStage.REM:
|
|
454
|
+
stages.rem += hours;
|
|
455
|
+
break;
|
|
456
|
+
default:
|
|
457
|
+
stages.unknown += hours;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const totalDuration = stages.light + stages.deep + stages.rem + stages.unknown;
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
startDate: samples[0].startDate,
|
|
466
|
+
endDate: samples[samples.length - 1].endDate,
|
|
467
|
+
totalDuration,
|
|
468
|
+
stages,
|
|
469
|
+
sourceName: samples[0].sourceName ?? 'Unknown',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private async aggregateCategorySamples(
|
|
474
|
+
type: HealthDataType,
|
|
475
|
+
mapping: TypeMapping,
|
|
476
|
+
startDate: Date,
|
|
477
|
+
endDate: Date,
|
|
478
|
+
): Promise<HealthAggregate> {
|
|
479
|
+
const samples = await this.queryCategorySamples(
|
|
480
|
+
type,
|
|
481
|
+
mapping,
|
|
482
|
+
startDate,
|
|
483
|
+
endDate,
|
|
484
|
+
);
|
|
485
|
+
const values = samples.map((s) => s.value);
|
|
486
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
type,
|
|
490
|
+
sum,
|
|
491
|
+
avg: values.length > 0 ? sum / values.length : undefined,
|
|
492
|
+
min: values.length > 0 ? values.reduce((a, b) => a < b ? a : b) : undefined,
|
|
493
|
+
max: values.length > 0 ? values.reduce((a, b) => a > b ? a : b) : undefined,
|
|
494
|
+
unit: mapping.unit,
|
|
495
|
+
startDate,
|
|
496
|
+
endDate,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
declare const require: (mod: string) => any;
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'permissions_requested';
|
|
4
|
+
const LEGACY_ASYNC_STORAGE_KEY = '@robin_health_sdk:permissions_requested';
|
|
5
|
+
const DB_NAME = 'robin_health_sdk.db';
|
|
6
|
+
const TABLE = 'kv';
|
|
7
|
+
|
|
8
|
+
type SQLiteDb = {
|
|
9
|
+
execSync: (sql: string) => void;
|
|
10
|
+
getFirstSync: <T>(sql: string, params: unknown[]) => T | null;
|
|
11
|
+
runSync: (sql: string, params: unknown[]) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let db: SQLiteDb | null = null;
|
|
15
|
+
let migrationDone = false;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const SQLite = require('expo-sqlite');
|
|
19
|
+
db = SQLite.openDatabaseSync(DB_NAME) as SQLiteDb;
|
|
20
|
+
db.execSync(
|
|
21
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
// expo-sqlite not available — getPermissionStatus will always return UNKNOWN
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getFromDb(key: string): string | null {
|
|
28
|
+
if (!db) return null;
|
|
29
|
+
const row = db.getFirstSync<{ value: string }>(
|
|
30
|
+
`SELECT value FROM ${TABLE} WHERE key = ?`,
|
|
31
|
+
[key],
|
|
32
|
+
);
|
|
33
|
+
return row?.value ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setInDb(key: string, value: string): void {
|
|
37
|
+
if (!db) return;
|
|
38
|
+
db.runSync(
|
|
39
|
+
`INSERT OR REPLACE INTO ${TABLE} (key, value) VALUES (?, ?)`,
|
|
40
|
+
[key, value],
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One-time migration from legacy AsyncStorage to SQLite.
|
|
46
|
+
* Runs at most once per app session. If AsyncStorage is not installed
|
|
47
|
+
* or the legacy key doesn't exist, this is a no-op.
|
|
48
|
+
*/
|
|
49
|
+
async function migrateFromAsyncStorage(): Promise<void> {
|
|
50
|
+
if (migrationDone || !db) return;
|
|
51
|
+
migrationDone = true;
|
|
52
|
+
|
|
53
|
+
// Already have a value in SQLite — no migration needed
|
|
54
|
+
if (getFromDb(STORAGE_KEY) !== null) return;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
|
58
|
+
const legacy = await AsyncStorage.getItem(LEGACY_ASYNC_STORAGE_KEY);
|
|
59
|
+
if (legacy === 'true') {
|
|
60
|
+
setInDb(STORAGE_KEY, 'true');
|
|
61
|
+
await AsyncStorage.removeItem(LEGACY_ASYNC_STORAGE_KEY);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// AsyncStorage not installed or migration failed — not critical
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function getPermissionsRequested(): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
if (!db) return false;
|
|
71
|
+
await migrateFromAsyncStorage();
|
|
72
|
+
return getFromDb(STORAGE_KEY) === 'true';
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function setPermissionsRequested(): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
if (!db) return;
|
|
81
|
+
setInDb(STORAGE_KEY, 'true');
|
|
82
|
+
} catch {
|
|
83
|
+
// Swallow — worst case next call returns UNKNOWN
|
|
84
|
+
}
|
|
85
|
+
}
|