craft-native 0.0.168 → 0.0.170
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/dist/android/src/index.d.ts +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1028 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.ActivityNotFoundException
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.pm.PackageManager
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
13
|
+
import androidx.health.connect.client.HealthConnectFeatures
|
|
14
|
+
import androidx.health.connect.client.PermissionController
|
|
15
|
+
import androidx.health.connect.client.aggregate.AggregateMetric
|
|
16
|
+
import androidx.health.connect.client.aggregate.AggregationResult
|
|
17
|
+
import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
|
|
18
|
+
import androidx.health.connect.client.permission.HealthPermission
|
|
19
|
+
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
20
|
+
import androidx.health.connect.client.records.DistanceRecord
|
|
21
|
+
import androidx.health.connect.client.records.ElevationGainedRecord
|
|
22
|
+
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
23
|
+
import androidx.health.connect.client.records.HeartRateRecord
|
|
24
|
+
import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
|
|
25
|
+
import androidx.health.connect.client.records.MindfulnessSessionRecord
|
|
26
|
+
import androidx.health.connect.client.records.NutritionRecord
|
|
27
|
+
import androidx.health.connect.client.records.Record
|
|
28
|
+
import androidx.health.connect.client.records.RestingHeartRateRecord
|
|
29
|
+
import androidx.health.connect.client.records.SleepSessionRecord
|
|
30
|
+
import androidx.health.connect.client.records.StepsRecord
|
|
31
|
+
import androidx.health.connect.client.records.WeightRecord
|
|
32
|
+
import androidx.health.connect.client.records.metadata.DataOrigin
|
|
33
|
+
import androidx.health.connect.client.records.metadata.Device
|
|
34
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
35
|
+
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
36
|
+
import androidx.health.connect.client.request.AggregateRequest
|
|
37
|
+
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
38
|
+
import androidx.health.connect.client.time.TimeRangeFilter
|
|
39
|
+
import androidx.health.connect.client.units.Energy
|
|
40
|
+
import androidx.health.connect.client.units.Length
|
|
41
|
+
import androidx.health.connect.client.units.Mass
|
|
42
|
+
import kotlinx.coroutines.CancellationException
|
|
43
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
44
|
+
import kotlinx.coroutines.CoroutineScope
|
|
45
|
+
import kotlinx.coroutines.Dispatchers
|
|
46
|
+
import kotlinx.coroutines.SupervisorJob
|
|
47
|
+
import kotlinx.coroutines.cancel
|
|
48
|
+
import kotlinx.coroutines.launch
|
|
49
|
+
import kotlinx.coroutines.withContext
|
|
50
|
+
import org.json.JSONArray
|
|
51
|
+
import org.json.JSONObject
|
|
52
|
+
import java.time.Duration
|
|
53
|
+
import java.time.Instant
|
|
54
|
+
import java.time.LocalDate
|
|
55
|
+
import java.time.LocalDateTime
|
|
56
|
+
import java.time.Period
|
|
57
|
+
import java.time.ZoneId
|
|
58
|
+
import java.util.TreeMap
|
|
59
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
60
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
61
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
62
|
+
import kotlin.math.roundToLong
|
|
63
|
+
import kotlin.reflect.KClass
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Health Connect for the native renderer: the Android counterpart of
|
|
67
|
+
* CraftHealth.swift, answering the same `Health` methods with the same
|
|
68
|
+
* arguments and the same result shapes, so one page's JavaScript reads and
|
|
69
|
+
* writes Health Connect exactly as it reads and writes Apple Health.
|
|
70
|
+
*
|
|
71
|
+
* Where the two stores differ, the answer keeps Apple's shape and this file
|
|
72
|
+
* says what it stands for:
|
|
73
|
+
* - heart rate variability is RMSSD here (Health Connect has no SDNN), still
|
|
74
|
+
* in milliseconds;
|
|
75
|
+
* - Health Connect tells an app which permissions it holds, so asking answers
|
|
76
|
+
* whether reading was allowed, where HealthKit only says the sheet closed;
|
|
77
|
+
* - a record's source is the package that wrote it, named by its app label,
|
|
78
|
+
* so this app's own writes come back under its own name (which a page uses
|
|
79
|
+
* to skip them on the way back in).
|
|
80
|
+
*
|
|
81
|
+
* Every answer is delivered on the main thread, exactly once. The work runs on
|
|
82
|
+
* Dispatchers.IO in a scope that `close()` cancels.
|
|
83
|
+
*/
|
|
84
|
+
@OptIn(ExperimentalMindfulnessSessionApi::class)
|
|
85
|
+
internal class CraftNativeHealth(
|
|
86
|
+
private val context: Context,
|
|
87
|
+
/** The foreground activity, or null in a background worker (no permission prompt possible). */
|
|
88
|
+
private val activityProvider: () -> Activity?,
|
|
89
|
+
/** Ask for READ_HEALTH_DATA_IN_BACKGROUND / READ_HEALTH_DATA_HISTORY with the read types when the feature is available (the manifest declares them only when these are true). */
|
|
90
|
+
private val backgroundReads: Boolean,
|
|
91
|
+
private val historyReads: Boolean,
|
|
92
|
+
) {
|
|
93
|
+
/** A read or write that did not happen: the bridge's error code and the message a page sees. */
|
|
94
|
+
private class HealthFailure(val code: String, message: String) : Exception(message)
|
|
95
|
+
|
|
96
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
97
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
98
|
+
private val closed = AtomicBoolean(false)
|
|
99
|
+
private val permissionContract = PermissionController.createRequestPermissionResultContract()
|
|
100
|
+
|
|
101
|
+
/** The permission sheet on screen, if any: completed by onActivityResult. One at a time. */
|
|
102
|
+
private val pendingPermission = AtomicReference<CompletableDeferred<Unit>?>(null)
|
|
103
|
+
|
|
104
|
+
/** App labels by package, so a read of a thousand records asks the package manager once per app. */
|
|
105
|
+
private val labels = ConcurrentHashMap<String, String>()
|
|
106
|
+
|
|
107
|
+
@Volatile
|
|
108
|
+
private var cachedClient: HealthConnectClient? = null
|
|
109
|
+
|
|
110
|
+
private val ownOrigin = DataOrigin(context.packageName)
|
|
111
|
+
|
|
112
|
+
/** A native screen's call (`Health` in CraftNativeActions), by method and arguments as the bridge sends them. */
|
|
113
|
+
fun perform(method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
114
|
+
val options = { index: Int -> args.optJSONObject(index) ?: JSONObject() }
|
|
115
|
+
when (method) {
|
|
116
|
+
"requestAuthorization" -> answer(completion) {
|
|
117
|
+
val requested = args.optJSONArray(0) ?: throw invalid("requestAuthorization needs a list of types")
|
|
118
|
+
val types = (0 until requested.length()).map { index ->
|
|
119
|
+
requested.opt(index) as? String ?: throw invalid("requestAuthorization types must be strings")
|
|
120
|
+
}
|
|
121
|
+
// `write`: false (read only), true (the types' own writes),
|
|
122
|
+
// or a list of the types to write. Health Connect shows
|
|
123
|
+
// every permission asked for, and Play reviews each one, so
|
|
124
|
+
// an app asks to write only what it writes, in one sheet.
|
|
125
|
+
val write = options(1).opt("write")
|
|
126
|
+
val writeTypes = (write as? JSONArray)?.let { list -> (0 until list.length()).mapNotNull { list.opt(it) as? String }.toSet() }
|
|
127
|
+
val readOnly = write == false || options(1).optBoolean("readOnly", false)
|
|
128
|
+
requestAuthorization(types, readOnly, writeTypes)
|
|
129
|
+
}
|
|
130
|
+
"availability" -> answer(completion) { availability() }
|
|
131
|
+
"grantedTypes" -> answer(completion) { grantedTypes() }
|
|
132
|
+
"openSettings" -> answer(completion) { openSettings() }
|
|
133
|
+
"getData" -> answer(completion) {
|
|
134
|
+
val type = args.opt(0) as? String ?: throw invalid("getData needs a type")
|
|
135
|
+
getData(type, options(1))
|
|
136
|
+
}
|
|
137
|
+
"saveData" -> answer(completion) {
|
|
138
|
+
val type = args.opt(0) as? String
|
|
139
|
+
val value = (args.opt(1) as? Number)?.toDouble()
|
|
140
|
+
if (type == null || value == null) throw invalid("saveData needs a type and numeric value")
|
|
141
|
+
saveData(type, value, options(2))
|
|
142
|
+
}
|
|
143
|
+
"saveWorkout" -> answer(completion) { saveWorkout(options(0)) }
|
|
144
|
+
"getWorkouts" -> answer(completion) { getWorkouts(options(0)) }
|
|
145
|
+
"getDailyStatistics" -> answer(completion) {
|
|
146
|
+
val type = args.opt(0) as? String ?: throw invalid("getDailyStatistics needs a type")
|
|
147
|
+
getDailyStatistics(type, options(1))
|
|
148
|
+
}
|
|
149
|
+
"getHeartRateSamples" -> answer(completion) { getHeartRateSamples(options(0)) }
|
|
150
|
+
"getSleepIntervals" -> answer(completion) { getSleepIntervals(options(0)) }
|
|
151
|
+
"saveMindfulSession" -> answer(completion) { saveMindfulSession(options(0)) }
|
|
152
|
+
"getMindfulSessions" -> answer(completion) { getMindfulSessions(options(0)) }
|
|
153
|
+
else -> deliver(completion, CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Health method: $method"))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Activity results; returns true when the request code was this class's. */
|
|
158
|
+
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
|
159
|
+
if (requestCode != PERMISSION_REQUEST_CODE) return false
|
|
160
|
+
// What was granted is read back from Health Connect itself, which
|
|
161
|
+
// also counts permissions granted before this sheet opened.
|
|
162
|
+
pendingPermission.getAndSet(null)?.complete(Unit)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fun close() {
|
|
167
|
+
closed.set(true)
|
|
168
|
+
pendingPermission.getAndSet(null)?.cancel()
|
|
169
|
+
scope.cancel()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: - Answering
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Runs one call on the IO scope and hands its answer to `completion` on
|
|
176
|
+
* the main thread, once: the value it returns, or the failure it threw.
|
|
177
|
+
* A call cut short by `close()` still answers, with a failure.
|
|
178
|
+
*/
|
|
179
|
+
private fun answer(completion: (CraftNativeCapabilityResult) -> Unit, block: suspend () -> Any) {
|
|
180
|
+
val answered = AtomicBoolean(false)
|
|
181
|
+
val finish = { result: CraftNativeCapabilityResult ->
|
|
182
|
+
if (answered.compareAndSet(false, true)) deliver(completion, result)
|
|
183
|
+
}
|
|
184
|
+
if (closed.get()) return finish(closedResult())
|
|
185
|
+
val job = scope.launch {
|
|
186
|
+
val result = try {
|
|
187
|
+
CraftNativeCapabilityResult(data = block())
|
|
188
|
+
} catch (error: CancellationException) {
|
|
189
|
+
finish(closedResult())
|
|
190
|
+
throw error
|
|
191
|
+
} catch (error: Throwable) {
|
|
192
|
+
failure(error)
|
|
193
|
+
}
|
|
194
|
+
finish(result)
|
|
195
|
+
}
|
|
196
|
+
// A job cancelled before it started never runs its body.
|
|
197
|
+
job.invokeOnCompletion { cause -> if (cause != null) finish(closedResult()) }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private fun deliver(completion: (CraftNativeCapabilityResult) -> Unit, result: CraftNativeCapabilityResult) {
|
|
201
|
+
if (Looper.myLooper() == Looper.getMainLooper()) completion(result) else mainHandler.post { completion(result) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun closedResult() = CraftNativeCapabilityResult(code = "CRAFT_ERROR", message = "Health Connect was closed before it answered")
|
|
205
|
+
|
|
206
|
+
private fun failure(error: Throwable): CraftNativeCapabilityResult = when (error) {
|
|
207
|
+
is HealthFailure -> CraftNativeCapabilityResult(code = error.code, message = error.message)
|
|
208
|
+
// Health Connect refuses a read (or write) it was not allowed, and a
|
|
209
|
+
// read from the background without the background permission.
|
|
210
|
+
is SecurityException -> CraftNativeCapabilityResult(code = "PERMISSION_DENIED", message = error.message ?: "Health Connect permission was not granted")
|
|
211
|
+
else -> CraftNativeCapabilityResult(code = "CRAFT_ERROR", message = error.message ?: error.javaClass.simpleName)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun invalid(message: String) = HealthFailure("INVALID_ARGUMENT", message)
|
|
215
|
+
|
|
216
|
+
// MARK: - Health Connect itself
|
|
217
|
+
|
|
218
|
+
private fun sdkStatus(): Int = HealthConnectClient.getSdkStatus(context, PROVIDER_PACKAGE)
|
|
219
|
+
|
|
220
|
+
/** The client, or the failure that says why there is none: not on this device, or the app needs installing or updating. */
|
|
221
|
+
private fun client(): HealthConnectClient {
|
|
222
|
+
cachedClient?.let { return it }
|
|
223
|
+
when (sdkStatus()) {
|
|
224
|
+
HealthConnectClient.SDK_AVAILABLE -> Unit
|
|
225
|
+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> throw HealthFailure(
|
|
226
|
+
"HEALTH_CONNECT_UPDATE_REQUIRED",
|
|
227
|
+
"Health Connect needs to be installed or updated from Google Play",
|
|
228
|
+
)
|
|
229
|
+
else -> throw HealthFailure("HEALTH_CONNECT_UNAVAILABLE", "Health Connect is not available on this device")
|
|
230
|
+
}
|
|
231
|
+
return HealthConnectClient.getOrCreate(context, PROVIDER_PACKAGE).also { cachedClient = it }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun featureAvailable(client: HealthConnectClient, feature: Int): Boolean = try {
|
|
235
|
+
client.features.getFeatureStatus(feature) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE
|
|
236
|
+
} catch (error: Exception) {
|
|
237
|
+
false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Mindfulness sessions arrived in a later Health Connect; an older one has no such record. */
|
|
241
|
+
private fun mindfulnessAvailable(client: HealthConnectClient) = featureAvailable(client, HealthConnectFeatures.FEATURE_MINDFULNESS_SESSION)
|
|
242
|
+
|
|
243
|
+
// MARK: - Permissions
|
|
244
|
+
|
|
245
|
+
private suspend fun requestAuthorization(types: List<String>, readOnly: Boolean, writeTypes: Set<String>? = null): Any {
|
|
246
|
+
if (types.isEmpty()) throw invalid("requestAuthorization needs at least one type")
|
|
247
|
+
types.firstOrNull { it !in TYPES }?.let { throw invalid("Unknown health data type: $it") }
|
|
248
|
+
writeTypes?.firstOrNull { it !in TYPES }?.let { throw invalid("Unknown health data type: $it") }
|
|
249
|
+
val client = client()
|
|
250
|
+
val mindfulness = mindfulnessAvailable(client)
|
|
251
|
+
|
|
252
|
+
val reads = mutableSetOf<String>()
|
|
253
|
+
val writes = mutableSetOf<String>()
|
|
254
|
+
for (type in types) {
|
|
255
|
+
// A Health Connect without mindfulness sessions cannot grant
|
|
256
|
+
// them; asking would only fail the whole sheet.
|
|
257
|
+
if (type == "mindful" && !mindfulness) continue
|
|
258
|
+
reads += readPermissions(type)
|
|
259
|
+
// A page that only reads asks only to read.
|
|
260
|
+
if (!readOnly && writeTypes == null) writes += writePermissions(type)
|
|
261
|
+
}
|
|
262
|
+
for (type in writeTypes.orEmpty()) {
|
|
263
|
+
if (type == "mindful" && !mindfulness) continue
|
|
264
|
+
writes += writePermissions(type)
|
|
265
|
+
}
|
|
266
|
+
if (reads.isEmpty() && writes.isEmpty()) return false
|
|
267
|
+
|
|
268
|
+
val requested = reads + writes + buildSet {
|
|
269
|
+
// Without these Health Connect answers reads only while the app
|
|
270
|
+
// is in front, and only for the 30 days before the first grant.
|
|
271
|
+
if (reads.isNotEmpty() && backgroundReads && featureAvailable(client, HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND)) {
|
|
272
|
+
add(HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)
|
|
273
|
+
}
|
|
274
|
+
if (reads.isNotEmpty() && historyReads && featureAvailable(client, HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY)) {
|
|
275
|
+
add(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (client.permissionController.getGrantedPermissions().containsAll(requested)) return true
|
|
280
|
+
|
|
281
|
+
val activity = activityProvider()
|
|
282
|
+
?: throw HealthFailure("NEEDS_ACTIVITY", "Health Connect permissions can only be asked for while the app is open")
|
|
283
|
+
val answered = CompletableDeferred<Unit>()
|
|
284
|
+
if (!pendingPermission.compareAndSet(null, answered)) {
|
|
285
|
+
throw HealthFailure("BUSY", "A Health Connect permission request is already open")
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
withContext(Dispatchers.Main) {
|
|
289
|
+
// From Android 14 Health Connect's permissions are the
|
|
290
|
+
// platform's own, and the contract's intent is the runtime
|
|
291
|
+
// permission request: only an activity's result registry can
|
|
292
|
+
// launch it (startActivityForResult finds no activity for it).
|
|
293
|
+
val registry = (activity as? androidx.activity.ComponentActivity)?.activityResultRegistry
|
|
294
|
+
if (registry != null) {
|
|
295
|
+
var launcher: androidx.activity.result.ActivityResultLauncher<Set<String>>? = null
|
|
296
|
+
launcher = registry.register("craft-health-${System.nanoTime()}", permissionContract) {
|
|
297
|
+
launcher?.unregister()
|
|
298
|
+
pendingPermission.getAndSet(null)?.complete(Unit)
|
|
299
|
+
}
|
|
300
|
+
launcher.launch(requested)
|
|
301
|
+
} else {
|
|
302
|
+
val intent = permissionContract.createIntent(activity, requested)
|
|
303
|
+
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
answered.await()
|
|
307
|
+
} finally {
|
|
308
|
+
pendingPermission.compareAndSet(answered, null)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Unlike HealthKit, Health Connect says what was allowed: reading
|
|
312
|
+
// anything at all counts as connected.
|
|
313
|
+
val granted = client.permissionController.getGrantedPermissions()
|
|
314
|
+
return reads.any { it in granted }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private fun availability(): Any = when (sdkStatus()) {
|
|
318
|
+
HealthConnectClient.SDK_AVAILABLE -> "available"
|
|
319
|
+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "updateRequired"
|
|
320
|
+
else -> "unavailable"
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The types whose every read permission is granted, in the order of TYPES. */
|
|
324
|
+
private suspend fun grantedTypes(): Any {
|
|
325
|
+
val granted = client().permissionController.getGrantedPermissions()
|
|
326
|
+
return JSONArray(TYPES.filter { type -> granted.containsAll(readPermissions(type)) })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Health Connect's own screen for this app's permissions, or Google Play's
|
|
331
|
+
* page for Health Connect when it needs installing or updating. Answers
|
|
332
|
+
* whether a screen opened.
|
|
333
|
+
*/
|
|
334
|
+
private suspend fun openSettings(): Any {
|
|
335
|
+
val candidates = mutableListOf<Intent>()
|
|
336
|
+
if (sdkStatus() == HealthConnectClient.SDK_AVAILABLE) {
|
|
337
|
+
candidates += HealthConnectClient.getHealthConnectManageDataIntent(context, PROVIDER_PACKAGE)
|
|
338
|
+
candidates += Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
|
|
339
|
+
} else {
|
|
340
|
+
candidates += Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$PROVIDER_PACKAGE&url=healthconnect%3A%2F%2Fonboarding"))
|
|
341
|
+
.setPackage("com.android.vending")
|
|
342
|
+
candidates += Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$PROVIDER_PACKAGE"))
|
|
343
|
+
}
|
|
344
|
+
return withContext(Dispatchers.Main) {
|
|
345
|
+
val activity = activityProvider()
|
|
346
|
+
candidates.any { intent ->
|
|
347
|
+
try {
|
|
348
|
+
if (activity != null) {
|
|
349
|
+
activity.startActivity(intent)
|
|
350
|
+
} else {
|
|
351
|
+
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
|
352
|
+
}
|
|
353
|
+
true
|
|
354
|
+
} catch (error: ActivityNotFoundException) {
|
|
355
|
+
false
|
|
356
|
+
} catch (error: SecurityException) {
|
|
357
|
+
false
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// MARK: - Quantities
|
|
364
|
+
|
|
365
|
+
/** Sum, average or latest of one type between two instants, as `{ value, unit }`; 0 when there is nothing. */
|
|
366
|
+
private suspend fun getData(type: String, options: JSONObject): Any {
|
|
367
|
+
val unit = unitOf(type) ?: throw invalid("Unknown health data type: $type")
|
|
368
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
369
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(7))
|
|
370
|
+
val filter = range(start, end)
|
|
371
|
+
val client = client()
|
|
372
|
+
val value = when (type) {
|
|
373
|
+
"heartRateVariability" -> readAll(client, HeartRateVariabilityRmssdRecord::class, filter)
|
|
374
|
+
.map { it.heartRateVariabilityMillis }
|
|
375
|
+
.averageOrNull() ?: 0.0
|
|
376
|
+
"bodyMass" -> client.readRecords(
|
|
377
|
+
ReadRecordsRequest(recordType = WeightRecord::class, timeRangeFilter = filter, ascendingOrder = false, pageSize = 1),
|
|
378
|
+
).records.firstOrNull()?.weight?.inKilograms ?: 0.0
|
|
379
|
+
else -> {
|
|
380
|
+
val metric = aggregateMetric(type) ?: throw invalid("Unknown health data type: $type")
|
|
381
|
+
aggregateValue(client.aggregate(AggregateRequest(setOf(metric), filter)), type) ?: 0.0
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return JSONObject().put("value", value).put("unit", unit)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Saves one food-log entry (one nutrient of a NutritionRecord) or one
|
|
389
|
+
* weight. Dates are epoch milliseconds; an omitted range records now.
|
|
390
|
+
*/
|
|
391
|
+
private suspend fun saveData(type: String, value: Double, options: JSONObject): Any {
|
|
392
|
+
if (!value.isFinite() || value < 0) throw invalid("saveData needs a finite, non-negative value")
|
|
393
|
+
if (type != "bodyMass" && type !in DIETARY_TYPES) throw invalid("saveData supports bodyMass and dietary types, not $type")
|
|
394
|
+
val start = options.instant("startDate") ?: Instant.now()
|
|
395
|
+
val end = options.instant("endDate") ?: start
|
|
396
|
+
if (end.isBefore(start)) throw invalid("saveData needs a valid date range")
|
|
397
|
+
val zone = ZoneId.systemDefault().rules
|
|
398
|
+
|
|
399
|
+
val record: Record = buildRecord {
|
|
400
|
+
if (type == "bodyMass") {
|
|
401
|
+
WeightRecord(
|
|
402
|
+
time = start,
|
|
403
|
+
zoneOffset = zone.getOffset(start),
|
|
404
|
+
weight = Mass.kilograms(value),
|
|
405
|
+
metadata = Metadata.manualEntry(),
|
|
406
|
+
)
|
|
407
|
+
} else {
|
|
408
|
+
// A nutrition record spans time; an instant entry gets a second.
|
|
409
|
+
val until = if (end.isAfter(start)) end else start.plusSeconds(1)
|
|
410
|
+
NutritionRecord(
|
|
411
|
+
startTime = start,
|
|
412
|
+
startZoneOffset = zone.getOffset(start),
|
|
413
|
+
endTime = until,
|
|
414
|
+
endZoneOffset = zone.getOffset(until),
|
|
415
|
+
metadata = Metadata.manualEntry(),
|
|
416
|
+
energy = if (type == "dietaryEnergy") Energy.kilocalories(value) else null,
|
|
417
|
+
protein = if (type == "dietaryProtein") Mass.grams(value) else null,
|
|
418
|
+
totalCarbohydrate = if (type == "dietaryCarbohydrates") Mass.grams(value) else null,
|
|
419
|
+
totalFat = if (type == "dietaryFat") Mass.grams(value) else null,
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
val response = client().insertRecords(listOf(record))
|
|
424
|
+
return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: "")
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// MARK: - Workouts
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Saves a finished session as an exercise session, with its distance and
|
|
431
|
+
* active energy as their own records. Each carries the activity's id as
|
|
432
|
+
* its client record id, so saving the same activity again replaces it
|
|
433
|
+
* instead of adding a second.
|
|
434
|
+
*/
|
|
435
|
+
private suspend fun saveWorkout(body: JSONObject): Any {
|
|
436
|
+
val activityId = body.opt("activityId") as? String
|
|
437
|
+
val type = body.opt("type") as? String
|
|
438
|
+
val start = body.instant("startDate")
|
|
439
|
+
val end = body.instant("endDate")
|
|
440
|
+
if (activityId.isNullOrEmpty() || type == null || start == null || end == null || !end.isAfter(start)) {
|
|
441
|
+
throw invalid("A valid activityId, type, startDate, and endDate are required")
|
|
442
|
+
}
|
|
443
|
+
val exerciseType = EXERCISE_TYPES[type] ?: throw invalid("Unsupported workout type: $type")
|
|
444
|
+
val zone = ZoneId.systemDefault().rules
|
|
445
|
+
val startOffset = zone.getOffset(start)
|
|
446
|
+
val endOffset = zone.getOffset(end)
|
|
447
|
+
// A later save of the same activity wins over an earlier one.
|
|
448
|
+
val version = System.currentTimeMillis()
|
|
449
|
+
val metadata = { suffix: String -> Metadata.activelyRecorded(phone(), clientRecordId = activityId + suffix, clientRecordVersion = version) }
|
|
450
|
+
val title = (body.opt("title") as? String)?.takeIf { it.isNotBlank() }
|
|
451
|
+
|
|
452
|
+
val records = buildRecord {
|
|
453
|
+
buildList<Record> {
|
|
454
|
+
add(ExerciseSessionRecord(
|
|
455
|
+
startTime = start,
|
|
456
|
+
startZoneOffset = startOffset,
|
|
457
|
+
endTime = end,
|
|
458
|
+
endZoneOffset = endOffset,
|
|
459
|
+
metadata = metadata(""),
|
|
460
|
+
exerciseType = exerciseType,
|
|
461
|
+
title = title,
|
|
462
|
+
))
|
|
463
|
+
body.positive("distanceMeters")?.let { meters ->
|
|
464
|
+
add(DistanceRecord(start, startOffset, end, endOffset, Length.meters(meters), metadata("-distance")))
|
|
465
|
+
}
|
|
466
|
+
body.positive("activeEnergyCalories")?.let { kcal ->
|
|
467
|
+
add(ActiveCaloriesBurnedRecord(start, startOffset, end, endOffset, Energy.kilocalories(kcal), metadata("-energy")))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
val response = client().insertRecords(records)
|
|
472
|
+
return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: activityId)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Exercise sessions in Health Connect, from every app that writes there,
|
|
477
|
+
* newest first, with the numbers a training log needs. Distance, energy,
|
|
478
|
+
* heart rate and climb come from the records the same app wrote over the
|
|
479
|
+
* session's span; a number this app may not read is left out.
|
|
480
|
+
*/
|
|
481
|
+
private suspend fun getWorkouts(options: JSONObject): Any {
|
|
482
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
483
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(30))
|
|
484
|
+
val cap = (options.int("limit") ?: 200).coerceIn(1, 1000)
|
|
485
|
+
val client = client()
|
|
486
|
+
val sessions = readAll(client, ExerciseSessionRecord::class, range(start, end), ascending = false, cap = cap)
|
|
487
|
+
|
|
488
|
+
val granted = client.permissionController.getGrantedPermissions()
|
|
489
|
+
fun may(type: KClass<out Record>) = HealthPermission.getReadPermission(type) in granted
|
|
490
|
+
|
|
491
|
+
// A workout's numbers are the ones the app that recorded it wrote over
|
|
492
|
+
// its span (the watch's distance, energy and heart rate), read as
|
|
493
|
+
// records and summed here. Health Connect's aggregates would leave out
|
|
494
|
+
// an app that is not on its data-priority list, and a workout's own
|
|
495
|
+
// numbers are not a question of priority.
|
|
496
|
+
val oldest = sessions.minOfOrNull { it.startTime }
|
|
497
|
+
val newest = sessions.maxOfOrNull { it.endTime }
|
|
498
|
+
val span = if (oldest != null && newest != null && oldest.isBefore(newest)) TimeRangeFilter.between(oldest, newest) else null
|
|
499
|
+
val origins = sessions.map { DataOrigin(it.metadata.dataOrigin.packageName) }.toSet()
|
|
500
|
+
suspend fun <T : Record> numbers(type: KClass<T>): List<T> = if (span == null || !may(type)) emptyList() else try {
|
|
501
|
+
readAll(client, type, span, origins = origins)
|
|
502
|
+
} catch (error: CancellationException) {
|
|
503
|
+
throw error
|
|
504
|
+
} catch (error: Exception) {
|
|
505
|
+
// One kind of number failing (a revoked grant) leaves workouts without it, not the read.
|
|
506
|
+
android.util.Log.w("CraftNativeHealth", "Workout numbers could not be read", error)
|
|
507
|
+
emptyList()
|
|
508
|
+
}
|
|
509
|
+
val distances = numbers(DistanceRecord::class)
|
|
510
|
+
val energies = numbers(ActiveCaloriesBurnedRecord::class)
|
|
511
|
+
val beats = numbers(HeartRateRecord::class)
|
|
512
|
+
val climbs = numbers(ElevationGainedRecord::class)
|
|
513
|
+
|
|
514
|
+
val rows = JSONArray()
|
|
515
|
+
for (session in sessions) {
|
|
516
|
+
val origin = session.metadata.dataOrigin.packageName
|
|
517
|
+
val item = JSONObject()
|
|
518
|
+
.put("id", session.metadata.id)
|
|
519
|
+
.put("type", workoutTypeName(session.exerciseType))
|
|
520
|
+
.put("startDate", session.startTime.ms())
|
|
521
|
+
.put("endDate", session.endTime.ms())
|
|
522
|
+
.put("durationSeconds", Duration.between(session.startTime, session.endTime).toMillis() / 1000.0)
|
|
523
|
+
.put("sourceName", sourceName(origin))
|
|
524
|
+
.put("sourceBundleId", origin)
|
|
525
|
+
.put("indoor", session.exerciseType in INDOOR_EXERCISE_TYPES)
|
|
526
|
+
if (session.endTime.isAfter(session.startTime)) {
|
|
527
|
+
// The share of each interval record inside the session.
|
|
528
|
+
fun share(start: Instant, end: Instant): Double {
|
|
529
|
+
val from = maxOf(start, session.startTime)
|
|
530
|
+
val to = minOf(end, session.endTime)
|
|
531
|
+
if (!to.isAfter(from)) return 0.0
|
|
532
|
+
val whole = Duration.between(start, end).toMillis()
|
|
533
|
+
return if (whole <= 0) 1.0 else Duration.between(from, to).toMillis().toDouble() / whole
|
|
534
|
+
}
|
|
535
|
+
val mine = { record: Record -> record.metadata.dataOrigin.packageName == origin }
|
|
536
|
+
distances.filter(mine).sumOf { it.distance.inMeters * share(it.startTime, it.endTime) }
|
|
537
|
+
.takeIf { it > 0 }?.let { item.put("distanceMeters", it) }
|
|
538
|
+
energies.filter(mine).sumOf { it.energy.inKilocalories * share(it.startTime, it.endTime) }
|
|
539
|
+
.takeIf { it > 0 }?.let { item.put("activeEnergyCalories", it) }
|
|
540
|
+
climbs.filter(mine).sumOf { it.elevation.inMeters * share(it.startTime, it.endTime) }
|
|
541
|
+
.takeIf { it > 0 }?.let { item.put("elevationGainMeters", it) }
|
|
542
|
+
val bpm = beats.filter(mine).flatMap { record ->
|
|
543
|
+
record.samples.filter { !it.time.isBefore(session.startTime) && !it.time.isAfter(session.endTime) }
|
|
544
|
+
}.map { it.beatsPerMinute }
|
|
545
|
+
if (bpm.isNotEmpty()) {
|
|
546
|
+
item.put("averageHeartRate", bpm.average())
|
|
547
|
+
item.put("maxHeartRate", bpm.max().toDouble())
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
rows.put(item)
|
|
551
|
+
}
|
|
552
|
+
return rows
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// MARK: - Daily values
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* One value per local day that has data: a sum for steps, energy,
|
|
559
|
+
* distance and food, an average for heart rate, resting heart rate and
|
|
560
|
+
* HRV, the latest weight, and hours asleep for sleep (counted on the day
|
|
561
|
+
* the night ended).
|
|
562
|
+
*/
|
|
563
|
+
private suspend fun getDailyStatistics(type: String, options: JSONObject): Any {
|
|
564
|
+
if (type != "sleep" && unitOf(type) == null) throw invalid("Unknown health data type: $type")
|
|
565
|
+
val zone = ZoneId.systemDefault()
|
|
566
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
567
|
+
val firstDay = (options.instant("startDate") ?: end.minus(Duration.ofDays(30))).atZone(zone).toLocalDate()
|
|
568
|
+
val start = firstDay.atStartOfDay(zone).toInstant()
|
|
569
|
+
if (!start.isBefore(end)) return JSONArray()
|
|
570
|
+
val client = client()
|
|
571
|
+
|
|
572
|
+
return when (type) {
|
|
573
|
+
"sleep" -> dailySleep(client, start, end, firstDay, zone)
|
|
574
|
+
"heartRateVariability" -> {
|
|
575
|
+
val byDay = TreeMap<LocalDate, MutableList<Double>>()
|
|
576
|
+
for (record in readAll(client, HeartRateVariabilityRmssdRecord::class, range(start, end))) {
|
|
577
|
+
byDay.getOrPut(record.time.atZone(zone).toLocalDate()) { mutableListOf() } += record.heartRateVariabilityMillis
|
|
578
|
+
}
|
|
579
|
+
JSONArray(byDay.map { (day, values) -> dayRow(day, values.average(), "ms") })
|
|
580
|
+
}
|
|
581
|
+
"bodyMass" -> {
|
|
582
|
+
val byDay = TreeMap<LocalDate, WeightRecord>()
|
|
583
|
+
for (record in readAll(client, WeightRecord::class, range(start, end))) {
|
|
584
|
+
val day = record.time.atZone(zone).toLocalDate()
|
|
585
|
+
val kept = byDay[day]
|
|
586
|
+
if (kept == null || !record.time.isBefore(kept.time)) byDay[day] = record
|
|
587
|
+
}
|
|
588
|
+
JSONArray(byDay.map { (day, record) -> dayRow(day, record.weight.inKilograms, "kg") })
|
|
589
|
+
}
|
|
590
|
+
else -> {
|
|
591
|
+
val metric = aggregateMetric(type) ?: throw invalid("Unknown health data type: $type")
|
|
592
|
+
val unit = unitOf(type) ?: throw invalid("Unknown health data type: $type")
|
|
593
|
+
val groups = client.aggregateGroupByPeriod(
|
|
594
|
+
AggregateGroupByPeriodRequest(
|
|
595
|
+
metrics = setOf(metric),
|
|
596
|
+
timeRangeFilter = TimeRangeFilter.between(LocalDateTime.ofInstant(start, zone), LocalDateTime.ofInstant(end, zone)),
|
|
597
|
+
timeRangeSlicer = Period.ofDays(1),
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
JSONArray(
|
|
601
|
+
groups.sortedBy { it.startTime }.mapNotNull { group ->
|
|
602
|
+
aggregateValue(group.result, type)?.let { dayRow(group.startTime.toLocalDate(), it, unit) }
|
|
603
|
+
},
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Hours asleep per local day, from the evening before the first day so its
|
|
611
|
+
* night counts. Awake, out of bed and awake in bed are not sleep; every
|
|
612
|
+
* other stage is. A session the source did not stage counts whole.
|
|
613
|
+
*/
|
|
614
|
+
private suspend fun dailySleep(client: HealthConnectClient, start: Instant, end: Instant, firstDay: LocalDate, zone: ZoneId): Any {
|
|
615
|
+
val secondsByDay = TreeMap<LocalDate, Double>()
|
|
616
|
+
for (session in readAll(client, SleepSessionRecord::class, range(start.minus(Duration.ofHours(12)), end))) {
|
|
617
|
+
val seconds = asleepSeconds(session)
|
|
618
|
+
val day = session.endTime.atZone(zone).toLocalDate()
|
|
619
|
+
if (seconds <= 0 || day.isBefore(firstDay)) continue
|
|
620
|
+
secondsByDay[day] = (secondsByDay[day] ?: 0.0) + seconds
|
|
621
|
+
}
|
|
622
|
+
return JSONArray(secondsByDay.map { (day, seconds) -> dayRow(day, (seconds / 3600 * 100).roundToLong() / 100.0, "hr") })
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private fun asleepSeconds(session: SleepSessionRecord): Double {
|
|
626
|
+
if (session.stages.isEmpty()) return Duration.between(session.startTime, session.endTime).toMillis() / 1000.0
|
|
627
|
+
return session.stages
|
|
628
|
+
.filter { it.stage !in AWAKE_STAGES }
|
|
629
|
+
.sumOf { Duration.between(it.startTime, it.endTime).toMillis() / 1000.0 }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private fun dayRow(day: LocalDate, value: Double, unit: String) = JSONObject()
|
|
633
|
+
.put("date", day.toString())
|
|
634
|
+
.put("value", value)
|
|
635
|
+
.put("unit", unit)
|
|
636
|
+
|
|
637
|
+
// MARK: - Heart rate and sleep intervals
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Every heart-rate reading between two instants, oldest first: one row
|
|
641
|
+
* per reading in a record (Health Connect stores a watch's readings as a
|
|
642
|
+
* series), with the app and device that wrote it.
|
|
643
|
+
*/
|
|
644
|
+
private suspend fun getHeartRateSamples(options: JSONObject): Any {
|
|
645
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
646
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(1))
|
|
647
|
+
val cap = (options.int("limit") ?: 10_000).coerceIn(1, 50_000)
|
|
648
|
+
val records = readAll(client(), HeartRateRecord::class, range(start, end))
|
|
649
|
+
|
|
650
|
+
// A record that overlaps the range can hold readings outside it, and
|
|
651
|
+
// two apps' records can interleave, so the readings are sorted whole.
|
|
652
|
+
val readings = records
|
|
653
|
+
.flatMap { record -> record.samples.filter { !it.time.isBefore(start) && it.time.isBefore(end) }.map { record to it } }
|
|
654
|
+
.sortedBy { it.second.time }
|
|
655
|
+
.take(cap)
|
|
656
|
+
|
|
657
|
+
val rows = JSONArray()
|
|
658
|
+
for ((record, sample) in readings) {
|
|
659
|
+
val at = sample.time.ms()
|
|
660
|
+
val item = JSONObject()
|
|
661
|
+
.put("bpm", sample.beatsPerMinute.toDouble())
|
|
662
|
+
.put("startDate", at)
|
|
663
|
+
.put("endDate", at)
|
|
664
|
+
.put("sourceName", sourceName(record.metadata.dataOrigin.packageName))
|
|
665
|
+
putDevice(item, record.metadata.device, withModel = true)
|
|
666
|
+
rows.put(item)
|
|
667
|
+
}
|
|
668
|
+
return rows
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Sleep between two instants, oldest first, as the sources wrote it: each
|
|
673
|
+
* stage with its name and whether it counts as asleep. A session with no
|
|
674
|
+
* stages is one asleep stretch.
|
|
675
|
+
*/
|
|
676
|
+
private suspend fun getSleepIntervals(options: JSONObject): Any {
|
|
677
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
678
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(1))
|
|
679
|
+
val sessions = readAll(client(), SleepSessionRecord::class, range(start, end))
|
|
680
|
+
|
|
681
|
+
val rows = mutableListOf<JSONObject>()
|
|
682
|
+
for (session in sessions) {
|
|
683
|
+
val source = sourceName(session.metadata.dataOrigin.packageName)
|
|
684
|
+
val row = { from: Instant, until: Instant, stage: String, asleep: Boolean ->
|
|
685
|
+
JSONObject()
|
|
686
|
+
.put("startDate", from.ms())
|
|
687
|
+
.put("endDate", until.ms())
|
|
688
|
+
.put("stage", stage)
|
|
689
|
+
.put("asleep", asleep)
|
|
690
|
+
.put("sourceName", source)
|
|
691
|
+
.also { putDevice(it, session.metadata.device, withModel = false) }
|
|
692
|
+
}
|
|
693
|
+
if (session.stages.isEmpty()) {
|
|
694
|
+
rows += row(session.startTime, session.endTime, "asleep", true)
|
|
695
|
+
} else {
|
|
696
|
+
for (stage in session.stages) {
|
|
697
|
+
val awake = stage.stage in AWAKE_STAGES
|
|
698
|
+
rows += row(stage.startTime, stage.endTime, sleepStageName(stage.stage), !awake)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return JSONArray(rows.sortedBy { it.getDouble("startDate") })
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// MARK: - Mindful sessions
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Saves a mindfulness session: a meditation or breathing session that ran
|
|
709
|
+
* from `startDate` to `endDate`, epoch milliseconds. `id` is the app's own
|
|
710
|
+
* id for it, kept as the record's client record id, so saving the same
|
|
711
|
+
* session twice keeps the first record: the answer then says
|
|
712
|
+
* `existing: true`.
|
|
713
|
+
*/
|
|
714
|
+
private suspend fun saveMindfulSession(body: JSONObject): Any {
|
|
715
|
+
val client = client()
|
|
716
|
+
if (!mindfulnessAvailable(client)) throw HealthFailure("UNSUPPORTED", "This Health Connect has no mindfulness sessions")
|
|
717
|
+
val id = body.opt("id") as? String
|
|
718
|
+
val start = body.instant("startDate")
|
|
719
|
+
val end = body.instant("endDate")
|
|
720
|
+
if (id.isNullOrEmpty() || id.length > 128 || start == null || end == null ||
|
|
721
|
+
!end.isAfter(start) || Duration.between(start, end) > Duration.ofHours(24)
|
|
722
|
+
) {
|
|
723
|
+
throw invalid("A valid id, startDate and endDate (at most a day apart) are required")
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Only this app's own records can be the same session.
|
|
727
|
+
val kept = readAll(
|
|
728
|
+
client,
|
|
729
|
+
MindfulnessSessionRecord::class,
|
|
730
|
+
TimeRangeFilter.between(start.minus(Duration.ofDays(1)), end.plus(Duration.ofDays(1))),
|
|
731
|
+
origins = setOf(ownOrigin),
|
|
732
|
+
).firstOrNull { it.metadata.clientRecordId == id }
|
|
733
|
+
if (kept != null) {
|
|
734
|
+
return JSONObject().put("id", kept.metadata.id).put("externalId", id).put("existing", true)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
val zone = ZoneId.systemDefault().rules
|
|
738
|
+
val record = buildRecord {
|
|
739
|
+
MindfulnessSessionRecord(
|
|
740
|
+
startTime = start,
|
|
741
|
+
startZoneOffset = zone.getOffset(start),
|
|
742
|
+
endTime = end,
|
|
743
|
+
endZoneOffset = zone.getOffset(end),
|
|
744
|
+
metadata = Metadata.activelyRecorded(phone(), clientRecordId = id, clientRecordVersion = 0L),
|
|
745
|
+
mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING,
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
val response = client.insertRecords(listOf(record))
|
|
749
|
+
return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: id).put("externalId", id).put("existing", false)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Mindfulness sessions between two instants, oldest first, from every app
|
|
754
|
+
* that writes them. Each says whether this app wrote it (`ownApp`) and
|
|
755
|
+
* carries the client record id it was saved with, so a page can tell its
|
|
756
|
+
* own sessions from the rest. Nothing where Health Connect has none.
|
|
757
|
+
*/
|
|
758
|
+
private suspend fun getMindfulSessions(options: JSONObject): Any {
|
|
759
|
+
val client = client()
|
|
760
|
+
if (!mindfulnessAvailable(client)) return JSONArray()
|
|
761
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
762
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(30))
|
|
763
|
+
val cap = (options.int("limit") ?: 1000).coerceIn(1, 5000)
|
|
764
|
+
|
|
765
|
+
val rows = JSONArray()
|
|
766
|
+
for (session in readAll(client, MindfulnessSessionRecord::class, range(start, end), cap = cap)) {
|
|
767
|
+
val origin = session.metadata.dataOrigin.packageName
|
|
768
|
+
val item = JSONObject()
|
|
769
|
+
.put("id", session.metadata.id)
|
|
770
|
+
.put("startDate", session.startTime.ms())
|
|
771
|
+
.put("endDate", session.endTime.ms())
|
|
772
|
+
.put("durationSeconds", Duration.between(session.startTime, session.endTime).toMillis() / 1000.0)
|
|
773
|
+
.put("sourceName", sourceName(origin))
|
|
774
|
+
.put("sourceBundleId", origin)
|
|
775
|
+
.put("ownApp", origin == context.packageName)
|
|
776
|
+
session.metadata.clientRecordId?.let { item.put("externalId", it) }
|
|
777
|
+
putDevice(item, session.metadata.device, withModel = false)
|
|
778
|
+
rows.put(item)
|
|
779
|
+
}
|
|
780
|
+
return rows
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// MARK: - Helpers
|
|
784
|
+
|
|
785
|
+
/** Every record of a type in a range, page by page, up to `cap`. */
|
|
786
|
+
private suspend fun <T : Record> readAll(
|
|
787
|
+
client: HealthConnectClient,
|
|
788
|
+
type: KClass<T>,
|
|
789
|
+
filter: TimeRangeFilter,
|
|
790
|
+
ascending: Boolean = true,
|
|
791
|
+
origins: Set<DataOrigin> = emptySet(),
|
|
792
|
+
cap: Int = Int.MAX_VALUE,
|
|
793
|
+
): List<T> {
|
|
794
|
+
val records = ArrayList<T>()
|
|
795
|
+
var token: String? = null
|
|
796
|
+
do {
|
|
797
|
+
val response = client.readRecords(
|
|
798
|
+
ReadRecordsRequest(
|
|
799
|
+
recordType = type,
|
|
800
|
+
timeRangeFilter = filter,
|
|
801
|
+
dataOriginFilter = origins,
|
|
802
|
+
ascendingOrder = ascending,
|
|
803
|
+
pageSize = PAGE_SIZE,
|
|
804
|
+
pageToken = token,
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
records += response.records
|
|
808
|
+
token = response.pageToken?.takeIf { it.isNotEmpty() }
|
|
809
|
+
} while (token != null && records.size < cap)
|
|
810
|
+
return if (records.size > cap) records.subList(0, cap) else records
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private fun range(start: Instant, end: Instant): TimeRangeFilter {
|
|
814
|
+
if (!start.isBefore(end)) throw invalid("startDate must be before endDate")
|
|
815
|
+
return TimeRangeFilter.between(start, end)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** A record's constructor checks its values (a weight over 1000 kg, a negative distance): that is the caller's argument. */
|
|
819
|
+
private inline fun <T> buildRecord(build: () -> T): T = try {
|
|
820
|
+
build()
|
|
821
|
+
} catch (error: IllegalArgumentException) {
|
|
822
|
+
throw invalid(error.message ?: "Health data is out of range")
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** This phone, as the device a session timed in the app was recorded on. */
|
|
826
|
+
private fun phone() = Device(type = Device.TYPE_PHONE, manufacturer = Build.MANUFACTURER, model = Build.MODEL)
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The app that wrote a record, by its label. This app's own writes come
|
|
830
|
+
* back under its own label, which a page compares against to skip them.
|
|
831
|
+
* Another app's label needs package visibility (a <queries> entry);
|
|
832
|
+
* without it the package name stands in.
|
|
833
|
+
*/
|
|
834
|
+
private fun sourceName(packageName: String): String = labels.getOrPut(packageName) {
|
|
835
|
+
val packageManager = context.packageManager
|
|
836
|
+
if (packageName == context.packageName) {
|
|
837
|
+
context.applicationInfo.loadLabel(packageManager).toString()
|
|
838
|
+
} else {
|
|
839
|
+
try {
|
|
840
|
+
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
841
|
+
packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
|
|
842
|
+
} else {
|
|
843
|
+
@Suppress("DEPRECATION")
|
|
844
|
+
packageManager.getApplicationInfo(packageName, 0)
|
|
845
|
+
}
|
|
846
|
+
info.loadLabel(packageManager).toString()
|
|
847
|
+
} catch (error: PackageManager.NameNotFoundException) {
|
|
848
|
+
packageName
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private fun putDevice(item: JSONObject, device: Device?, withModel: Boolean) {
|
|
854
|
+
if (device == null) return
|
|
855
|
+
val name = listOfNotNull(device.manufacturer, device.model).filter { it.isNotBlank() }.joinToString(" ")
|
|
856
|
+
if (name.isNotEmpty()) item.put("deviceName", name)
|
|
857
|
+
if (withModel) device.model?.takeIf { it.isNotBlank() }?.let { item.put("deviceModel", it) }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private fun Instant.ms(): Double = toEpochMilli().toDouble()
|
|
861
|
+
|
|
862
|
+
private fun JSONObject.number(key: String): Double? = (opt(key) as? Number)?.toDouble()?.takeIf { it.isFinite() }
|
|
863
|
+
|
|
864
|
+
private fun JSONObject.instant(key: String): Instant? = number(key)?.let { Instant.ofEpochMilli(it.toLong()) }
|
|
865
|
+
|
|
866
|
+
private fun JSONObject.int(key: String): Int? = number(key)?.toInt()
|
|
867
|
+
|
|
868
|
+
private fun JSONObject.positive(key: String): Double? = number(key)?.takeIf { it > 0 }
|
|
869
|
+
|
|
870
|
+
private fun List<Double>.averageOrNull(): Double? = if (isEmpty()) null else average()
|
|
871
|
+
|
|
872
|
+
companion object {
|
|
873
|
+
const val PERMISSION_REQUEST_CODE = 8411
|
|
874
|
+
|
|
875
|
+
/** Health Connect's own package: the Play Store app before Android 14, the system module after. */
|
|
876
|
+
private const val PROVIDER_PACKAGE = "com.google.android.apps.healthdata"
|
|
877
|
+
private const val PAGE_SIZE = 1000
|
|
878
|
+
|
|
879
|
+
/** Every type the bridge knows, in the order `grantedTypes` lists them. */
|
|
880
|
+
private val TYPES = listOf(
|
|
881
|
+
"steps", "heartRate", "activeEnergy", "distance", "workouts", "restingHeartRate",
|
|
882
|
+
"heartRateVariability", "bodyMass", "sleep", "mindful",
|
|
883
|
+
"dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat",
|
|
884
|
+
)
|
|
885
|
+
private val DIETARY_TYPES = setOf("dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat")
|
|
886
|
+
|
|
887
|
+
private val AWAKE_STAGES = setOf(
|
|
888
|
+
SleepSessionRecord.STAGE_TYPE_AWAKE,
|
|
889
|
+
SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED,
|
|
890
|
+
SleepSessionRecord.STAGE_TYPE_OUT_OF_BED,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
private val INDOOR_EXERCISE_TYPES = setOf(
|
|
894
|
+
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
|
|
895
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
|
|
896
|
+
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
|
|
897
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE,
|
|
898
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL,
|
|
899
|
+
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
/** The bridge's workout names, as the exercise each is written as. */
|
|
903
|
+
private val EXERCISE_TYPES = mapOf(
|
|
904
|
+
"running" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
|
|
905
|
+
"walking" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
|
|
906
|
+
"hiking" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
|
|
907
|
+
"cycling" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
|
|
908
|
+
"swimming" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER,
|
|
909
|
+
"strength" to ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
|
|
910
|
+
"hiit" to ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
|
|
911
|
+
"yoga" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
|
|
912
|
+
"pilates" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES,
|
|
913
|
+
"rowing" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
|
|
914
|
+
"elliptical" to ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL,
|
|
915
|
+
"stairClimbing" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING,
|
|
916
|
+
"crossTraining" to ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP,
|
|
917
|
+
"mobility" to ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING,
|
|
918
|
+
"other" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* A stable name for an exercise type, the same names CraftHealth.swift
|
|
923
|
+
* gives HealthKit's, so a page maps one list to its own sports.
|
|
924
|
+
*/
|
|
925
|
+
private fun workoutTypeName(type: Int): String = when (type) {
|
|
926
|
+
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
|
|
927
|
+
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL -> "running"
|
|
928
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
|
|
929
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> "cycling"
|
|
930
|
+
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
|
|
931
|
+
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
|
|
932
|
+
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
|
|
933
|
+
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER -> "swimming"
|
|
934
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
|
|
935
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "rowing"
|
|
936
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "elliptical"
|
|
937
|
+
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING,
|
|
938
|
+
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE -> "stairClimbing"
|
|
939
|
+
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "yoga"
|
|
940
|
+
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "pilates"
|
|
941
|
+
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
|
|
942
|
+
ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING,
|
|
943
|
+
ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS -> "strength"
|
|
944
|
+
ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING -> "hiit"
|
|
945
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP,
|
|
946
|
+
ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS -> "crossTraining"
|
|
947
|
+
ExerciseSessionRecord.EXERCISE_TYPE_SKIING,
|
|
948
|
+
ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING -> "skiing"
|
|
949
|
+
ExerciseSessionRecord.EXERCISE_TYPE_PADDLING -> "paddling"
|
|
950
|
+
ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING -> "climbing"
|
|
951
|
+
ExerciseSessionRecord.EXERCISE_TYPE_DANCING -> "dance"
|
|
952
|
+
ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING,
|
|
953
|
+
ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING -> "mobility"
|
|
954
|
+
else -> "other"
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** The same stage names CraftHealth.swift gives HealthKit's sleep values. */
|
|
958
|
+
private fun sleepStageName(stage: Int): String = when (stage) {
|
|
959
|
+
SleepSessionRecord.STAGE_TYPE_LIGHT -> "core"
|
|
960
|
+
SleepSessionRecord.STAGE_TYPE_DEEP -> "deep"
|
|
961
|
+
SleepSessionRecord.STAGE_TYPE_REM -> "rem"
|
|
962
|
+
in AWAKE_STAGES -> "awake"
|
|
963
|
+
else -> "asleep"
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** The unit each quantity type answers in, as HealthKit spells it; null for a type with no single number. */
|
|
967
|
+
private fun unitOf(type: String): String? = when (type) {
|
|
968
|
+
"steps" -> "count"
|
|
969
|
+
"activeEnergy", "dietaryEnergy" -> "kcal"
|
|
970
|
+
"distance" -> "m"
|
|
971
|
+
"heartRate", "restingHeartRate" -> "count/min"
|
|
972
|
+
"heartRateVariability" -> "ms"
|
|
973
|
+
"bodyMass" -> "kg"
|
|
974
|
+
"dietaryProtein", "dietaryCarbohydrates", "dietaryFat" -> "g"
|
|
975
|
+
else -> null
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** The aggregate each summed or averaged type is read with. HRV and weight have none and are read as records. */
|
|
979
|
+
private fun aggregateMetric(type: String): AggregateMetric<*>? = when (type) {
|
|
980
|
+
"steps" -> StepsRecord.COUNT_TOTAL
|
|
981
|
+
"activeEnergy" -> ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
|
|
982
|
+
"distance" -> DistanceRecord.DISTANCE_TOTAL
|
|
983
|
+
"heartRate" -> HeartRateRecord.BPM_AVG
|
|
984
|
+
"restingHeartRate" -> RestingHeartRateRecord.BPM_AVG
|
|
985
|
+
"dietaryEnergy" -> NutritionRecord.ENERGY_TOTAL
|
|
986
|
+
"dietaryProtein" -> NutritionRecord.PROTEIN_TOTAL
|
|
987
|
+
"dietaryCarbohydrates" -> NutritionRecord.TOTAL_CARBOHYDRATE_TOTAL
|
|
988
|
+
"dietaryFat" -> NutritionRecord.TOTAL_FAT_TOTAL
|
|
989
|
+
else -> null
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** An aggregate's value in the type's unit, or null when there was no data. */
|
|
993
|
+
private fun aggregateValue(result: AggregationResult, type: String): Double? = when (type) {
|
|
994
|
+
"steps" -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
995
|
+
"activeEnergy" -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
996
|
+
"distance" -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
997
|
+
"heartRate" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
998
|
+
"restingHeartRate" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
999
|
+
"dietaryEnergy" -> result[NutritionRecord.ENERGY_TOTAL]?.inKilocalories
|
|
1000
|
+
"dietaryProtein" -> result[NutritionRecord.PROTEIN_TOTAL]?.inGrams
|
|
1001
|
+
"dietaryCarbohydrates" -> result[NutritionRecord.TOTAL_CARBOHYDRATE_TOTAL]?.inGrams
|
|
1002
|
+
"dietaryFat" -> result[NutritionRecord.TOTAL_FAT_TOTAL]?.inGrams
|
|
1003
|
+
else -> null
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Every Health Connect permission string a type needs, for read and
|
|
1008
|
+
* for write; the builder's manifest list must match this. A workout's
|
|
1009
|
+
* numbers live in their own records, so reading workouts reads those
|
|
1010
|
+
* too. Heart rate variability is RMSSD (Apple's is SDNN). Unknown
|
|
1011
|
+
* types need nothing.
|
|
1012
|
+
*/
|
|
1013
|
+
fun readPermissions(type: String): Set<String> = when (type) {
|
|
1014
|
+
"steps" -> setOf(HealthPermission.getReadPermission(StepsRecord::class))
|
|
1015
|
+
"heartRate" -> setOf(HealthPermission.getReadPermission(HeartRateRecord::class))
|
|
1016
|
+
"activeEnergy" -> setOf(HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class))
|
|
1017
|
+
"distance" -> setOf(HealthPermission.getReadPermission(DistanceRecord::class))
|
|
1018
|
+
"workouts" -> setOf(
|
|
1019
|
+
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
|
1020
|
+
HealthPermission.getReadPermission(HeartRateRecord::class),
|
|
1021
|
+
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
|
|
1022
|
+
HealthPermission.getReadPermission(DistanceRecord::class),
|
|
1023
|
+
)
|
|
1024
|
+
"restingHeartRate" -> setOf(HealthPermission.getReadPermission(RestingHeartRateRecord::class))
|
|
1025
|
+
"heartRateVariability" -> setOf(HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class))
|
|
1026
|
+
"bodyMass" -> setOf(HealthPermission.getReadPermission(WeightRecord::class))
|
|
1027
|
+
"sleep" -> setOf(HealthPermission.getReadPermission(SleepSessionRecord::class))
|
|
1028
|
+
"mindful" -> setOf(HealthPermission.getReadPermission(MindfulnessSessionRecord::class))
|
|
1029
|
+
in DIETARY_TYPES -> setOf(HealthPermission.getReadPermission(NutritionRecord::class))
|
|
1030
|
+
else -> emptySet()
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
fun writePermissions(type: String): Set<String> = when (type) {
|
|
1034
|
+
"activeEnergy" -> setOf(HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class))
|
|
1035
|
+
"distance" -> setOf(HealthPermission.getWritePermission(DistanceRecord::class))
|
|
1036
|
+
"workouts" -> setOf(
|
|
1037
|
+
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
|
|
1038
|
+
HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
|
|
1039
|
+
HealthPermission.getWritePermission(DistanceRecord::class),
|
|
1040
|
+
)
|
|
1041
|
+
"mindful" -> setOf(HealthPermission.getWritePermission(MindfulnessSessionRecord::class))
|
|
1042
|
+
in DIETARY_TYPES -> setOf(HealthPermission.getWritePermission(NutritionRecord::class))
|
|
1043
|
+
else -> emptySet()
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|