@appeeky/expo-healthkit 0.1.0

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 (62) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +92 -0
  3. package/README.md +453 -0
  4. package/android/build.gradle +22 -0
  5. package/android/src/main/AndroidManifest.xml +74 -0
  6. package/android/src/main/java/expo/modules/healthkit/ExpoHealthKitModule.kt +228 -0
  7. package/android/src/main/java/expo/modules/healthkit/HealthConnectMapping.kt +129 -0
  8. package/android/src/main/java/expo/modules/healthkit/HealthConnectNutrition.kt +152 -0
  9. package/android/src/main/java/expo/modules/healthkit/HealthConnectRationaleActivity.kt +62 -0
  10. package/android/src/main/java/expo/modules/healthkit/HealthConnectService.kt +1054 -0
  11. package/android/src/main/java/expo/modules/healthkit/HealthConnectUnits.kt +133 -0
  12. package/android/src/main/java/expo/modules/healthkit/HealthConnectWorkouts.kt +105 -0
  13. package/android/src/main/java/expo/modules/healthkit/HealthKitExceptions.kt +30 -0
  14. package/app.plugin.js +1 -0
  15. package/build/ExpoHealthKitModule.d.ts +39 -0
  16. package/build/ExpoHealthKitModule.d.ts.map +1 -0
  17. package/build/ExpoHealthKitModule.js +35 -0
  18. package/build/ExpoHealthKitModule.js.map +1 -0
  19. package/build/ExpoHealthKitModule.web.d.ts +39 -0
  20. package/build/ExpoHealthKitModule.web.d.ts.map +1 -0
  21. package/build/ExpoHealthKitModule.web.js +105 -0
  22. package/build/ExpoHealthKitModule.web.js.map +1 -0
  23. package/build/dates.d.ts +6 -0
  24. package/build/dates.d.ts.map +1 -0
  25. package/build/dates.js +13 -0
  26. package/build/dates.js.map +1 -0
  27. package/build/errors.d.ts +9 -0
  28. package/build/errors.d.ts.map +1 -0
  29. package/build/errors.js +18 -0
  30. package/build/errors.js.map +1 -0
  31. package/build/identifiers.d.ts +418 -0
  32. package/build/identifiers.d.ts.map +1 -0
  33. package/build/identifiers.js +402 -0
  34. package/build/identifiers.js.map +1 -0
  35. package/build/index.d.ts +454 -0
  36. package/build/index.d.ts.map +1 -0
  37. package/build/index.js +418 -0
  38. package/build/index.js.map +1 -0
  39. package/build/types.d.ts +542 -0
  40. package/build/types.d.ts.map +1 -0
  41. package/build/types.js +2 -0
  42. package/build/types.js.map +1 -0
  43. package/docs/banner.jpg +0 -0
  44. package/expo-module.config.json +9 -0
  45. package/ios/ExpoHealthKit.podspec +30 -0
  46. package/ios/ExpoHealthKitModule.swift +155 -0
  47. package/ios/HealthKitAdvancedQueries.swift +458 -0
  48. package/ios/HealthKitExceptions.swift +109 -0
  49. package/ios/HealthKitIdentifiers.swift +183 -0
  50. package/ios/HealthKitRecords.swift +214 -0
  51. package/ios/HealthKitService.swift +626 -0
  52. package/ios/PrivacyInfo.xcprivacy +14 -0
  53. package/package.json +105 -0
  54. package/plugin/build/index.d.ts +30 -0
  55. package/plugin/build/index.js +138 -0
  56. package/src/ExpoHealthKitModule.ts +116 -0
  57. package/src/ExpoHealthKitModule.web.ts +140 -0
  58. package/src/dates.ts +17 -0
  59. package/src/errors.ts +22 -0
  60. package/src/identifiers.ts +494 -0
  61. package/src/index.ts +563 -0
  62. package/src/types.ts +625 -0
