@capgo/capacitor-health 7.0.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/CapgoCapacitorHealth.podspec +18 -0
- package/Package.swift +31 -0
- package/README.md +273 -0
- package/android/build.gradle +72 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/app/capgo/plugin/health/Health.java +11 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthDataType.kt +34 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthManager.kt +280 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthPlugin.kt +316 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +457 -0
- package/dist/esm/definitions.d.ts +71 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +9 -0
- package/dist/esm/web.js +23 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +37 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +40 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/HealthPlugin/Health.swift +420 -0
- package/ios/Sources/HealthPlugin/HealthPlugin.swift +130 -0
- package/ios/Tests/HealthPluginTests/HealthPluginTests.swift +15 -0
- package/package.json +80 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
package app.capgo.plugin.health
|
|
2
|
+
|
|
3
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
4
|
+
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
5
|
+
import androidx.health.connect.client.records.DistanceRecord
|
|
6
|
+
import androidx.health.connect.client.records.HeartRateRecord
|
|
7
|
+
import androidx.health.connect.client.records.Record
|
|
8
|
+
import androidx.health.connect.client.records.StepsRecord
|
|
9
|
+
import androidx.health.connect.client.records.WeightRecord
|
|
10
|
+
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
11
|
+
import androidx.health.connect.client.time.TimeRangeFilter
|
|
12
|
+
import androidx.health.connect.client.units.Energy
|
|
13
|
+
import androidx.health.connect.client.units.Length
|
|
14
|
+
import androidx.health.connect.client.units.Mass
|
|
15
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
16
|
+
import com.getcapacitor.JSArray
|
|
17
|
+
import com.getcapacitor.JSObject
|
|
18
|
+
import java.time.Instant
|
|
19
|
+
import java.time.ZoneId
|
|
20
|
+
import java.time.ZoneOffset
|
|
21
|
+
import java.time.format.DateTimeFormatter
|
|
22
|
+
import kotlin.math.min
|
|
23
|
+
import kotlin.collections.buildSet
|
|
24
|
+
|
|
25
|
+
class HealthManager {
|
|
26
|
+
|
|
27
|
+
private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT
|
|
28
|
+
|
|
29
|
+
fun permissionsFor(readTypes: Collection<HealthDataType>, writeTypes: Collection<HealthDataType>): Set<String> = buildSet {
|
|
30
|
+
readTypes.forEach { add(it.readPermission) }
|
|
31
|
+
writeTypes.forEach { add(it.writePermission) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
suspend fun authorizationStatus(
|
|
35
|
+
client: HealthConnectClient,
|
|
36
|
+
readTypes: Collection<HealthDataType>,
|
|
37
|
+
writeTypes: Collection<HealthDataType>
|
|
38
|
+
): JSObject {
|
|
39
|
+
val granted = client.permissionController.getGrantedPermissions()
|
|
40
|
+
|
|
41
|
+
val readAuthorized = JSArray()
|
|
42
|
+
val readDenied = JSArray()
|
|
43
|
+
readTypes.forEach { type ->
|
|
44
|
+
if (granted.contains(type.readPermission)) {
|
|
45
|
+
readAuthorized.put(type.identifier)
|
|
46
|
+
} else {
|
|
47
|
+
readDenied.put(type.identifier)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
val writeAuthorized = JSArray()
|
|
52
|
+
val writeDenied = JSArray()
|
|
53
|
+
writeTypes.forEach { type ->
|
|
54
|
+
if (granted.contains(type.writePermission)) {
|
|
55
|
+
writeAuthorized.put(type.identifier)
|
|
56
|
+
} else {
|
|
57
|
+
writeDenied.put(type.identifier)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return JSObject().apply {
|
|
62
|
+
put("readAuthorized", readAuthorized)
|
|
63
|
+
put("readDenied", readDenied)
|
|
64
|
+
put("writeAuthorized", writeAuthorized)
|
|
65
|
+
put("writeDenied", writeDenied)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
suspend fun readSamples(
|
|
70
|
+
client: HealthConnectClient,
|
|
71
|
+
dataType: HealthDataType,
|
|
72
|
+
startTime: Instant,
|
|
73
|
+
endTime: Instant,
|
|
74
|
+
limit: Int,
|
|
75
|
+
ascending: Boolean
|
|
76
|
+
): JSArray {
|
|
77
|
+
val samples = mutableListOf<Pair<Instant, JSObject>>()
|
|
78
|
+
when (dataType) {
|
|
79
|
+
HealthDataType.STEPS -> readRecords(client, StepsRecord::class, startTime, endTime, limit) { record ->
|
|
80
|
+
val payload = createSamplePayload(
|
|
81
|
+
dataType,
|
|
82
|
+
record.startTime,
|
|
83
|
+
record.endTime,
|
|
84
|
+
record.count.toDouble(),
|
|
85
|
+
record.metadata
|
|
86
|
+
)
|
|
87
|
+
samples.add(record.startTime to payload)
|
|
88
|
+
}
|
|
89
|
+
HealthDataType.DISTANCE -> readRecords(client, DistanceRecord::class, startTime, endTime, limit) { record ->
|
|
90
|
+
val payload = createSamplePayload(
|
|
91
|
+
dataType,
|
|
92
|
+
record.startTime,
|
|
93
|
+
record.endTime,
|
|
94
|
+
record.distance.inMeters,
|
|
95
|
+
record.metadata
|
|
96
|
+
)
|
|
97
|
+
samples.add(record.startTime to payload)
|
|
98
|
+
}
|
|
99
|
+
HealthDataType.CALORIES -> readRecords(client, ActiveCaloriesBurnedRecord::class, startTime, endTime, limit) { record ->
|
|
100
|
+
val payload = createSamplePayload(
|
|
101
|
+
dataType,
|
|
102
|
+
record.startTime,
|
|
103
|
+
record.endTime,
|
|
104
|
+
record.energy.inKilocalories,
|
|
105
|
+
record.metadata
|
|
106
|
+
)
|
|
107
|
+
samples.add(record.startTime to payload)
|
|
108
|
+
}
|
|
109
|
+
HealthDataType.WEIGHT -> readRecords(client, WeightRecord::class, startTime, endTime, limit) { record ->
|
|
110
|
+
val payload = createSamplePayload(
|
|
111
|
+
dataType,
|
|
112
|
+
record.time,
|
|
113
|
+
record.time,
|
|
114
|
+
record.weight.inKilograms,
|
|
115
|
+
record.metadata
|
|
116
|
+
)
|
|
117
|
+
samples.add(record.time to payload)
|
|
118
|
+
}
|
|
119
|
+
HealthDataType.HEART_RATE -> readRecords(client, HeartRateRecord::class, startTime, endTime, limit) { record ->
|
|
120
|
+
record.samples.forEach { sample ->
|
|
121
|
+
val payload = createSamplePayload(
|
|
122
|
+
dataType,
|
|
123
|
+
sample.time,
|
|
124
|
+
sample.time,
|
|
125
|
+
sample.beatsPerMinute.toDouble(),
|
|
126
|
+
record.metadata
|
|
127
|
+
)
|
|
128
|
+
samples.add(sample.time to payload)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
val sorted = samples.sortedBy { it.first }
|
|
134
|
+
val ordered = if (ascending) sorted else sorted.asReversed()
|
|
135
|
+
val limited = if (limit > 0) ordered.take(limit) else ordered
|
|
136
|
+
|
|
137
|
+
val array = JSArray()
|
|
138
|
+
limited.forEach { array.put(it.second) }
|
|
139
|
+
return array
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private suspend fun <T : Record> readRecords(
|
|
143
|
+
client: HealthConnectClient,
|
|
144
|
+
recordClass: kotlin.reflect.KClass<T>,
|
|
145
|
+
startTime: Instant,
|
|
146
|
+
endTime: Instant,
|
|
147
|
+
limit: Int,
|
|
148
|
+
consumer: (record: T) -> Unit
|
|
149
|
+
) {
|
|
150
|
+
var pageToken: String? = null
|
|
151
|
+
val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
|
|
152
|
+
var fetched = 0
|
|
153
|
+
|
|
154
|
+
do {
|
|
155
|
+
val request = ReadRecordsRequest(
|
|
156
|
+
recordType = recordClass,
|
|
157
|
+
timeRangeFilter = TimeRangeFilter.between(startTime, endTime),
|
|
158
|
+
pageSize = pageSize,
|
|
159
|
+
pageToken = pageToken
|
|
160
|
+
)
|
|
161
|
+
val response = client.readRecords(request)
|
|
162
|
+
response.records.forEach { record ->
|
|
163
|
+
consumer(record)
|
|
164
|
+
}
|
|
165
|
+
fetched += response.records.size
|
|
166
|
+
pageToken = response.pageToken
|
|
167
|
+
} while (pageToken != null && (limit <= 0 || fetched < limit))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Suppress("UNUSED_PARAMETER")
|
|
171
|
+
suspend fun saveSample(
|
|
172
|
+
client: HealthConnectClient,
|
|
173
|
+
dataType: HealthDataType,
|
|
174
|
+
value: Double,
|
|
175
|
+
startTime: Instant,
|
|
176
|
+
endTime: Instant,
|
|
177
|
+
metadata: Map<String, String>?
|
|
178
|
+
) {
|
|
179
|
+
when (dataType) {
|
|
180
|
+
HealthDataType.STEPS -> {
|
|
181
|
+
val record = StepsRecord(
|
|
182
|
+
startTime = startTime,
|
|
183
|
+
startZoneOffset = zoneOffset(startTime),
|
|
184
|
+
endTime = endTime,
|
|
185
|
+
endZoneOffset = zoneOffset(endTime),
|
|
186
|
+
count = value.toLong().coerceAtLeast(0)
|
|
187
|
+
)
|
|
188
|
+
client.insertRecords(listOf(record))
|
|
189
|
+
}
|
|
190
|
+
HealthDataType.DISTANCE -> {
|
|
191
|
+
val record = DistanceRecord(
|
|
192
|
+
startTime = startTime,
|
|
193
|
+
startZoneOffset = zoneOffset(startTime),
|
|
194
|
+
endTime = endTime,
|
|
195
|
+
endZoneOffset = zoneOffset(endTime),
|
|
196
|
+
distance = Length.meters(value)
|
|
197
|
+
)
|
|
198
|
+
client.insertRecords(listOf(record))
|
|
199
|
+
}
|
|
200
|
+
HealthDataType.CALORIES -> {
|
|
201
|
+
val record = ActiveCaloriesBurnedRecord(
|
|
202
|
+
startTime = startTime,
|
|
203
|
+
startZoneOffset = zoneOffset(startTime),
|
|
204
|
+
endTime = endTime,
|
|
205
|
+
endZoneOffset = zoneOffset(endTime),
|
|
206
|
+
energy = Energy.kilocalories(value)
|
|
207
|
+
)
|
|
208
|
+
client.insertRecords(listOf(record))
|
|
209
|
+
}
|
|
210
|
+
HealthDataType.WEIGHT -> {
|
|
211
|
+
val record = WeightRecord(
|
|
212
|
+
time = startTime,
|
|
213
|
+
zoneOffset = zoneOffset(startTime),
|
|
214
|
+
weight = Mass.kilograms(value)
|
|
215
|
+
)
|
|
216
|
+
client.insertRecords(listOf(record))
|
|
217
|
+
}
|
|
218
|
+
HealthDataType.HEART_RATE -> {
|
|
219
|
+
val samples = listOf(HeartRateRecord.Sample(time = startTime, beatsPerMinute = value.toBpmLong()))
|
|
220
|
+
val record = HeartRateRecord(
|
|
221
|
+
startTime = startTime,
|
|
222
|
+
startZoneOffset = zoneOffset(startTime),
|
|
223
|
+
endTime = endTime,
|
|
224
|
+
endZoneOffset = zoneOffset(endTime),
|
|
225
|
+
samples = samples
|
|
226
|
+
)
|
|
227
|
+
client.insertRecords(listOf(record))
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fun parseInstant(value: String?, defaultInstant: Instant): Instant {
|
|
233
|
+
if (value.isNullOrBlank()) {
|
|
234
|
+
return defaultInstant
|
|
235
|
+
}
|
|
236
|
+
return Instant.parse(value)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun createSamplePayload(
|
|
240
|
+
dataType: HealthDataType,
|
|
241
|
+
startTime: Instant,
|
|
242
|
+
endTime: Instant,
|
|
243
|
+
value: Double,
|
|
244
|
+
metadata: Metadata
|
|
245
|
+
): JSObject {
|
|
246
|
+
val payload = JSObject()
|
|
247
|
+
payload.put("dataType", dataType.identifier)
|
|
248
|
+
payload.put("value", value)
|
|
249
|
+
payload.put("unit", dataType.unit)
|
|
250
|
+
payload.put("startDate", formatter.format(startTime))
|
|
251
|
+
payload.put("endDate", formatter.format(endTime))
|
|
252
|
+
|
|
253
|
+
val dataOrigin = metadata.dataOrigin
|
|
254
|
+
payload.put("sourceId", dataOrigin.packageName)
|
|
255
|
+
payload.put("sourceName", dataOrigin.packageName)
|
|
256
|
+
metadata.device?.let { device ->
|
|
257
|
+
val manufacturer = device.manufacturer?.takeIf { it.isNotBlank() }
|
|
258
|
+
val model = device.model?.takeIf { it.isNotBlank() }
|
|
259
|
+
val label = listOfNotNull(manufacturer, model).joinToString(" ").trim()
|
|
260
|
+
if (label.isNotEmpty()) {
|
|
261
|
+
payload.put("sourceName", label)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return payload
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private fun zoneOffset(instant: Instant): ZoneOffset? {
|
|
269
|
+
return ZoneId.systemDefault().rules.getOffset(instant)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun Double.toBpmLong(): Long {
|
|
273
|
+
return java.lang.Math.round(this.coerceAtLeast(0.0))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
companion object {
|
|
277
|
+
private const val DEFAULT_PAGE_SIZE = 100
|
|
278
|
+
private const val MAX_PAGE_SIZE = 500
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
package app.capgo.plugin.health
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import com.getcapacitor.JSArray
|
|
6
|
+
import com.getcapacitor.JSObject
|
|
7
|
+
import com.getcapacitor.Plugin
|
|
8
|
+
import com.getcapacitor.PluginCall
|
|
9
|
+
import com.getcapacitor.PluginMethod
|
|
10
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
11
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
12
|
+
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
|
|
13
|
+
import java.time.Instant
|
|
14
|
+
import java.time.Duration
|
|
15
|
+
import java.time.format.DateTimeParseException
|
|
16
|
+
import kotlinx.coroutines.CoroutineScope
|
|
17
|
+
import kotlinx.coroutines.Dispatchers
|
|
18
|
+
import kotlinx.coroutines.SupervisorJob
|
|
19
|
+
import kotlinx.coroutines.cancel
|
|
20
|
+
import kotlinx.coroutines.launch
|
|
21
|
+
import kotlinx.coroutines.withContext
|
|
22
|
+
|
|
23
|
+
@CapacitorPlugin(name = "Health")
|
|
24
|
+
class HealthPlugin : Plugin() {
|
|
25
|
+
private val manager = HealthManager()
|
|
26
|
+
private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
27
|
+
private var pendingAuthorization: PendingAuthorization? = null
|
|
28
|
+
private val permissionRequestContract = HealthPermissionsRequestContract()
|
|
29
|
+
|
|
30
|
+
override fun handleOnDestroy() {
|
|
31
|
+
super.handleOnDestroy()
|
|
32
|
+
pluginScope.cancel()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@PluginMethod
|
|
36
|
+
fun isAvailable(call: PluginCall) {
|
|
37
|
+
val status = HealthConnectClient.getSdkStatus(context)
|
|
38
|
+
call.resolve(availabilityPayload(status))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@PluginMethod
|
|
42
|
+
fun requestAuthorization(call: PluginCall) {
|
|
43
|
+
val readTypes = try {
|
|
44
|
+
parseTypeList(call, "read")
|
|
45
|
+
} catch (e: IllegalArgumentException) {
|
|
46
|
+
call.reject(e.message, null, e)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
val writeTypes = try {
|
|
51
|
+
parseTypeList(call, "write")
|
|
52
|
+
} catch (e: IllegalArgumentException) {
|
|
53
|
+
call.reject(e.message, null, e)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pluginScope.launch {
|
|
58
|
+
val client = getClientOrReject(call) ?: return@launch
|
|
59
|
+
val permissions = manager.permissionsFor(readTypes, writeTypes)
|
|
60
|
+
|
|
61
|
+
if (permissions.isEmpty()) {
|
|
62
|
+
val status = manager.authorizationStatus(client, readTypes, writeTypes)
|
|
63
|
+
call.resolve(status)
|
|
64
|
+
return@launch
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
val granted = client.permissionController.getGrantedPermissions()
|
|
68
|
+
if (granted.containsAll(permissions)) {
|
|
69
|
+
val status = manager.authorizationStatus(client, readTypes, writeTypes)
|
|
70
|
+
call.resolve(status)
|
|
71
|
+
return@launch
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
val activity = activity
|
|
75
|
+
if (activity == null) {
|
|
76
|
+
call.reject("Unable to request authorization without an active Activity.")
|
|
77
|
+
return@launch
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (pendingAuthorization != null) {
|
|
81
|
+
call.reject("Another authorization request is already running. Try again later.")
|
|
82
|
+
return@launch
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
val intent = withContext(Dispatchers.IO) {
|
|
86
|
+
permissionRequestContract.createIntent(activity, permissions)
|
|
87
|
+
}
|
|
88
|
+
pendingAuthorization = PendingAuthorization(call, readTypes, writeTypes)
|
|
89
|
+
call.setKeepAlive(true)
|
|
90
|
+
|
|
91
|
+
withContext(Dispatchers.Main) {
|
|
92
|
+
try {
|
|
93
|
+
activity.startActivityForResult(intent, REQUEST_AUTHORIZATION)
|
|
94
|
+
} catch (e: Exception) {
|
|
95
|
+
pendingAuthorization = null
|
|
96
|
+
call.setKeepAlive(false)
|
|
97
|
+
call.reject("Failed to launch Health Connect permission request.", null, e)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@PluginMethod
|
|
104
|
+
fun checkAuthorization(call: PluginCall) {
|
|
105
|
+
val readTypes = try {
|
|
106
|
+
parseTypeList(call, "read")
|
|
107
|
+
} catch (e: IllegalArgumentException) {
|
|
108
|
+
call.reject(e.message, null, e)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
val writeTypes = try {
|
|
113
|
+
parseTypeList(call, "write")
|
|
114
|
+
} catch (e: IllegalArgumentException) {
|
|
115
|
+
call.reject(e.message, null, e)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pluginScope.launch {
|
|
120
|
+
val client = getClientOrReject(call) ?: return@launch
|
|
121
|
+
val status = manager.authorizationStatus(client, readTypes, writeTypes)
|
|
122
|
+
call.resolve(status)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@PluginMethod
|
|
127
|
+
fun readSamples(call: PluginCall) {
|
|
128
|
+
val identifier = call.getString("dataType")
|
|
129
|
+
if (identifier.isNullOrBlank()) {
|
|
130
|
+
call.reject("dataType is required")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val dataType = HealthDataType.from(identifier)
|
|
135
|
+
if (dataType == null) {
|
|
136
|
+
call.reject("Unsupported data type: $identifier")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
|
|
141
|
+
val ascending = call.getBoolean("ascending") ?: false
|
|
142
|
+
|
|
143
|
+
val startInstant = try {
|
|
144
|
+
manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
|
|
145
|
+
} catch (e: DateTimeParseException) {
|
|
146
|
+
call.reject(e.message, null, e)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
val endInstant = try {
|
|
151
|
+
manager.parseInstant(call.getString("endDate"), Instant.now())
|
|
152
|
+
} catch (e: DateTimeParseException) {
|
|
153
|
+
call.reject(e.message, null, e)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (endInstant.isBefore(startInstant)) {
|
|
158
|
+
call.reject("endDate must be greater than or equal to startDate")
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pluginScope.launch {
|
|
163
|
+
val client = getClientOrReject(call) ?: return@launch
|
|
164
|
+
try {
|
|
165
|
+
val samples = manager.readSamples(client, dataType, startInstant, endInstant, limit, ascending)
|
|
166
|
+
val result = JSObject().apply { put("samples", samples) }
|
|
167
|
+
call.resolve(result)
|
|
168
|
+
} catch (e: Exception) {
|
|
169
|
+
call.reject(e.message ?: "Failed to read samples.", null, e)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@PluginMethod
|
|
175
|
+
fun saveSample(call: PluginCall) {
|
|
176
|
+
val identifier = call.getString("dataType")
|
|
177
|
+
if (identifier.isNullOrBlank()) {
|
|
178
|
+
call.reject("dataType is required")
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
val dataType = HealthDataType.from(identifier)
|
|
183
|
+
if (dataType == null) {
|
|
184
|
+
call.reject("Unsupported data type: $identifier")
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
val value = call.getDouble("value")
|
|
189
|
+
if (value == null) {
|
|
190
|
+
call.reject("value is required")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
val unit = call.getString("unit")
|
|
195
|
+
if (unit != null && unit != dataType.unit) {
|
|
196
|
+
call.reject("Unsupported unit $unit for ${dataType.identifier}. Expected ${dataType.unit}.")
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
val startInstant = try {
|
|
201
|
+
manager.parseInstant(call.getString("startDate"), Instant.now())
|
|
202
|
+
} catch (e: DateTimeParseException) {
|
|
203
|
+
call.reject(e.message, null, e)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
val endInstant = try {
|
|
208
|
+
manager.parseInstant(call.getString("endDate"), startInstant)
|
|
209
|
+
} catch (e: DateTimeParseException) {
|
|
210
|
+
call.reject(e.message, null, e)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (endInstant.isBefore(startInstant)) {
|
|
215
|
+
call.reject("endDate must be greater than or equal to startDate")
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
val metadataObj = call.getObject("metadata")
|
|
220
|
+
val metadata = metadataObj?.let { obj ->
|
|
221
|
+
val iterator = obj.keys()
|
|
222
|
+
val map = mutableMapOf<String, String>()
|
|
223
|
+
while (iterator.hasNext()) {
|
|
224
|
+
val key = iterator.next()
|
|
225
|
+
val rawValue = obj.opt(key)
|
|
226
|
+
if (rawValue is String) {
|
|
227
|
+
map[key] = rawValue
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
map.takeIf { it.isNotEmpty() }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pluginScope.launch {
|
|
234
|
+
val client = getClientOrReject(call) ?: return@launch
|
|
235
|
+
try {
|
|
236
|
+
manager.saveSample(client, dataType, value, startInstant, endInstant, metadata)
|
|
237
|
+
call.resolve()
|
|
238
|
+
} catch (e: Exception) {
|
|
239
|
+
call.reject(e.message ?: "Failed to save sample.", null, e)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
245
|
+
super.handleOnActivityResult(requestCode, resultCode, data)
|
|
246
|
+
if (requestCode != REQUEST_AUTHORIZATION) {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
val pending = pendingAuthorization ?: return
|
|
251
|
+
pendingAuthorization = null
|
|
252
|
+
pending.call.setKeepAlive(false)
|
|
253
|
+
|
|
254
|
+
pluginScope.launch {
|
|
255
|
+
val client = getClientOrReject(pending.call) ?: return@launch
|
|
256
|
+
if (resultCode != Activity.RESULT_OK) {
|
|
257
|
+
pending.call.reject("Authorization request was cancelled or denied.")
|
|
258
|
+
return@launch
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
val status = manager.authorizationStatus(client, pending.readTypes, pending.writeTypes)
|
|
262
|
+
pending.call.resolve(status)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun parseTypeList(call: PluginCall, key: String): List<HealthDataType> {
|
|
267
|
+
val array = call.getArray(key) ?: JSArray()
|
|
268
|
+
val result = mutableListOf<HealthDataType>()
|
|
269
|
+
for (i in 0 until array.length()) {
|
|
270
|
+
val identifier = array.optString(i, null) ?: continue
|
|
271
|
+
val dataType = HealthDataType.from(identifier)
|
|
272
|
+
?: throw IllegalArgumentException("Unsupported data type: $identifier")
|
|
273
|
+
result.add(dataType)
|
|
274
|
+
}
|
|
275
|
+
return result
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private fun getClientOrReject(call: PluginCall): HealthConnectClient? {
|
|
279
|
+
val status = HealthConnectClient.getSdkStatus(context)
|
|
280
|
+
if (status != HealthConnectClient.SDK_AVAILABLE) {
|
|
281
|
+
call.reject(availabilityReason(status))
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
return HealthConnectClient.getOrCreate(context)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private fun availabilityPayload(status: Int): JSObject {
|
|
288
|
+
val payload = JSObject()
|
|
289
|
+
payload.put("platform", "android")
|
|
290
|
+
payload.put("available", status == HealthConnectClient.SDK_AVAILABLE)
|
|
291
|
+
if (status != HealthConnectClient.SDK_AVAILABLE) {
|
|
292
|
+
payload.put("reason", availabilityReason(status))
|
|
293
|
+
}
|
|
294
|
+
return payload
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun availabilityReason(status: Int): String {
|
|
298
|
+
return when (status) {
|
|
299
|
+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "Health Connect needs an update."
|
|
300
|
+
HealthConnectClient.SDK_UNAVAILABLE -> "Health Connect is unavailable on this device."
|
|
301
|
+
else -> "Health Connect availability unknown."
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private data class PendingAuthorization(
|
|
306
|
+
val call: PluginCall,
|
|
307
|
+
val readTypes: List<HealthDataType>,
|
|
308
|
+
val writeTypes: List<HealthDataType>
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
companion object {
|
|
312
|
+
private const val REQUEST_AUTHORIZATION = 9501
|
|
313
|
+
private const val DEFAULT_LIMIT = 100
|
|
314
|
+
private val DEFAULT_PAST_DURATION: Duration = Duration.ofDays(1)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
File without changes
|