@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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +92 -0
- package/README.md +453 -0
- package/android/build.gradle +22 -0
- package/android/src/main/AndroidManifest.xml +74 -0
- package/android/src/main/java/expo/modules/healthkit/ExpoHealthKitModule.kt +228 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectMapping.kt +129 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectNutrition.kt +152 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectRationaleActivity.kt +62 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectService.kt +1054 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectUnits.kt +133 -0
- package/android/src/main/java/expo/modules/healthkit/HealthConnectWorkouts.kt +105 -0
- package/android/src/main/java/expo/modules/healthkit/HealthKitExceptions.kt +30 -0
- package/app.plugin.js +1 -0
- package/build/ExpoHealthKitModule.d.ts +39 -0
- package/build/ExpoHealthKitModule.d.ts.map +1 -0
- package/build/ExpoHealthKitModule.js +35 -0
- package/build/ExpoHealthKitModule.js.map +1 -0
- package/build/ExpoHealthKitModule.web.d.ts +39 -0
- package/build/ExpoHealthKitModule.web.d.ts.map +1 -0
- package/build/ExpoHealthKitModule.web.js +105 -0
- package/build/ExpoHealthKitModule.web.js.map +1 -0
- package/build/dates.d.ts +6 -0
- package/build/dates.d.ts.map +1 -0
- package/build/dates.js +13 -0
- package/build/dates.js.map +1 -0
- package/build/errors.d.ts +9 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +18 -0
- package/build/errors.js.map +1 -0
- package/build/identifiers.d.ts +418 -0
- package/build/identifiers.d.ts.map +1 -0
- package/build/identifiers.js +402 -0
- package/build/identifiers.js.map +1 -0
- package/build/index.d.ts +454 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +418 -0
- package/build/index.js.map +1 -0
- package/build/types.d.ts +542 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/docs/banner.jpg +0 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoHealthKit.podspec +30 -0
- package/ios/ExpoHealthKitModule.swift +155 -0
- package/ios/HealthKitAdvancedQueries.swift +458 -0
- package/ios/HealthKitExceptions.swift +109 -0
- package/ios/HealthKitIdentifiers.swift +183 -0
- package/ios/HealthKitRecords.swift +214 -0
- package/ios/HealthKitService.swift +626 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/package.json +105 -0
- package/plugin/build/index.d.ts +30 -0
- package/plugin/build/index.js +138 -0
- package/src/ExpoHealthKitModule.ts +116 -0
- package/src/ExpoHealthKitModule.web.ts +140 -0
- package/src/dates.ts +17 -0
- package/src/errors.ts +22 -0
- package/src/identifiers.ts +494 -0
- package/src/index.ts +563 -0
- 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
|
+
}
|