@@ -0,0 +1,1054 @@
1
+ package expo.modules.healthkit
2
+
3
+ import android.content.Context
4
+ import androidx.health.connect.client.HealthConnectClient
5
+ import androidx.health.connect.client.aggregate.AggregationResult
6
+ import androidx.health.connect.client.changes.DeletionChange
7
+ import androidx.health.connect.client.changes.UpsertionChange
8
+ import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
9
+ import androidx.health.connect.client.records.BloodGlucoseRecord
10
+ import androidx.health.connect.client.records.BloodPressureRecord
11
+ import androidx.health.connect.client.records.BodyFatRecord
12
+ import androidx.health.connect.client.records.BodyTemperatureRecord
13
+ import androidx.health.connect.client.records.DistanceRecord
14
+ import androidx.health.connect.client.records.ExerciseSessionRecord
15
+ import androidx.health.connect.client.records.FloorsClimbedRecord
16
+ import androidx.health.connect.client.records.HeartRateRecord
17
+ import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
18
+ import androidx.health.connect.client.records.HeightRecord
19
+ import androidx.health.connect.client.records.HydrationRecord
20
+ import androidx.health.connect.client.records.LeanBodyMassRecord
21
+ import androidx.health.connect.client.records.MealType
22
+ import androidx.health.connect.client.records.NutritionRecord
23
+ import androidx.health.connect.client.records.OxygenSaturationRecord
24
+ import androidx.health.connect.client.records.Record
25
+ import androidx.health.connect.client.records.RespiratoryRateRecord
26
+ import androidx.health.connect.client.records.RestingHeartRateRecord
27
+ import androidx.health.connect.client.records.SleepSessionRecord
28
+ import androidx.health.connect.client.records.StepsRecord
29
+ import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
30
+ import androidx.health.connect.client.records.Vo2MaxRecord
31
+ import androidx.health.connect.client.records.WeightRecord
32
+ import androidx.health.connect.client.records.WheelchairPushesRecord
33
+ import androidx.health.connect.client.records.metadata.Metadata
34
+ import androidx.health.connect.client.request.AggregateGroupByDurationRequest
35
+ import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
36
+ import androidx.health.connect.client.request.AggregateRequest
37
+ import androidx.health.connect.client.request.ChangesTokenRequest
38
+ import androidx.health.connect.client.request.ReadRecordsRequest
39
+ import androidx.health.connect.client.time.TimeRangeFilter
40
+ import androidx.health.connect.client.units.BloodGlucose
41
+ import androidx.health.connect.client.units.Energy
42
+ import androidx.health.connect.client.units.Length
43
+ import androidx.health.connect.client.units.Mass
44
+ import androidx.health.connect.client.units.Percentage
45
+ import androidx.health.connect.client.units.Pressure
46
+ import androidx.health.connect.client.units.Temperature
47
+ import androidx.health.connect.client.units.Volume
48
+ import java.time.Duration
49
+ import java.time.Instant
50
+ import java.time.Period
51
+ import java.time.ZoneId
52
+ import java.time.ZoneOffset
53
+ import java.time.format.DateTimeFormatter
54
+ import kotlin.math.max
55
+ import kotlin.reflect.KClass
56
+
57
+ internal class HealthConnectService(
58
+ private val context: Context,
59
+ private val requestPermissions: suspend (Set<String>) -> Set<String>
60
+ ) {
61
+ fun isAvailable(): Boolean = isAvailable(context)
62
+
63
+ suspend fun requestAuthorization(options: Map<String, Any?>): Boolean {
64
+ assertAvailable()
65
+ val toRead = stringList(options["toRead"])
66
+ val toShare = stringList(options["toShare"])
67
+ val permissions = HealthConnectMapping.permissions(toRead, write = false) +
68
+ HealthConnectMapping.permissions(toShare, write = true)
69
+ if (toRead.isEmpty() && toShare.isEmpty()) {
70
+ throw EmptyPermissionsException()
71
+ }
72
+ if (permissions.isEmpty()) {
73
+ return true
74
+ }
75
+ val requested = permissions.toMutableSet()
76
+ if (toRead.isNotEmpty()) {
77
+ requested += androidx.health.connect.client.permission.HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY
78
+ }
79
+ val granted = client().permissionController.getGrantedPermissions()
80
+ if (granted.containsAll(requested)) {
81
+ return true
82
+ }
83
+ val result = requestPermissions(requested)
84
+ return result.containsAll(permissions)
85
+ }
86
+
87
+ suspend fun authorizationStatus(identifier: String): Int {
88
+ assertAvailable()
89
+ val recordClass = HealthConnectMapping.recordClass(identifier) ?: return 0
90
+ val granted = client().permissionController.getGrantedPermissions()
91
+ val write = androidx.health.connect.client.permission.HealthPermission.getWritePermission(recordClass)
92
+ val read = androidx.health.connect.client.permission.HealthPermission.getReadPermission(recordClass)
93
+ return if (write in granted || read in granted) 2 else 0
94
+ }
95
+
96
+ suspend fun requestStatusForAuthorization(options: Map<String, Any?>): Int {
97
+ assertAvailable()
98
+ val permissions = HealthConnectMapping.permissions(stringList(options["toRead"]), write = false) +
99
+ HealthConnectMapping.permissions(stringList(options["toShare"]), write = true)
100
+ if (permissions.isEmpty()) {
101
+ return 2
102
+ }
103
+ val granted = client().permissionController.getGrantedPermissions()
104
+ return if (granted.containsAll(permissions)) 2 else 1
105
+ }
106
+
107
+ suspend fun queryQuantitySamples(options: Map<String, Any?>): List<Map<String, Any?>> {
108
+ assertAvailable()
109
+ val type = requiredString(options, "type")
110
+ val unit = options["unit"] as? String ?: ""
111
+ val filter = timeFilter(options)
112
+ val limit = intValue(options["limit"])
113
+ val ascending = options["ascending"] as? Boolean ?: false
114
+ return quantitySamples(type, unit, filter, limit, ascending)
115
+ }
116
+
117
+ suspend fun queryCategorySamples(options: Map<String, Any?>): List<Map<String, Any?>> {
118
+ assertAvailable()
119
+ val type = requiredString(options, "type")
120
+ val filter = timeFilter(options)
121
+ val limit = intValue(options["limit"])
122
+ val ascending = options["ascending"] as? Boolean ?: false
123
+ return when (type) {
124
+ HealthConnectMapping.SLEEP -> {
125
+ val sessions = readAll(SleepSessionRecord::class, filter, ascending, 0)
126
+ val samples = sessions.flatMap { session -> sleepSamples(session) }
127
+ applyLimit(if (ascending) samples else samples.reversed(), limit)
128
+ }
129
+ else -> throw InvalidIdentifierException(type)
130
+ }
131
+ }
132
+
133
+ suspend fun queryWorkouts(options: Map<String, Any?>): List<Map<String, Any?>> {
134
+ assertAvailable()
135
+ val filter = timeFilter(options)
136
+ val limit = intValue(options["limit"])
137
+ val ascending = options["ascending"] as? Boolean ?: false
138
+ val activityType = (options["activityType"] as? Number)?.toInt()
139
+ val records = readAll(ExerciseSessionRecord::class, filter, ascending, 0)
140
+ val mapped = records.mapNotNull { record ->
141
+ val hkType = HealthConnectWorkouts.toHealthKit(record.exerciseType)
142
+ if (activityType != null && hkType != activityType) {
143
+ return@mapNotNull null
144
+ }
145
+ workoutSample(record, hkType)
146
+ }
147
+ return applyLimit(mapped, limit)
148
+ }
149
+
150
+ suspend fun queryCorrelations(options: Map<String, Any?>): List<Map<String, Any?>> {
151
+ assertAvailable()
152
+ val type = requiredString(options, "type")
153
+ val filter = timeFilter(options)
154
+ val limit = intValue(options["limit"])
155
+ val ascending = options["ascending"] as? Boolean ?: false
156
+ return when (type) {
157
+ HealthConnectMapping.BLOOD_PRESSURE -> {
158
+ readAll(BloodPressureRecord::class, filter, ascending, limit).map { record ->
159
+ correlation(
160
+ record.metadata,
161
+ type,
162
+ record.time,
163
+ record.time,
164
+ listOf(
165
+ quantitySample(
166
+ record.metadata,
167
+ HealthConnectMapping.SYSTOLIC,
168
+ record.time,
169
+ record.time,
170
+ HealthConnectUnits.toRequested(
171
+ record.systolic.inMillimetersOfMercury,
172
+ "mmHg",
173
+ QuantityKind.PRESSURE
174
+ ),
175
+ "mmHg"
176
+ ),
177
+ quantitySample(
178
+ record.metadata,
179
+ HealthConnectMapping.DIASTOLIC,
180
+ record.time,
181
+ record.time,
182
+ HealthConnectUnits.toRequested(
183
+ record.diastolic.inMillimetersOfMercury,
184
+ "mmHg",
185
+ QuantityKind.PRESSURE
186
+ ),
187
+ "mmHg"
188
+ )
189
+ )
190
+ )
191
+ }
192
+ }
193
+ HealthConnectMapping.FOOD -> {
194
+ readAll(NutritionRecord::class, filter, ascending, limit).map { record ->
195
+ val objects = HealthConnectNutrition.presentFields(record).map { (identifier, canonical) ->
196
+ val kind = HealthConnectMapping.quantityKind(identifier)
197
+ val unit = if (kind == QuantityKind.ENERGY) "kcal" else "g"
198
+ val value = if (kind == QuantityKind.MASS) {
199
+ HealthConnectUnits.toRequested(canonical, "g", kind)
200
+ } else {
201
+ canonical
202
+ }
203
+ quantitySample(record.metadata, identifier, record.startTime, record.endTime, value, unit)
204
+ }
205
+ correlation(record.metadata, type, record.startTime, record.endTime, objects)
206
+ }
207
+ }
208
+ else -> throw InvalidIdentifierException(type)
209
+ }
210
+ }
211
+
212
+ suspend fun queryStatistics(options: Map<String, Any?>): Map<String, Any?> {
213
+ assertAvailable()
214
+ val type = requiredString(options, "type")
215
+ val unit = options["unit"] as? String ?: ""
216
+ val filter = timeFilter(options)
217
+ val bits = intValue(options["options"])
218
+ val start = filterStart(options)
219
+ val end = filterEnd(options)
220
+ return statisticsRecord(type, unit, start, end, bits, aggregate(type, filter, bits), latestValue(type, unit, filter, bits))
221
+ }
222
+
223
+ suspend fun queryStatisticsCollection(options: Map<String, Any?>): List<Map<String, Any?>> {
224
+ assertAvailable()
225
+ val type = requiredString(options, "type")
226
+ val unit = options["unit"] as? String ?: ""
227
+ val filter = timeFilter(options)
228
+ val bits = intValue(options["options"])
229
+ val year = intValue(options["year"])
230
+ val month = intValue(options["month"])
231
+ val day = intValue(options["day"])
232
+ val hour = intValue(options["hour"])
233
+ val minute = intValue(options["minute"])
234
+ val second = intValue(options["second"])
235
+ val metrics = aggregateMetrics(type, bits)
236
+ if (metrics.isEmpty()) {
237
+ return emptyList()
238
+ }
239
+ val durationBased = hour > 0 || minute > 0 || second > 0
240
+ return if (durationBased) {
241
+ val duration = Duration.ofHours(max(hour, 0).toLong())
242
+ .plusMinutes(max(minute, 0).toLong())
243
+ .plusSeconds(max(second, 0).toLong())
244
+ .let { if (it.isZero) Duration.ofHours(1) else it }
245
+ client().aggregateGroupByDuration(
246
+ AggregateGroupByDurationRequest(metrics, filter, duration)
247
+ ).map { result ->
248
+ statisticsRecord(type, unit, result.startTime, result.endTime, bits, result.result, null)
249
+ }
250
+ } else {
251
+ val period = when {
252
+ year > 0 -> Period.ofYears(year)
253
+ month > 0 -> Period.ofMonths(month)
254
+ day > 0 -> Period.ofDays(day)
255
+ else -> Period.ofDays(1)
256
+ }
257
+ client().aggregateGroupByPeriod(
258
+ AggregateGroupByPeriodRequest(metrics, filter, period)
259
+ ).map { result ->
260
+ val start = result.startTime.atZone(ZoneId.systemDefault()).toInstant()
261
+ val end = result.endTime.atZone(ZoneId.systemDefault()).toInstant()
262
+ statisticsRecord(type, unit, start, end, bits, result.result, null)
263
+ }
264
+ }
265
+ }
266
+
267
+ suspend fun queryAnchored(options: Map<String, Any?>): Map<String, Any?> {
268
+ assertAvailable()
269
+ val type = requiredString(options, "type")
270
+ val unit = options["unit"] as? String ?: ""
271
+ val recordClass = HealthConnectMapping.recordClass(type) ?: throw InvalidIdentifierException(type)
272
+ val anchor = options["anchor"] as? String
273
+ val client = client()
274
+ if (anchor.isNullOrBlank()) {
275
+ val samples = quantitySamples(type, unit, timeFilter(options), intValue(options["limit"]), options["ascending"] as? Boolean ?: true)
276
+ val token = client.getChangesToken(ChangesTokenRequest(setOf(recordClass)))
277
+ return mapOf("added" to samples, "deleted" to emptyList<Map<String, Any?>>(), "anchor" to token)
278
+ }
279
+ return try {
280
+ var token = requireNotNull(anchor)
281
+ val added = mutableListOf<Map<String, Any?>>()
282
+ val deleted = mutableListOf<Map<String, Any?>>()
283
+ while (true) {
284
+ val changes = client.getChanges(token)
285
+ if (changes.changesTokenExpired) {
286
+ val samples = quantitySamples(type, unit, timeFilter(options), intValue(options["limit"]), true)
287
+ val fresh = client.getChangesToken(ChangesTokenRequest(setOf(recordClass)))
288
+ return mapOf("added" to samples, "deleted" to emptyList<Map<String, Any?>>(), "anchor" to fresh)
289
+ }
290
+ for (change in changes.changes) {
291
+ when (change) {
292
+ is UpsertionChange -> added += quantitySamplesFromRecord(type, unit, change.record)
293
+ is DeletionChange -> deleted += mapOf("uuid" to change.recordId, "type" to type)
294
+ }
295
+ }
296
+ token = changes.nextChangesToken
297
+ if (!changes.hasMore) break
298
+ }
299
+ mapOf("added" to added, "deleted" to deleted, "anchor" to token)
300
+ } catch (_: Exception) {
301
+ val samples = quantitySamples(type, unit, timeFilter(options), intValue(options["limit"]), true)
302
+ val token = client.getChangesToken(ChangesTokenRequest(setOf(recordClass)))
303
+ mapOf("added" to samples, "deleted" to emptyList<Map<String, Any?>>(), "anchor" to token)
304
+ }
305
+ }
306
+
307
+ suspend fun saveQuantitySample(input: Map<String, Any?>): String {
308
+ assertAvailable()
309
+ val type = requiredString(input, "type")
310
+ val unit = requiredString(input, "unit")
311
+ val value = (input["value"] as? Number)?.toDouble() ?: 0.0
312
+ val start = parseInstant(requiredString(input, "startDate"))
313
+ val end = parseInstant(input["endDate"] as? String ?: requiredString(input, "startDate"))
314
+ val canonical = HealthConnectUnits.toCanonical(value, unit, HealthConnectMapping.quantityKind(type))
315
+ val record = quantityRecord(type, canonical, ensureRange(start, end).first, ensureRange(start, end).second)
316
+ ?: throw InvalidSampleTypeException(type)
317
+ val ids = client().insertRecords(listOf(record))
318
+ return ids.recordIdsList.first()
319
+ }
320
+
321
+ suspend fun saveCategorySample(input: Map<String, Any?>): String {
322
+ assertAvailable()
323
+ val type = requiredString(input, "type")
324
+ val value = intValue(input["value"])
325
+ val start = parseInstant(requiredString(input, "startDate"))
326
+ val end = parseInstant(input["endDate"] as? String ?: requiredString(input, "startDate"))
327
+ val range = ensureRange(start, end)
328
+ val offset = zoneOffset(range.first)
329
+ val record: Record = when (type) {
330
+ HealthConnectMapping.SLEEP -> SleepSessionRecord(
331
+ startTime = range.first,
332
+ startZoneOffset = offset,
333
+ endTime = range.second,
334
+ endZoneOffset = zoneOffset(range.second),
335
+ stages = listOf(
336
+ SleepSessionRecord.Stage(range.first, range.second, toSleepStage(value))
337
+ ),
338
+ metadata = metadata()
339
+ )
340
+ else -> throw InvalidSampleTypeException(type)
341
+ }
342
+ return client().insertRecords(listOf(record)).recordIdsList.first()
343
+ }
344
+
345
+ suspend fun saveWorkout(input: Map<String, Any?>): String {
346
+ assertAvailable()
347
+ val start = parseInstant(requiredString(input, "startDate"))
348
+ val end = parseInstant(requiredString(input, "endDate"))
349
+ val range = ensureRange(start, end)
350
+ val activityType = intValue(input["activityType"])
351
+ val records = mutableListOf<Record>(
352
+ ExerciseSessionRecord(
353
+ startTime = range.first,
354
+ startZoneOffset = zoneOffset(range.first),
355
+ endTime = range.second,
356
+ endZoneOffset = zoneOffset(range.second),
357
+ exerciseType = HealthConnectWorkouts.toHealthConnect(activityType),
358
+ metadata = metadata()
359
+ )
360
+ )
361
+ val energy = (input["energyBurned"] as? Number)?.toDouble()
362
+ val energyUnit = input["energyBurnedUnit"] as? String ?: "kcal"
363
+ if (energy != null) {
364
+ records += ActiveCaloriesBurnedRecord(
365
+ startTime = range.first,
366
+ startZoneOffset = zoneOffset(range.first),
367
+ endTime = range.second,
368
+ endZoneOffset = zoneOffset(range.second),
369
+ energy = Energy.kilocalories(HealthConnectUnits.toCanonical(energy, energyUnit, QuantityKind.ENERGY)),
370
+ metadata = metadata()
371
+ )
372
+ }
373
+ val distance = (input["distance"] as? Number)?.toDouble()
374
+ val distanceUnit = input["distanceUnit"] as? String ?: "m"
375
+ if (distance != null) {
376
+ records += DistanceRecord(
377
+ startTime = range.first,
378
+ startZoneOffset = zoneOffset(range.first),
379
+ endTime = range.second,
380
+ endZoneOffset = zoneOffset(range.second),
381
+ distance = Length.meters(HealthConnectUnits.toCanonical(distance, distanceUnit, QuantityKind.LENGTH)),
382
+ metadata = metadata()
383
+ )
384
+ }
385
+ return client().insertRecords(records).recordIdsList.first()
386
+ }
387
+
388
+ suspend fun saveCorrelation(input: Map<String, Any?>): String {
389
+ assertAvailable()
390
+ val type = requiredString(input, "type")
391
+ val start = parseInstant(requiredString(input, "startDate"))
392
+ val end = parseInstant(input["endDate"] as? String ?: requiredString(input, "startDate"))
393
+ val objects = (input["objects"] as? List<*>)?.mapNotNull { item ->
394
+ @Suppress("UNCHECKED_CAST")
395
+ item as? Map<String, Any?>
396
+ } ?: emptyList()
397
+ if (objects.isEmpty()) {
398
+ throw EmptyCorrelationException()
399
+ }
400
+ val record = when (type) {
401
+ HealthConnectMapping.BLOOD_PRESSURE -> {
402
+ var systolic: Double? = null
403
+ var diastolic: Double? = null
404
+ for (item in objects) {
405
+ val identifier = item["type"] as? String ?: continue
406
+ val unit = item["unit"] as? String ?: "mmHg"
407
+ val value = (item["value"] as? Number)?.toDouble() ?: continue
408
+ val mmHg = HealthConnectUnits.toCanonical(value, unit, QuantityKind.PRESSURE)
409
+ when (identifier) {
410
+ HealthConnectMapping.SYSTOLIC -> systolic = mmHg
411
+ HealthConnectMapping.DIASTOLIC -> diastolic = mmHg
412
+ }
413
+ }
414
+ if (systolic == null || diastolic == null) {
415
+ throw EmptyCorrelationException()
416
+ }
417
+ BloodPressureRecord(
418
+ time = start,
419
+ zoneOffset = zoneOffset(start),
420
+ systolic = Pressure.millimetersOfMercury(systolic),
421
+ diastolic = Pressure.millimetersOfMercury(diastolic),
422
+ metadata = metadata()
423
+ )
424
+ }
425
+ HealthConnectMapping.FOOD -> {
426
+ val values = mutableMapOf<String, Double>()
427
+ for (item in objects) {
428
+ val identifier = item["type"] as? String ?: continue
429
+ val unit = item["unit"] as? String ?: ""
430
+ val value = (item["value"] as? Number)?.toDouble() ?: continue
431
+ values[identifier] = HealthConnectUnits.toCanonical(value, unit, HealthConnectMapping.quantityKind(identifier))
432
+ }
433
+ val range = ensureRange(start, end)
434
+ HealthConnectNutrition.build(range.first, range.second, zoneOffset(range.first), zoneOffset(range.second), metadata(), values)
435
+ }
436
+ else -> throw InvalidIdentifierException(type)
437
+ }
438
+ return client().insertRecords(listOf(record)).recordIdsList.first()
439
+ }
440
+
441
+ suspend fun deleteObjects(options: Map<String, Any?>): Int {
442
+ assertAvailable()
443
+ val type = options["type"] as? String ?: throw MissingDeleteTypeException()
444
+ val recordClass = HealthConnectMapping.recordClass(type) ?: throw InvalidIdentifierException(type)
445
+ val uuid = options["uuid"] as? String
446
+ val client = client()
447
+ if (!uuid.isNullOrBlank()) {
448
+ client.deleteRecords(recordClass, listOf(uuid), emptyList())
449
+ return 1
450
+ }
451
+ val filter = timeFilter(options)
452
+ val existing = readAll(recordClass, filter, true, 0)
453
+ if (existing.isEmpty()) {
454
+ return 0
455
+ }
456
+ client.deleteRecords(recordClass, existing.map { it.metadata.id }, emptyList())
457
+ return existing.size
458
+ }
459
+
460
+ fun unsupported(feature: String) {
461
+ throw HealthConnectUnsupportedException(feature)
462
+ }
463
+
464
+ private suspend fun quantitySamples(
465
+ type: String,
466
+ unit: String,
467
+ filter: TimeRangeFilter,
468
+ limit: Int,
469
+ ascending: Boolean
470
+ ): List<Map<String, Any?>> {
471
+ val samples = when (type) {
472
+ HealthConnectMapping.HEART_RATE -> {
473
+ readAll(HeartRateRecord::class, filter, ascending, 0).flatMap { record ->
474
+ record.samples.map { sample ->
475
+ quantitySample(
476
+ record.metadata,
477
+ type,
478
+ sample.time,
479
+ sample.time,
480
+ HealthConnectUnits.toRequested(sample.beatsPerMinute.toDouble(), unit, QuantityKind.BPM),
481
+ unit
482
+ )
483
+ }
484
+ }
485
+ }
486
+ HealthConnectMapping.BASAL_ENERGY -> emptyList()
487
+ else -> {
488
+ val recordClass = HealthConnectMapping.recordClass(type) ?: throw InvalidIdentifierException(type)
489
+ readAll(recordClass, filter, ascending, 0).flatMap { record ->
490
+ quantitySamplesFromRecord(type, unit, record)
491
+ }
492
+ }
493
+ }
494
+ val ordered = if (ascending) samples else samples.reversed()
495
+ return applyLimit(ordered, limit)
496
+ }
497
+
498
+ private fun quantitySamplesFromRecord(type: String, unit: String, record: Record): List<Map<String, Any?>> {
499
+ val kind = HealthConnectMapping.quantityKind(type)
500
+ return when (record) {
501
+ is StepsRecord -> listOf(
502
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.count.toDouble(), unit, kind), unit)
503
+ )
504
+ is DistanceRecord -> listOf(
505
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.distance.inMeters, unit, kind), unit)
506
+ )
507
+ is ActiveCaloriesBurnedRecord -> listOf(
508
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.energy.inKilocalories, unit, kind), unit)
509
+ )
510
+ is FloorsClimbedRecord -> listOf(
511
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.floors, unit, kind), unit)
512
+ )
513
+ is WheelchairPushesRecord -> listOf(
514
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.count.toDouble(), unit, kind), unit)
515
+ )
516
+ is HeartRateRecord -> record.samples.map { sample ->
517
+ quantitySample(record.metadata, type, sample.time, sample.time, HealthConnectUnits.toRequested(sample.beatsPerMinute.toDouble(), unit, kind), unit)
518
+ }
519
+ is RestingHeartRateRecord -> listOf(
520
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.beatsPerMinute.toDouble(), unit, kind), unit)
521
+ )
522
+ is HeartRateVariabilityRmssdRecord -> listOf(
523
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.heartRateVariabilityMillis, unit, kind), unit)
524
+ )
525
+ is OxygenSaturationRecord -> listOf(
526
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(percentToCanonical(record.percentage), unit, kind), unit)
527
+ )
528
+ is RespiratoryRateRecord -> listOf(
529
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.rate, unit, kind), unit)
530
+ )
531
+ is BodyTemperatureRecord -> listOf(
532
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.temperature.inCelsius, unit, kind), unit)
533
+ )
534
+ is BloodGlucoseRecord -> listOf(
535
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.level.inMilligramsPerDeciliter, unit, kind), unit)
536
+ )
537
+ is HeightRecord -> listOf(
538
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.height.inMeters, unit, kind), unit)
539
+ )
540
+ is WeightRecord -> listOf(
541
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.weight.inKilograms, unit, kind), unit)
542
+ )
543
+ is LeanBodyMassRecord -> listOf(
544
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.mass.inKilograms, unit, kind), unit)
545
+ )
546
+ is BodyFatRecord -> listOf(
547
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(percentToCanonical(record.percentage), unit, kind), unit)
548
+ )
549
+ is Vo2MaxRecord -> listOf(
550
+ quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(record.vo2MillilitersPerMinuteKilogram, unit, kind), unit)
551
+ )
552
+ is HydrationRecord -> listOf(
553
+ quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(record.volume.inLiters, unit, kind), unit)
554
+ )
555
+ is BloodPressureRecord -> {
556
+ val mmHg = if (type == HealthConnectMapping.DIASTOLIC) record.diastolic.inMillimetersOfMercury else record.systolic.inMillimetersOfMercury
557
+ listOf(quantitySample(record.metadata, type, record.time, record.time, HealthConnectUnits.toRequested(mmHg, unit, kind), unit))
558
+ }
559
+ is NutritionRecord -> {
560
+ val canonical = HealthConnectNutrition.canonicalValue(record, type) ?: return emptyList()
561
+ listOf(quantitySample(record.metadata, type, record.startTime, record.endTime, HealthConnectUnits.toRequested(canonical, unit, kind), unit))
562
+ }
563
+ else -> emptyList()
564
+ }
565
+ }
566
+
567
+ private fun quantityRecord(type: String, canonical: Double, start: Instant, end: Instant): Record? {
568
+ val offset = zoneOffset(start)
569
+ val endOffset = zoneOffset(end)
570
+ val meta = metadata()
571
+ return when (type) {
572
+ HealthConnectMapping.STEPS -> StepsRecord(
573
+ startTime = start,
574
+ startZoneOffset = offset,
575
+ endTime = end,
576
+ endZoneOffset = endOffset,
577
+ count = canonical.toLong().coerceAtLeast(1),
578
+ metadata = meta
579
+ )
580
+ HealthConnectMapping.DISTANCE_WALKING, HealthConnectMapping.DISTANCE_CYCLING,
581
+ HealthConnectMapping.DISTANCE_SWIMMING, HealthConnectMapping.DISTANCE_WHEELCHAIR ->
582
+ DistanceRecord(
583
+ startTime = start,
584
+ startZoneOffset = offset,
585
+ endTime = end,
586
+ endZoneOffset = endOffset,
587
+ distance = Length.meters(canonical),
588
+ metadata = meta
589
+ )
590
+ HealthConnectMapping.ACTIVE_ENERGY -> ActiveCaloriesBurnedRecord(
591
+ startTime = start,
592
+ startZoneOffset = offset,
593
+ endTime = end,
594
+ endZoneOffset = endOffset,
595
+ energy = Energy.kilocalories(canonical),
596
+ metadata = meta
597
+ )
598
+ HealthConnectMapping.FLOORS -> FloorsClimbedRecord(
599
+ startTime = start,
600
+ startZoneOffset = offset,
601
+ endTime = end,
602
+ endZoneOffset = endOffset,
603
+ floors = canonical,
604
+ metadata = meta
605
+ )
606
+ HealthConnectMapping.PUSH_COUNT -> WheelchairPushesRecord(
607
+ startTime = start,
608
+ startZoneOffset = offset,
609
+ endTime = end,
610
+ endZoneOffset = endOffset,
611
+ count = canonical.toLong().coerceAtLeast(1),
612
+ metadata = meta
613
+ )
614
+ HealthConnectMapping.HEART_RATE -> HeartRateRecord(
615
+ startTime = start,
616
+ startZoneOffset = offset,
617
+ endTime = end,
618
+ endZoneOffset = endOffset,
619
+ samples = listOf(HeartRateRecord.Sample(time = start, beatsPerMinute = canonical.toLong().coerceAtLeast(1))),
620
+ metadata = meta
621
+ )
622
+ HealthConnectMapping.RESTING_HEART_RATE -> RestingHeartRateRecord(
623
+ time = start,
624
+ zoneOffset = offset,
625
+ beatsPerMinute = canonical.toLong().coerceAtLeast(1),
626
+ metadata = meta
627
+ )
628
+ HealthConnectMapping.HRV -> HeartRateVariabilityRmssdRecord(
629
+ time = start,
630
+ zoneOffset = offset,
631
+ heartRateVariabilityMillis = canonical,
632
+ metadata = meta
633
+ )
634
+ HealthConnectMapping.OXYGEN -> OxygenSaturationRecord(
635
+ time = start,
636
+ zoneOffset = offset,
637
+ percentage = percentFromCanonical(canonical),
638
+ metadata = meta
639
+ )
640
+ HealthConnectMapping.RESPIRATORY -> RespiratoryRateRecord(
641
+ time = start,
642
+ zoneOffset = offset,
643
+ rate = canonical,
644
+ metadata = meta
645
+ )
646
+ HealthConnectMapping.BODY_TEMP -> BodyTemperatureRecord(
647
+ time = start,
648
+ zoneOffset = offset,
649
+ temperature = Temperature.celsius(canonical),
650
+ metadata = meta
651
+ )
652
+ HealthConnectMapping.GLUCOSE -> BloodGlucoseRecord(
653
+ time = start,
654
+ zoneOffset = offset,
655
+ level = BloodGlucose.milligramsPerDeciliter(canonical),
656
+ specimenSource = BloodGlucoseRecord.SPECIMEN_SOURCE_UNKNOWN,
657
+ mealType = MealType.MEAL_TYPE_UNKNOWN,
658
+ relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN,
659
+ metadata = meta
660
+ )
661
+ HealthConnectMapping.HEIGHT -> HeightRecord(
662
+ time = start,
663
+ zoneOffset = offset,
664
+ height = Length.meters(canonical),
665
+ metadata = meta
666
+ )
667
+ HealthConnectMapping.WEIGHT -> WeightRecord(
668
+ time = start,
669
+ zoneOffset = offset,
670
+ weight = Mass.kilograms(canonical),
671
+ metadata = meta
672
+ )
673
+ HealthConnectMapping.LEAN_MASS -> LeanBodyMassRecord(
674
+ time = start,
675
+ zoneOffset = offset,
676
+ mass = Mass.kilograms(canonical),
677
+ metadata = meta
678
+ )
679
+ HealthConnectMapping.BODY_FAT -> BodyFatRecord(
680
+ time = start,
681
+ zoneOffset = offset,
682
+ percentage = percentFromCanonical(canonical),
683
+ metadata = meta
684
+ )
685
+ HealthConnectMapping.VO2_MAX -> Vo2MaxRecord(
686
+ time = start,
687
+ zoneOffset = offset,
688
+ vo2MillilitersPerMinuteKilogram = canonical,
689
+ measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
690
+ metadata = meta
691
+ )
692
+ HealthConnectMapping.WATER -> HydrationRecord(
693
+ startTime = start,
694
+ startZoneOffset = offset,
695
+ endTime = end,
696
+ endZoneOffset = endOffset,
697
+ volume = Volume.liters(canonical),
698
+ metadata = meta
699
+ )
700
+ HealthConnectMapping.SYSTOLIC, HealthConnectMapping.DIASTOLIC -> null
701
+ else -> if (HealthConnectMapping.isDietary(type)) {
702
+ HealthConnectNutrition.build(start, end, offset, endOffset, meta, mapOf(type to canonical))
703
+ } else {
704
+ null
705
+ }
706
+ }
707
+ }
708
+
709
+ private suspend fun aggregate(type: String, filter: TimeRangeFilter, bits: Int): AggregationResult? {
710
+ val metrics = aggregateMetrics(type, bits)
711
+ if (metrics.isEmpty()) return null
712
+ val client = client()
713
+ return client.aggregate(AggregateRequest(metrics, filter))
714
+ }
715
+
716
+ private fun aggregateMetrics(type: String, bits: Int): Set<androidx.health.connect.client.aggregate.AggregateMetric<*>> {
717
+ val useSum = hasOption(bits, OPT_SUM) || (bits == 0 && HealthConnectMapping.isCumulative(type))
718
+ val useAvg = hasOption(bits, OPT_AVG) || (bits == 0 && !HealthConnectMapping.isCumulative(type))
719
+ val useMin = hasOption(bits, OPT_MIN)
720
+ val useMax = hasOption(bits, OPT_MAX)
721
+ return when (type) {
722
+ HealthConnectMapping.STEPS -> setOfNotNull(StepsRecord.COUNT_TOTAL.takeIf { useSum })
723
+ HealthConnectMapping.DISTANCE_WALKING, HealthConnectMapping.DISTANCE_CYCLING,
724
+ HealthConnectMapping.DISTANCE_SWIMMING, HealthConnectMapping.DISTANCE_WHEELCHAIR ->
725
+ setOfNotNull(DistanceRecord.DISTANCE_TOTAL.takeIf { useSum })
726
+ HealthConnectMapping.ACTIVE_ENERGY -> setOfNotNull(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL.takeIf { useSum })
727
+ HealthConnectMapping.BASAL_ENERGY -> setOf(TotalCaloriesBurnedRecord.ENERGY_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
728
+ HealthConnectMapping.FLOORS -> setOfNotNull(FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL.takeIf { useSum })
729
+ HealthConnectMapping.PUSH_COUNT -> setOfNotNull(WheelchairPushesRecord.COUNT_TOTAL.takeIf { useSum })
730
+ HealthConnectMapping.HEART_RATE -> buildSet {
731
+ if (useAvg) add(HeartRateRecord.BPM_AVG)
732
+ if (useMin) add(HeartRateRecord.BPM_MIN)
733
+ if (useMax) add(HeartRateRecord.BPM_MAX)
734
+ }
735
+ HealthConnectMapping.RESTING_HEART_RATE -> buildSet {
736
+ if (useAvg) add(RestingHeartRateRecord.BPM_AVG)
737
+ if (useMin) add(RestingHeartRateRecord.BPM_MIN)
738
+ if (useMax) add(RestingHeartRateRecord.BPM_MAX)
739
+ }
740
+ HealthConnectMapping.WEIGHT -> buildSet {
741
+ if (useAvg) add(WeightRecord.WEIGHT_AVG)
742
+ if (useMin) add(WeightRecord.WEIGHT_MIN)
743
+ if (useMax) add(WeightRecord.WEIGHT_MAX)
744
+ }
745
+ HealthConnectMapping.HEIGHT -> buildSet {
746
+ if (useAvg) add(HeightRecord.HEIGHT_AVG)
747
+ if (useMin) add(HeightRecord.HEIGHT_MIN)
748
+ if (useMax) add(HeightRecord.HEIGHT_MAX)
749
+ }
750
+ HealthConnectMapping.WATER -> setOfNotNull(HydrationRecord.VOLUME_TOTAL.takeIf { useSum })
751
+ HealthConnectMapping.DIETARY_ENERGY -> setOfNotNull(NutritionRecord.ENERGY_TOTAL.takeIf { useSum })
752
+ HealthConnectMapping.SYSTOLIC -> buildSet {
753
+ if (useAvg) add(BloodPressureRecord.SYSTOLIC_AVG)
754
+ if (useMin) add(BloodPressureRecord.SYSTOLIC_MIN)
755
+ if (useMax) add(BloodPressureRecord.SYSTOLIC_MAX)
756
+ }
757
+ HealthConnectMapping.DIASTOLIC -> buildSet {
758
+ if (useAvg) add(BloodPressureRecord.DIASTOLIC_AVG)
759
+ if (useMin) add(BloodPressureRecord.DIASTOLIC_MIN)
760
+ if (useMax) add(BloodPressureRecord.DIASTOLIC_MAX)
761
+ }
762
+ else -> emptySet()
763
+ }
764
+ }
765
+
766
+ private suspend fun latestValue(type: String, unit: String, filter: TimeRangeFilter, bits: Int): Double? {
767
+ if (!hasOption(bits, OPT_RECENT)) return null
768
+ return quantitySamples(type, unit, filter, 1, false).firstOrNull()?.get("value") as? Double
769
+ }
770
+
771
+ private fun statisticsRecord(
772
+ type: String,
773
+ unit: String,
774
+ start: Instant,
775
+ end: Instant,
776
+ bits: Int,
777
+ result: AggregationResult?,
778
+ latest: Double?
779
+ ): Map<String, Any?> {
780
+ val kind = HealthConnectMapping.quantityKind(type)
781
+ val useSum = hasOption(bits, OPT_SUM) || (bits == 0 && HealthConnectMapping.isCumulative(type))
782
+ val useAvg = hasOption(bits, OPT_AVG) || (bits == 0 && !HealthConnectMapping.isCumulative(type))
783
+ val record = mutableMapOf<String, Any?>(
784
+ "startDate" to iso(start),
785
+ "endDate" to iso(end),
786
+ "unit" to unit
787
+ )
788
+ if (result != null) {
789
+ if (useSum) numberFromAggregate(type, result, "sum")?.let {
790
+ record["sum"] = HealthConnectUnits.toRequested(it, unit, kind)
791
+ }
792
+ if (useAvg) numberFromAggregate(type, result, "avg")?.let {
793
+ record["average"] = HealthConnectUnits.toRequested(it, unit, kind)
794
+ }
795
+ if (hasOption(bits, OPT_MIN)) numberFromAggregate(type, result, "min")?.let {
796
+ record["min"] = HealthConnectUnits.toRequested(it, unit, kind)
797
+ }
798
+ if (hasOption(bits, OPT_MAX)) numberFromAggregate(type, result, "max")?.let {
799
+ record["max"] = HealthConnectUnits.toRequested(it, unit, kind)
800
+ }
801
+ }
802
+ if (type == HealthConnectMapping.BASAL_ENERGY && result != null) {
803
+ val total = result[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories ?: 0.0
804
+ val active = result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
805
+ val basal = (total - active).coerceAtLeast(0.0)
806
+ record["sum"] = HealthConnectUnits.toRequested(basal, unit, kind)
807
+ }
808
+ latest?.let { record["mostRecent"] = it }
809
+ return record
810
+ }
811
+
812
+ private fun numberFromAggregate(type: String, result: AggregationResult, field: String): Double? {
813
+ return when (type) {
814
+ HealthConnectMapping.STEPS -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
815
+ HealthConnectMapping.DISTANCE_WALKING, HealthConnectMapping.DISTANCE_CYCLING,
816
+ HealthConnectMapping.DISTANCE_SWIMMING, HealthConnectMapping.DISTANCE_WHEELCHAIR ->
817
+ result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
818
+ HealthConnectMapping.ACTIVE_ENERGY -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
819
+ HealthConnectMapping.FLOORS -> result[FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL]
820
+ HealthConnectMapping.PUSH_COUNT -> result[WheelchairPushesRecord.COUNT_TOTAL]?.toDouble()
821
+ HealthConnectMapping.HEART_RATE -> when (field) {
822
+ "avg" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
823
+ "min" -> result[HeartRateRecord.BPM_MIN]?.toDouble()
824
+ "max" -> result[HeartRateRecord.BPM_MAX]?.toDouble()
825
+ else -> null
826
+ }
827
+ HealthConnectMapping.RESTING_HEART_RATE -> when (field) {
828
+ "avg" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
829
+ "min" -> result[RestingHeartRateRecord.BPM_MIN]?.toDouble()
830
+ "max" -> result[RestingHeartRateRecord.BPM_MAX]?.toDouble()
831
+ else -> null
832
+ }
833
+ HealthConnectMapping.WEIGHT -> when (field) {
834
+ "avg" -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
835
+ "min" -> result[WeightRecord.WEIGHT_MIN]?.inKilograms
836
+ "max" -> result[WeightRecord.WEIGHT_MAX]?.inKilograms
837
+ else -> null
838
+ }
839
+ HealthConnectMapping.HEIGHT -> when (field) {
840
+ "avg" -> result[HeightRecord.HEIGHT_AVG]?.inMeters
841
+ "min" -> result[HeightRecord.HEIGHT_MIN]?.inMeters
842
+ "max" -> result[HeightRecord.HEIGHT_MAX]?.inMeters
843
+ else -> null
844
+ }
845
+ HealthConnectMapping.WATER -> result[HydrationRecord.VOLUME_TOTAL]?.inLiters
846
+ HealthConnectMapping.DIETARY_ENERGY -> result[NutritionRecord.ENERGY_TOTAL]?.inKilocalories
847
+ HealthConnectMapping.SYSTOLIC -> when (field) {
848
+ "avg" -> result[BloodPressureRecord.SYSTOLIC_AVG]?.inMillimetersOfMercury
849
+ "min" -> result[BloodPressureRecord.SYSTOLIC_MIN]?.inMillimetersOfMercury
850
+ "max" -> result[BloodPressureRecord.SYSTOLIC_MAX]?.inMillimetersOfMercury
851
+ else -> null
852
+ }
853
+ HealthConnectMapping.DIASTOLIC -> when (field) {
854
+ "avg" -> result[BloodPressureRecord.DIASTOLIC_AVG]?.inMillimetersOfMercury
855
+ "min" -> result[BloodPressureRecord.DIASTOLIC_MIN]?.inMillimetersOfMercury
856
+ "max" -> result[BloodPressureRecord.DIASTOLIC_MAX]?.inMillimetersOfMercury
857
+ else -> null
858
+ }
859
+ else -> null
860
+ }
861
+ }
862
+
863
+ private fun sleepSamples(session: SleepSessionRecord): List<Map<String, Any?>> {
864
+ val samples = mutableListOf(
865
+ categorySample(session.metadata, HealthConnectMapping.SLEEP, session.startTime, session.endTime, 0)
866
+ )
867
+ for (stage in session.stages) {
868
+ samples += categorySample(session.metadata, HealthConnectMapping.SLEEP, stage.startTime, stage.endTime, fromSleepStage(stage.stage))
869
+ }
870
+ return samples
871
+ }
872
+
873
+ private fun toSleepStage(value: Int): Int = when (value) {
874
+ 0 -> SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED
875
+ 2 -> SleepSessionRecord.STAGE_TYPE_AWAKE
876
+ 3 -> SleepSessionRecord.STAGE_TYPE_LIGHT
877
+ 4 -> SleepSessionRecord.STAGE_TYPE_DEEP
878
+ 5 -> SleepSessionRecord.STAGE_TYPE_REM
879
+ else -> SleepSessionRecord.STAGE_TYPE_SLEEPING
880
+ }
881
+
882
+ private fun fromSleepStage(stage: Int): Int = when (stage) {
883
+ SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED -> 0
884
+ SleepSessionRecord.STAGE_TYPE_AWAKE, SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> 2
885
+ SleepSessionRecord.STAGE_TYPE_LIGHT -> 3
886
+ SleepSessionRecord.STAGE_TYPE_DEEP -> 4
887
+ SleepSessionRecord.STAGE_TYPE_REM -> 5
888
+ else -> 1
889
+ }
890
+
891
+ private fun percentToCanonical(percentage: Percentage): Double = percentage.value / 100.0
892
+
893
+ private fun percentFromCanonical(canonical: Double): Percentage =
894
+ Percentage(if (canonical <= 1.0) canonical * 100.0 else canonical)
895
+
896
+ private suspend fun <T : Record> readAll(
897
+ recordType: KClass<T>,
898
+ filter: TimeRangeFilter,
899
+ ascending: Boolean,
900
+ limit: Int
901
+ ): List<T> {
902
+ val out = mutableListOf<T>()
903
+ var token: String? = null
904
+ val pageSize = if (limit > 0) minOf(limit, 5000) else 1000
905
+ do {
906
+ val response = client().readRecords(
907
+ ReadRecordsRequest(
908
+ recordType = recordType,
909
+ timeRangeFilter = filter,
910
+ ascendingOrder = ascending,
911
+ pageSize = pageSize,
912
+ pageToken = token
913
+ )
914
+ )
915
+ out += response.records
916
+ token = response.pageToken
917
+ if (limit > 0 && out.size >= limit) break
918
+ } while (token != null)
919
+ return if (limit > 0) out.take(limit) else out
920
+ }
921
+
922
+ private fun quantitySample(
923
+ metadata: Metadata,
924
+ type: String,
925
+ start: Instant,
926
+ end: Instant,
927
+ value: Double,
928
+ unit: String
929
+ ): Map<String, Any?> = mapOf(
930
+ "uuid" to metadata.id,
931
+ "type" to type,
932
+ "startDate" to iso(start),
933
+ "endDate" to iso(end),
934
+ "value" to value,
935
+ "unit" to unit,
936
+ "sourceName" to metadata.dataOrigin.packageName,
937
+ "sourceId" to metadata.dataOrigin.packageName
938
+ )
939
+
940
+ private fun categorySample(
941
+ metadata: Metadata,
942
+ type: String,
943
+ start: Instant,
944
+ end: Instant,
945
+ value: Int
946
+ ): Map<String, Any?> = mapOf(
947
+ "uuid" to metadata.id,
948
+ "type" to type,
949
+ "startDate" to iso(start),
950
+ "endDate" to iso(end),
951
+ "value" to value,
952
+ "sourceName" to metadata.dataOrigin.packageName,
953
+ "sourceId" to metadata.dataOrigin.packageName
954
+ )
955
+
956
+ private fun workoutSample(record: ExerciseSessionRecord, hkType: Int): Map<String, Any?> {
957
+ val duration = Duration.between(record.startTime, record.endTime).seconds.toDouble()
958
+ return mapOf(
959
+ "uuid" to record.metadata.id,
960
+ "type" to HealthConnectMapping.WORKOUT,
961
+ "startDate" to iso(record.startTime),
962
+ "endDate" to iso(record.endTime),
963
+ "duration" to duration,
964
+ "workoutActivityType" to hkType,
965
+ "sourceName" to record.metadata.dataOrigin.packageName,
966
+ "sourceId" to record.metadata.dataOrigin.packageName
967
+ )
968
+ }
969
+
970
+ private fun correlation(
971
+ metadata: Metadata,
972
+ type: String,
973
+ start: Instant,
974
+ end: Instant,
975
+ objects: List<Map<String, Any?>>
976
+ ): Map<String, Any?> = mapOf(
977
+ "uuid" to metadata.id,
978
+ "type" to type,
979
+ "startDate" to iso(start),
980
+ "endDate" to iso(end),
981
+ "objects" to objects,
982
+ "sourceName" to metadata.dataOrigin.packageName,
983
+ "sourceId" to metadata.dataOrigin.packageName
984
+ )
985
+
986
+ private fun client(): HealthConnectClient = HealthConnectClient.getOrCreate(context)
987
+
988
+ private fun assertAvailable() {
989
+ if (!isAvailable()) throw HealthKitUnavailableException()
990
+ }
991
+
992
+ private fun metadata(): Metadata = Metadata.manualEntry()
993
+
994
+ private fun timeFilter(options: Map<String, Any?>): TimeRangeFilter {
995
+ val from = (options["from"] as? String)?.let { parseInstant(it) }
996
+ val to = (options["to"] as? String)?.let { parseInstant(it) }
997
+ return when {
998
+ from != null && to != null -> TimeRangeFilter.between(from, if (to.isAfter(from)) to else from.plusMillis(1))
999
+ from != null -> TimeRangeFilter.after(from)
1000
+ to != null -> TimeRangeFilter.before(to)
1001
+ else -> TimeRangeFilter.after(Instant.EPOCH)
1002
+ }
1003
+ }
1004
+
1005
+ private fun filterStart(options: Map<String, Any?>): Instant =
1006
+ (options["from"] as? String)?.let { parseInstant(it) } ?: Instant.EPOCH
1007
+
1008
+ private fun filterEnd(options: Map<String, Any?>): Instant =
1009
+ (options["to"] as? String)?.let { parseInstant(it) } ?: Instant.now()
1010
+
1011
+ private fun parseInstant(value: String): Instant = try {
1012
+ Instant.parse(value)
1013
+ } catch (_: Exception) {
1014
+ throw InvalidDateException(value)
1015
+ }
1016
+
1017
+ private fun iso(instant: Instant): String = DateTimeFormatter.ISO_INSTANT.format(instant)
1018
+
1019
+ private fun zoneOffset(instant: Instant): ZoneOffset =
1020
+ ZoneId.systemDefault().rules.getOffset(instant)
1021
+
1022
+ private fun ensureRange(start: Instant, end: Instant): Pair<Instant, Instant> {
1023
+ if (!end.isAfter(start)) {
1024
+ return start to start.plusSeconds(1)
1025
+ }
1026
+ return start to end
1027
+ }
1028
+
1029
+ private fun requiredString(options: Map<String, Any?>, key: String): String =
1030
+ options[key] as? String ?: throw InvalidIdentifierException(key)
1031
+
1032
+ private fun stringList(value: Any?): List<String> =
1033
+ (value as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
1034
+
1035
+ private fun intValue(value: Any?): Int = (value as? Number)?.toInt() ?: 0
1036
+
1037
+ private fun <T> applyLimit(items: List<T>, limit: Int): List<T> =
1038
+ if (limit > 0) items.take(limit) else items
1039
+
1040
+ private fun hasOption(bits: Int, option: Int): Boolean = bits and option != 0
1041
+
1042
+ companion object {
1043
+ private const val OPT_AVG = 1
1044
+ private const val OPT_MIN = 2
1045
+ private const val OPT_MAX = 4
1046
+ private const val OPT_SUM = 8
1047
+ private const val OPT_RECENT = 32
1048
+
1049
+ fun isAvailable(context: Context?): Boolean {
1050
+ if (context == null) return false
1051
+ return HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
1052
+ }
1053
+ }
1054
+ }