@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.
@@ -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
+ }