@flomentumsolutions/capacitor-health-extended 0.6.3 → 0.7.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/README.md +63 -4
- package/android/src/main/java/com/flomentum/{health → vitals}/capacitor/HealthPlugin.kt +261 -3
- package/dist/esm/definitions.d.ts +25 -2
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +380 -6
- package/package.json +1 -1
- /package/android/src/main/java/com/flomentum/{health → vitals}/capacitor/PermissionsRationaleActivity.kt +0 -0
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
|
|
|
27
27
|
- Request and verify health permissions
|
|
28
28
|
- Query aggregated data like steps or calories
|
|
29
29
|
- Retrieve workout sessions with optional route and heart rate data
|
|
30
|
-
-
|
|
30
|
+
- Create workout sessions (e.g., rock climbing) with totals, optional routes, and heart-rate samples (write APIs)
|
|
31
|
+
- Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep (incl. REM duration), and exercise time.
|
|
31
32
|
- Read profile characteristics on iOS: biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use.
|
|
32
33
|
|
|
33
34
|
### Supported data types (parity iOS + Android)
|
|
@@ -36,7 +37,7 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
|
|
|
36
37
|
- Vitals: heart rate, resting heart rate, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature, basal body temperature
|
|
37
38
|
- Body: weight, height, body fat
|
|
38
39
|
- Characteristics (iOS): biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use
|
|
39
|
-
- Sessions: mindfulness, sleep
|
|
40
|
+
- Sessions: mindfulness, sleep, sleep REM (latest sample only)
|
|
40
41
|
|
|
41
42
|
## Install
|
|
42
43
|
|
|
@@ -57,6 +58,7 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
57
58
|
* Make sure your app id has the 'HealthKit' entitlement when this plugin is installed (see iOS dev center).
|
|
58
59
|
* Also, make sure your app and App Store description comply with the Apple review guidelines.
|
|
59
60
|
* There are two keys to be added to the info.plist file: NSHealthShareUsageDescription and NSHealthUpdateUsageDescription.
|
|
61
|
+
* Request WRITE_* permissions with `requestHealthPermissions` to enable saving workouts/energy/distance/routes/heart-rate samples to HealthKit.
|
|
60
62
|
|
|
61
63
|
### Android
|
|
62
64
|
|
|
@@ -90,8 +92,16 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
90
92
|
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
|
|
91
93
|
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
|
92
94
|
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
|
95
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
|
|
96
|
+
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
|
|
97
|
+
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
|
|
98
|
+
<uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
|
|
99
|
+
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
|
|
100
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
|
|
93
101
|
```
|
|
94
102
|
|
|
103
|
+
Include the WRITE_* entries when you call `saveWorkout` to insert exercise sessions, energy, distance, routes, or heart rate samples.
|
|
104
|
+
|
|
95
105
|
* Android Manifest in application tag
|
|
96
106
|
```xml
|
|
97
107
|
<!-- Handle Health Connect rationale (Android 13-) -->
|
|
@@ -223,6 +233,7 @@ This setup ensures your WebView will load HTTPS content securely and complies wi
|
|
|
223
233
|
* [`queryHeight()`](#queryheight)
|
|
224
234
|
* [`queryHeartRate()`](#queryheartrate)
|
|
225
235
|
* [`querySteps()`](#querysteps)
|
|
236
|
+
* [`saveWorkout(...)`](#saveworkout)
|
|
226
237
|
* [Interfaces](#interfaces)
|
|
227
238
|
* [Type Aliases](#type-aliases)
|
|
228
239
|
|
|
@@ -384,6 +395,7 @@ queryLatestSample(request: { dataType: LatestDataType; }) => Promise<QueryLatest
|
|
|
384
395
|
|
|
385
396
|
Query latest sample for a specific data type
|
|
386
397
|
- Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.
|
|
398
|
+
- `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
|
|
387
399
|
|
|
388
400
|
| Param | Type |
|
|
389
401
|
| ------------- | ------------------------------------------------------------------------ |
|
|
@@ -446,6 +458,26 @@ Query latest steps sample
|
|
|
446
458
|
--------------------
|
|
447
459
|
|
|
448
460
|
|
|
461
|
+
### saveWorkout(...)
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
saveWorkout(request: SaveWorkoutRequest) => Promise<SaveWorkoutResponse>
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Create a workout session with optional totals and route/heart-rate samples.
|
|
468
|
+
- iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
|
|
469
|
+
- Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
|
|
470
|
+
- Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
|
|
471
|
+
|
|
472
|
+
| Param | Type |
|
|
473
|
+
| ------------- | ----------------------------------------------------------------- |
|
|
474
|
+
| **`request`** | <code><a href="#saveworkoutrequest">SaveWorkoutRequest</a></code> |
|
|
475
|
+
|
|
476
|
+
**Returns:** <code>Promise<<a href="#saveworkoutresponse">SaveWorkoutResponse</a>></code>
|
|
477
|
+
|
|
478
|
+
--------------------
|
|
479
|
+
|
|
480
|
+
|
|
449
481
|
### Interfaces
|
|
450
482
|
|
|
451
483
|
|
|
@@ -572,6 +604,28 @@ Query latest steps sample
|
|
|
572
604
|
| **`metadata`** | <code><a href="#record">Record</a><string, unknown></code> |
|
|
573
605
|
|
|
574
606
|
|
|
607
|
+
#### SaveWorkoutResponse
|
|
608
|
+
|
|
609
|
+
| Prop | Type |
|
|
610
|
+
| ------------- | -------------------- |
|
|
611
|
+
| **`success`** | <code>boolean</code> |
|
|
612
|
+
| **`id`** | <code>string</code> |
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
#### SaveWorkoutRequest
|
|
616
|
+
|
|
617
|
+
| Prop | Type |
|
|
618
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
619
|
+
| **`activityType`** | <code><a href="#workoutactivitytype">WorkoutActivityType</a></code> |
|
|
620
|
+
| **`startDate`** | <code>string</code> |
|
|
621
|
+
| **`endDate`** | <code>string</code> |
|
|
622
|
+
| **`calories`** | <code>number</code> |
|
|
623
|
+
| **`distance`** | <code>number</code> |
|
|
624
|
+
| **`metadata`** | <code><a href="#record">Record</a><string, any></code> |
|
|
625
|
+
| **`route`** | <code>RouteSample[]</code> |
|
|
626
|
+
| **`heartRateSamples`** | <code>HeartRateSample[]</code> |
|
|
627
|
+
|
|
628
|
+
|
|
575
629
|
### Type Aliases
|
|
576
630
|
|
|
577
631
|
|
|
@@ -584,7 +638,7 @@ Construct a type with a set of properties K of type T
|
|
|
584
638
|
|
|
585
639
|
#### HealthPermission
|
|
586
640
|
|
|
587
|
-
<code>'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
|
|
641
|
+
<code>'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
|
|
588
642
|
|
|
589
643
|
|
|
590
644
|
#### HealthBiologicalSex
|
|
@@ -609,6 +663,11 @@ Construct a type with a set of properties K of type T
|
|
|
609
663
|
|
|
610
664
|
#### LatestDataType
|
|
611
665
|
|
|
612
|
-
<code>'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure'</code>
|
|
666
|
+
<code>'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure'</code>
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
#### WorkoutActivityType
|
|
670
|
+
|
|
671
|
+
<code>'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other'</code>
|
|
613
672
|
|
|
614
673
|
</docgen-api>
|
|
@@ -10,11 +10,14 @@ import androidx.health.connect.client.aggregate.AggregateMetric
|
|
|
10
10
|
import androidx.health.connect.client.aggregate.AggregationResult
|
|
11
11
|
import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
|
|
12
12
|
import androidx.health.connect.client.records.*
|
|
13
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
13
14
|
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
14
15
|
import androidx.health.connect.client.request.AggregateRequest
|
|
15
16
|
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
16
17
|
import androidx.health.connect.client.time.TimeRangeFilter
|
|
17
18
|
import androidx.health.connect.client.records.Record
|
|
19
|
+
import androidx.health.connect.client.units.kilocalories
|
|
20
|
+
import androidx.health.connect.client.units.meters
|
|
18
21
|
import com.getcapacitor.JSArray
|
|
19
22
|
import com.getcapacitor.JSObject
|
|
20
23
|
import com.getcapacitor.Plugin
|
|
@@ -57,7 +60,13 @@ enum class CapHealthPermission {
|
|
|
57
60
|
READ_BODY_FAT,
|
|
58
61
|
READ_FLOORS_CLIMBED,
|
|
59
62
|
READ_BASAL_CALORIES,
|
|
60
|
-
READ_SLEEP
|
|
63
|
+
READ_SLEEP,
|
|
64
|
+
WRITE_WORKOUTS,
|
|
65
|
+
WRITE_ACTIVE_CALORIES,
|
|
66
|
+
WRITE_TOTAL_CALORIES,
|
|
67
|
+
WRITE_DISTANCE,
|
|
68
|
+
WRITE_HEART_RATE,
|
|
69
|
+
WRITE_ROUTE;
|
|
61
70
|
|
|
62
71
|
companion object {
|
|
63
72
|
fun from(s: String): CapHealthPermission? {
|
|
@@ -95,7 +104,13 @@ enum class CapHealthPermission {
|
|
|
95
104
|
Permission(alias = "READ_BODY_FAT", strings = ["android.permission.health.READ_BODY_FAT"]),
|
|
96
105
|
Permission(alias = "READ_FLOORS_CLIMBED", strings = ["android.permission.health.READ_FLOORS_CLIMBED"]),
|
|
97
106
|
Permission(alias = "READ_BASAL_CALORIES", strings = ["android.permission.health.READ_BASAL_METABOLIC_RATE"]),
|
|
98
|
-
Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"])
|
|
107
|
+
Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"]),
|
|
108
|
+
Permission(alias = "WRITE_WORKOUTS", strings = ["android.permission.health.WRITE_EXERCISE"]),
|
|
109
|
+
Permission(alias = "WRITE_ACTIVE_CALORIES", strings = ["android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"]),
|
|
110
|
+
Permission(alias = "WRITE_TOTAL_CALORIES", strings = ["android.permission.health.WRITE_TOTAL_CALORIES_BURNED"]),
|
|
111
|
+
Permission(alias = "WRITE_DISTANCE", strings = ["android.permission.health.WRITE_DISTANCE"]),
|
|
112
|
+
Permission(alias = "WRITE_HEART_RATE", strings = ["android.permission.health.WRITE_HEART_RATE"]),
|
|
113
|
+
Permission(alias = "WRITE_ROUTE", strings = ["android.permission.health.WRITE_EXERCISE_ROUTE"])
|
|
99
114
|
]
|
|
100
115
|
)
|
|
101
116
|
|
|
@@ -132,7 +147,13 @@ class HealthPlugin : Plugin() {
|
|
|
132
147
|
CapHealthPermission.READ_BODY_FAT to HealthPermission.getReadPermission(BodyFatRecord::class),
|
|
133
148
|
CapHealthPermission.READ_FLOORS_CLIMBED to HealthPermission.getReadPermission(FloorsClimbedRecord::class),
|
|
134
149
|
CapHealthPermission.READ_BASAL_CALORIES to HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
|
135
|
-
CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class)
|
|
150
|
+
CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class),
|
|
151
|
+
CapHealthPermission.WRITE_WORKOUTS to HealthPermission.getWritePermission(ExerciseSessionRecord::class),
|
|
152
|
+
CapHealthPermission.WRITE_ACTIVE_CALORIES to HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
|
|
153
|
+
CapHealthPermission.WRITE_TOTAL_CALORIES to HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
|
|
154
|
+
CapHealthPermission.WRITE_DISTANCE to HealthPermission.getWritePermission(DistanceRecord::class),
|
|
155
|
+
CapHealthPermission.WRITE_HEART_RATE to HealthPermission.getWritePermission(HeartRateRecord::class),
|
|
156
|
+
CapHealthPermission.WRITE_ROUTE to HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE
|
|
136
157
|
)
|
|
137
158
|
|
|
138
159
|
override fun load() {
|
|
@@ -405,6 +426,7 @@ class HealthPlugin : Plugin() {
|
|
|
405
426
|
"exercise-time" -> readLatestExerciseTime()
|
|
406
427
|
"mindfulness" -> readLatestMindfulness()
|
|
407
428
|
"sleep" -> readLatestSleep()
|
|
429
|
+
"sleep-rem" -> readLatestSleepRem()
|
|
408
430
|
else -> throw IllegalArgumentException("Unsupported data type: $dataType")
|
|
409
431
|
}
|
|
410
432
|
call.resolve(result)
|
|
@@ -815,6 +837,32 @@ class HealthPlugin : Plugin() {
|
|
|
815
837
|
}
|
|
816
838
|
}
|
|
817
839
|
|
|
840
|
+
private suspend fun readLatestSleepRem(): JSObject {
|
|
841
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
842
|
+
if (!hasSleepPermission) {
|
|
843
|
+
throw Exception("Permission for sleep not granted")
|
|
844
|
+
}
|
|
845
|
+
val request = ReadRecordsRequest(
|
|
846
|
+
recordType = SleepSessionRecord::class,
|
|
847
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
848
|
+
pageSize = 1
|
|
849
|
+
)
|
|
850
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
851
|
+
?: throw Exception("No sleep data found")
|
|
852
|
+
val remMinutes = record.stages
|
|
853
|
+
.filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }
|
|
854
|
+
.sumOf { it.endTime.epochSecond - it.startTime.epochSecond } / 60.0
|
|
855
|
+
if (remMinutes <= 0) {
|
|
856
|
+
throw Exception("No REM sleep data found")
|
|
857
|
+
}
|
|
858
|
+
return JSObject().apply {
|
|
859
|
+
put("value", remMinutes)
|
|
860
|
+
put("timestamp", record.startTime.epochSecond * 1000)
|
|
861
|
+
put("endTimestamp", record.endTime.epochSecond * 1000)
|
|
862
|
+
put("unit", "min")
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
818
866
|
private suspend fun sumActiveAndBasalCalories(
|
|
819
867
|
timeRange: TimeRangeFilter,
|
|
820
868
|
includeTotalMetricFallback: Boolean = false
|
|
@@ -1471,6 +1519,151 @@ class HealthPlugin : Plugin() {
|
|
|
1471
1519
|
return granted.contains(targetPermission)
|
|
1472
1520
|
}
|
|
1473
1521
|
|
|
1522
|
+
@PluginMethod
|
|
1523
|
+
fun saveWorkout(call: PluginCall) {
|
|
1524
|
+
if (!ensureClientInitialized(call)) return
|
|
1525
|
+
|
|
1526
|
+
val activityTypeRaw = call.getString("activityType")
|
|
1527
|
+
val startDate = call.getString("startDate")
|
|
1528
|
+
val endDate = call.getString("endDate")
|
|
1529
|
+
val calories = call.getDouble("calories")
|
|
1530
|
+
val distance = call.getDouble("distance")
|
|
1531
|
+
val metadata = call.getObject("metadata")
|
|
1532
|
+
val routeArray = call.getArray("route")
|
|
1533
|
+
val heartRateArray = call.getArray("heartRateSamples")
|
|
1534
|
+
|
|
1535
|
+
if (activityTypeRaw == null || startDate == null || endDate == null) {
|
|
1536
|
+
call.reject("Missing required parameters: activityType, startDate, endDate")
|
|
1537
|
+
return
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
val startInstant = parseInstant(startDate)
|
|
1541
|
+
val endInstant = parseInstant(endDate)
|
|
1542
|
+
if (startInstant == null || endInstant == null) {
|
|
1543
|
+
call.reject("Invalid startDate or endDate; must be ISO-8601 strings.")
|
|
1544
|
+
return
|
|
1545
|
+
}
|
|
1546
|
+
if (!startInstant.isBefore(endInstant)) {
|
|
1547
|
+
call.reject("startDate must be before endDate")
|
|
1548
|
+
return
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
1552
|
+
try {
|
|
1553
|
+
val granted = healthConnectClient.permissionController.getGrantedPermissions()
|
|
1554
|
+
fun ensurePermission(cap: CapHealthPermission, name: String): Boolean {
|
|
1555
|
+
val hc = permissionMapping[cap]
|
|
1556
|
+
val ok = hc != null && granted.contains(hc)
|
|
1557
|
+
if (!ok) {
|
|
1558
|
+
call.reject("Missing $name permission")
|
|
1559
|
+
}
|
|
1560
|
+
return ok
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (!ensurePermission(CapHealthPermission.WRITE_WORKOUTS, "WRITE_WORKOUTS")) return@launch
|
|
1564
|
+
|
|
1565
|
+
val startZoneOffset = startInstant.atZone(ZoneId.systemDefault()).offset
|
|
1566
|
+
val endZoneOffset = endInstant.atZone(ZoneId.systemDefault()).offset
|
|
1567
|
+
|
|
1568
|
+
val exerciseType = resolveExerciseType(activityTypeRaw)
|
|
1569
|
+
val title = metadata?.optString("title")?.takeIf { it.isNotBlank() }
|
|
1570
|
+
val notes = metadata?.optString("notes")?.takeIf { it.isNotBlank() }
|
|
1571
|
+
?: metadata?.optString("description")?.takeIf { it.isNotBlank() }
|
|
1572
|
+
|
|
1573
|
+
val route = buildExerciseRoute(routeArray, startInstant, endInstant)
|
|
1574
|
+
if (route != null && !granted.contains(HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE)) {
|
|
1575
|
+
call.reject("Missing WRITE_ROUTE permission")
|
|
1576
|
+
return@launch
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
val records = mutableListOf<Record>()
|
|
1580
|
+
records.add(
|
|
1581
|
+
ExerciseSessionRecord(
|
|
1582
|
+
startTime = startInstant,
|
|
1583
|
+
startZoneOffset = startZoneOffset,
|
|
1584
|
+
endTime = endInstant,
|
|
1585
|
+
endZoneOffset = endZoneOffset,
|
|
1586
|
+
metadata = Metadata.manualEntry(),
|
|
1587
|
+
exerciseType = exerciseType,
|
|
1588
|
+
title = title,
|
|
1589
|
+
notes = notes,
|
|
1590
|
+
exerciseRoute = route
|
|
1591
|
+
)
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
if (calories != null) {
|
|
1595
|
+
when {
|
|
1596
|
+
granted.contains(permissionMapping[CapHealthPermission.WRITE_ACTIVE_CALORIES]) -> {
|
|
1597
|
+
records.add(
|
|
1598
|
+
ActiveCaloriesBurnedRecord(
|
|
1599
|
+
startTime = startInstant,
|
|
1600
|
+
startZoneOffset = startZoneOffset,
|
|
1601
|
+
endTime = endInstant,
|
|
1602
|
+
endZoneOffset = endZoneOffset,
|
|
1603
|
+
energy = kilocalories(calories),
|
|
1604
|
+
metadata = Metadata.manualEntry()
|
|
1605
|
+
)
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
granted.contains(permissionMapping[CapHealthPermission.WRITE_TOTAL_CALORIES]) -> {
|
|
1609
|
+
records.add(
|
|
1610
|
+
TotalCaloriesBurnedRecord(
|
|
1611
|
+
startTime = startInstant,
|
|
1612
|
+
startZoneOffset = startZoneOffset,
|
|
1613
|
+
endTime = endInstant,
|
|
1614
|
+
endZoneOffset = endZoneOffset,
|
|
1615
|
+
energy = kilocalories(calories),
|
|
1616
|
+
metadata = Metadata.manualEntry()
|
|
1617
|
+
)
|
|
1618
|
+
)
|
|
1619
|
+
}
|
|
1620
|
+
else -> {
|
|
1621
|
+
call.reject("Missing WRITE_ACTIVE_CALORIES or WRITE_TOTAL_CALORIES permission")
|
|
1622
|
+
return@launch
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (distance != null) {
|
|
1628
|
+
if (!ensurePermission(CapHealthPermission.WRITE_DISTANCE, "WRITE_DISTANCE")) return@launch
|
|
1629
|
+
records.add(
|
|
1630
|
+
DistanceRecord(
|
|
1631
|
+
startTime = startInstant,
|
|
1632
|
+
startZoneOffset = startZoneOffset,
|
|
1633
|
+
endTime = endInstant,
|
|
1634
|
+
endZoneOffset = endZoneOffset,
|
|
1635
|
+
distance = meters(distance),
|
|
1636
|
+
metadata = Metadata.manualEntry()
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
val heartRateSamples = buildHeartRateSamples(heartRateArray, startInstant, endInstant)
|
|
1642
|
+
if (heartRateSamples.isNotEmpty()) {
|
|
1643
|
+
if (!ensurePermission(CapHealthPermission.WRITE_HEART_RATE, "WRITE_HEART_RATE")) return@launch
|
|
1644
|
+
records.add(
|
|
1645
|
+
HeartRateRecord(
|
|
1646
|
+
startTime = heartRateSamples.first().time,
|
|
1647
|
+
startZoneOffset = heartRateSamples.first().time.atZone(ZoneId.systemDefault()).offset,
|
|
1648
|
+
endTime = heartRateSamples.last().time,
|
|
1649
|
+
endZoneOffset = heartRateSamples.last().time.atZone(ZoneId.systemDefault()).offset,
|
|
1650
|
+
samples = heartRateSamples,
|
|
1651
|
+
metadata = Metadata.manualEntry()
|
|
1652
|
+
)
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
val response = healthConnectClient.insertRecords(records)
|
|
1657
|
+
val result = JSObject()
|
|
1658
|
+
result.put("success", true)
|
|
1659
|
+
response.recordIdsList.firstOrNull()?.let { result.put("id", it) }
|
|
1660
|
+
call.resolve(result)
|
|
1661
|
+
} catch (e: Exception) {
|
|
1662
|
+
call.reject("Failed to save workout: ${e.message}")
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1474
1667
|
@PluginMethod
|
|
1475
1668
|
fun queryWorkouts(call: PluginCall) {
|
|
1476
1669
|
if (!ensureClientInitialized(call)) return
|
|
@@ -1662,6 +1855,71 @@ class HealthPlugin : Plugin() {
|
|
|
1662
1855
|
return routeArray
|
|
1663
1856
|
}
|
|
1664
1857
|
|
|
1858
|
+
private fun parseInstant(value: Any?): Instant? {
|
|
1859
|
+
return when (value) {
|
|
1860
|
+
is String -> {
|
|
1861
|
+
try {
|
|
1862
|
+
Instant.parse(value)
|
|
1863
|
+
} catch (_: Exception) {
|
|
1864
|
+
value.toDoubleOrNull()?.let { Instant.ofEpochMilli(it.toLong()) }
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
is Number -> Instant.ofEpochMilli(value.toLong())
|
|
1868
|
+
else -> null
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private fun resolveExerciseType(activityTypeRaw: String): Int {
|
|
1873
|
+
val normalized = activityTypeRaw.lowercase().replace("-", "_")
|
|
1874
|
+
return ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP[normalized]
|
|
1875
|
+
?: exerciseTypeMapping.entries.firstOrNull { it.value.equals(activityTypeRaw, ignoreCase = true) }?.key?.toInt()
|
|
1876
|
+
?: ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
private fun buildExerciseRoute(routeArray: JSArray?, startTime: Instant, endTime: Instant): ExerciseRoute? {
|
|
1880
|
+
val locations = routeArray
|
|
1881
|
+
?.toList<JSObject>()
|
|
1882
|
+
?.mapNotNull { point ->
|
|
1883
|
+
val lat = point.optDouble("lat", Double.NaN)
|
|
1884
|
+
val lng = point.optDouble("lng", Double.NaN)
|
|
1885
|
+
val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
|
|
1886
|
+
if (lat.isNaN() || lng.isNaN()) return@mapNotNull null
|
|
1887
|
+
if (ts.isBefore(startTime) || ts.isAfter(endTime)) return@mapNotNull null
|
|
1888
|
+
val altitudeValue = point.optDouble("alt", Double.NaN)
|
|
1889
|
+
ExerciseRoute.Location(
|
|
1890
|
+
time = ts,
|
|
1891
|
+
latitude = lat,
|
|
1892
|
+
longitude = lng,
|
|
1893
|
+
altitude = if (altitudeValue.isNaN()) null else meters(altitudeValue)
|
|
1894
|
+
)
|
|
1895
|
+
}
|
|
1896
|
+
?.sortedBy { it.time }
|
|
1897
|
+
?.toList()
|
|
1898
|
+
|
|
1899
|
+
if (locations.isNullOrEmpty()) {
|
|
1900
|
+
return null
|
|
1901
|
+
}
|
|
1902
|
+
return ExerciseRoute(locations)
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
private fun buildHeartRateSamples(array: JSArray?, startTime: Instant, endTime: Instant): List<HeartRateRecord.Sample> {
|
|
1906
|
+
return array
|
|
1907
|
+
?.toList<JSObject>()
|
|
1908
|
+
?.mapNotNull { point ->
|
|
1909
|
+
val bpm = point.optDouble("bpm", Double.NaN)
|
|
1910
|
+
val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
|
|
1911
|
+
if (bpm.isNaN()) return@mapNotNull null
|
|
1912
|
+
val clampedBpm = bpm.toLong().coerceIn(1, 300)
|
|
1913
|
+
HeartRateRecord.Sample(
|
|
1914
|
+
time = ts,
|
|
1915
|
+
beatsPerMinute = clampedBpm
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
?.filter { !it.time.isBefore(startTime) && !it.time.isAfter(endTime) }
|
|
1919
|
+
?.sortedBy { it.time }
|
|
1920
|
+
?: emptyList()
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1665
1923
|
|
|
1666
1924
|
private val exerciseTypeMapping = mapOf(
|
|
1667
1925
|
0 to "OTHER",
|
|
@@ -64,6 +64,7 @@ export interface HealthPlugin {
|
|
|
64
64
|
/**
|
|
65
65
|
* Query latest sample for a specific data type
|
|
66
66
|
* - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.
|
|
67
|
+
* - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
|
|
67
68
|
* @param request
|
|
68
69
|
*/
|
|
69
70
|
queryLatestSample(request: {
|
|
@@ -85,9 +86,16 @@ export interface HealthPlugin {
|
|
|
85
86
|
* Query latest steps sample
|
|
86
87
|
*/
|
|
87
88
|
querySteps(): Promise<QueryLatestSampleResponse>;
|
|
89
|
+
/**
|
|
90
|
+
* Create a workout session with optional totals and route/heart-rate samples.
|
|
91
|
+
* - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
|
|
92
|
+
* - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
|
|
93
|
+
* - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
|
|
94
|
+
*/
|
|
95
|
+
saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;
|
|
88
96
|
}
|
|
89
|
-
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
|
|
90
|
-
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure';
|
|
97
|
+
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
|
|
98
|
+
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure';
|
|
91
99
|
export interface PermissionsRequest {
|
|
92
100
|
permissions: HealthPermission[];
|
|
93
101
|
}
|
|
@@ -114,6 +122,21 @@ export interface RouteSample {
|
|
|
114
122
|
export interface QueryWorkoutResponse {
|
|
115
123
|
workouts: Workout[];
|
|
116
124
|
}
|
|
125
|
+
export type WorkoutActivityType = 'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other';
|
|
126
|
+
export interface SaveWorkoutRequest {
|
|
127
|
+
activityType: WorkoutActivityType;
|
|
128
|
+
startDate: string;
|
|
129
|
+
endDate: string;
|
|
130
|
+
calories?: number;
|
|
131
|
+
distance?: number;
|
|
132
|
+
metadata?: Record<string, any>;
|
|
133
|
+
route?: RouteSample[];
|
|
134
|
+
heartRateSamples?: HeartRateSample[];
|
|
135
|
+
}
|
|
136
|
+
export interface SaveWorkoutResponse {
|
|
137
|
+
success: boolean;
|
|
138
|
+
id?: string;
|
|
139
|
+
}
|
|
117
140
|
export interface Workout {
|
|
118
141
|
startDate: string;
|
|
119
142
|
endDate: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Create a workout session with optional totals and route/heart-rate samples.\n * - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.\n * - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.\n * - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).\n */\n saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'WRITE_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'WRITE_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'WRITE_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'WRITE_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'WRITE_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'WRITE_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'sleep-rem'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport type WorkoutActivityType =\n | 'rock-climbing'\n | 'climbing'\n | 'hiking'\n | 'running'\n | 'walking'\n | 'cycling'\n | 'biking'\n | 'strength-training'\n | 'yoga'\n | 'other';\n\nexport interface SaveWorkoutRequest {\n activityType: WorkoutActivityType;\n startDate: string;\n endDate: string;\n calories?: number;\n distance?: number;\n metadata?: Record<string, any>;\n route?: RouteSample[];\n heartRateSamples?: HeartRateSample[];\n}\n\nexport interface SaveWorkoutResponse {\n success: boolean;\n id?: string;\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
@@ -2,6 +2,7 @@ import Foundation
|
|
|
2
2
|
import UIKit
|
|
3
3
|
import Capacitor
|
|
4
4
|
import HealthKit
|
|
5
|
+
import CoreLocation
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Please read the Capacitor iOS Plugin Development Guide
|
|
@@ -19,7 +20,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
19
20
|
CAPPluginMethod(name: "queryAggregated", returnType: CAPPluginReturnPromise),
|
|
20
21
|
CAPPluginMethod(name: "queryWorkouts", returnType: CAPPluginReturnPromise),
|
|
21
22
|
CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise),
|
|
22
|
-
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise)
|
|
23
|
+
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "saveWorkout", returnType: CAPPluginReturnPromise)
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
let healthStore = HKHealthStore()
|
|
@@ -41,7 +43,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
41
43
|
var result: [String: String] = [:]
|
|
42
44
|
|
|
43
45
|
for permission in permissions {
|
|
44
|
-
let hkTypes = permissionToHKObjectType(permission)
|
|
46
|
+
let hkTypes = permissionToHKObjectType(permission) + permissionToHKSampleType(permission)
|
|
45
47
|
for type in hkTypes {
|
|
46
48
|
let status = healthStore.authorizationStatus(for: type)
|
|
47
49
|
|
|
@@ -69,19 +71,20 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
69
71
|
|
|
70
72
|
print("⚡️ [HealthPlugin] Requesting permissions: \(permissions)")
|
|
71
73
|
|
|
72
|
-
let
|
|
74
|
+
let readTypes: [HKObjectType] = permissions.flatMap { permissionToHKObjectType($0) }
|
|
75
|
+
let shareTypes: [HKSampleType] = permissions.flatMap { permissionToHKSampleType($0) }
|
|
73
76
|
|
|
74
|
-
print("⚡️ [HealthPlugin] Mapped to \(
|
|
77
|
+
print("⚡️ [HealthPlugin] Mapped to \(readTypes.count) HKObjectTypes (read) and \(shareTypes.count) HKSampleTypes (share)")
|
|
75
78
|
|
|
76
79
|
// Validate that we have at least one valid permission type
|
|
77
|
-
guard !
|
|
80
|
+
guard !readTypes.isEmpty || !shareTypes.isEmpty else {
|
|
78
81
|
let invalidPermissions = permissions.filter { permissionToHKObjectType($0).isEmpty }
|
|
79
82
|
call.reject("No valid permission types found. Invalid permissions: \(invalidPermissions)")
|
|
80
83
|
return
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
DispatchQueue.main.async {
|
|
84
|
-
self.healthStore.requestAuthorization(toShare:
|
|
87
|
+
self.healthStore.requestAuthorization(toShare: Set(shareTypes), read: Set(readTypes)) { success, error in
|
|
85
88
|
DispatchQueue.main.async {
|
|
86
89
|
if success {
|
|
87
90
|
//we don't know which actual permissions were granted, so we assume all
|
|
@@ -349,6 +352,113 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
349
352
|
healthStore.execute(query)
|
|
350
353
|
return
|
|
351
354
|
}
|
|
355
|
+
// ---- Special handling for REM sleep sessions (category samples) ----
|
|
356
|
+
if dataTypeString == "sleep-rem" {
|
|
357
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
358
|
+
call.reject("Sleep type not available")
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let endDate = Date()
|
|
363
|
+
let startDate = Calendar.current.date(byAdding: .hour, value: -36, to: endDate) ?? endDate.addingTimeInterval(-36 * 3600)
|
|
364
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictEndDate)
|
|
365
|
+
|
|
366
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
367
|
+
if let error = error {
|
|
368
|
+
call.reject("Error fetching latest REM sleep sample", "NO_SAMPLE", error)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
guard let categorySamples = samples as? [HKCategorySample], !categorySamples.isEmpty else {
|
|
372
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let asleepValues: Set<Int> = {
|
|
377
|
+
if #available(iOS 16.0, *) {
|
|
378
|
+
return Set([
|
|
379
|
+
HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue,
|
|
380
|
+
HKCategoryValueSleepAnalysis.asleepCore.rawValue,
|
|
381
|
+
HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
|
|
382
|
+
HKCategoryValueSleepAnalysis.asleepREM.rawValue
|
|
383
|
+
])
|
|
384
|
+
} else {
|
|
385
|
+
return Set([HKCategoryValueSleepAnalysis.asleep.rawValue])
|
|
386
|
+
}
|
|
387
|
+
}()
|
|
388
|
+
|
|
389
|
+
let remValue: Int? = {
|
|
390
|
+
if #available(iOS 16.0, *) {
|
|
391
|
+
return HKCategoryValueSleepAnalysis.asleepREM.rawValue
|
|
392
|
+
}
|
|
393
|
+
return nil
|
|
394
|
+
}()
|
|
395
|
+
|
|
396
|
+
func isAsleep(_ value: Int) -> Bool {
|
|
397
|
+
if asleepValues.contains(value) {
|
|
398
|
+
return true
|
|
399
|
+
}
|
|
400
|
+
return value != HKCategoryValueSleepAnalysis.inBed.rawValue &&
|
|
401
|
+
value != HKCategoryValueSleepAnalysis.awake.rawValue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let asleepSamples = categorySamples
|
|
405
|
+
.filter { isAsleep($0.value) }
|
|
406
|
+
.sorted { $0.startDate < $1.startDate }
|
|
407
|
+
|
|
408
|
+
guard !asleepSamples.isEmpty else {
|
|
409
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let maxGap: TimeInterval = 90 * 60 // 90 minutes separates sessions
|
|
414
|
+
var sessions: [(start: Date, end: Date, duration: TimeInterval, remDuration: TimeInterval)] = []
|
|
415
|
+
var currentStart: Date?
|
|
416
|
+
var currentEnd: Date?
|
|
417
|
+
var currentDuration: TimeInterval = 0
|
|
418
|
+
var currentRemDuration: TimeInterval = 0
|
|
419
|
+
|
|
420
|
+
for sample in asleepSamples {
|
|
421
|
+
if let lastEnd = currentEnd, sample.startDate.timeIntervalSince(lastEnd) > maxGap {
|
|
422
|
+
sessions.append((start: currentStart ?? lastEnd, end: lastEnd, duration: currentDuration, remDuration: currentRemDuration))
|
|
423
|
+
currentStart = nil
|
|
424
|
+
currentEnd = nil
|
|
425
|
+
currentDuration = 0
|
|
426
|
+
currentRemDuration = 0
|
|
427
|
+
}
|
|
428
|
+
if currentStart == nil { currentStart = sample.startDate }
|
|
429
|
+
currentEnd = sample.endDate
|
|
430
|
+
let sampleDuration = sample.endDate.timeIntervalSince(sample.startDate)
|
|
431
|
+
currentDuration += sampleDuration
|
|
432
|
+
if let remValue = remValue, sample.value == remValue {
|
|
433
|
+
currentRemDuration += sampleDuration
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if let start = currentStart, let end = currentEnd {
|
|
437
|
+
sessions.append((start: start, end: end, duration: currentDuration, remDuration: currentRemDuration))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
guard !sessions.isEmpty else {
|
|
441
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let minSessionDuration: TimeInterval = 3 * 3600 // prefer sessions 3h+
|
|
446
|
+
let preferredSession = sessions.reversed().first { $0.duration >= minSessionDuration } ?? sessions.last!
|
|
447
|
+
if preferredSession.remDuration <= 0 {
|
|
448
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
call.resolve([
|
|
453
|
+
"value": preferredSession.remDuration / 60,
|
|
454
|
+
"timestamp": preferredSession.start.timeIntervalSince1970 * 1000,
|
|
455
|
+
"endTimestamp": preferredSession.end.timeIntervalSince1970 * 1000,
|
|
456
|
+
"unit": "min"
|
|
457
|
+
])
|
|
458
|
+
}
|
|
459
|
+
healthStore.execute(query)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
352
462
|
// ---- Special handling for mindfulness sessions (category samples) ----
|
|
353
463
|
if dataTypeString == "mindfulness" {
|
|
354
464
|
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
|
@@ -527,16 +637,29 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
527
637
|
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
528
638
|
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) // iOS 16+
|
|
529
639
|
].compactMap { $0 }
|
|
640
|
+
case "WRITE_TOTAL_CALORIES":
|
|
641
|
+
return [
|
|
642
|
+
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
643
|
+
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
644
|
+
].compactMap { $0 }
|
|
530
645
|
case "READ_ACTIVE_CALORIES":
|
|
531
646
|
return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
|
|
647
|
+
case "WRITE_ACTIVE_CALORIES":
|
|
648
|
+
return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
|
|
532
649
|
case "READ_WORKOUTS":
|
|
533
650
|
return [HKObjectType.workoutType()].compactMap{$0}
|
|
651
|
+
case "WRITE_WORKOUTS":
|
|
652
|
+
return [HKObjectType.workoutType()].compactMap{$0}
|
|
534
653
|
case "READ_HEART_RATE":
|
|
535
654
|
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
655
|
+
case "WRITE_HEART_RATE":
|
|
656
|
+
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
536
657
|
case "READ_RESTING_HEART_RATE":
|
|
537
658
|
return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
|
|
538
659
|
case "READ_ROUTE":
|
|
539
660
|
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
661
|
+
case "WRITE_ROUTE":
|
|
662
|
+
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
540
663
|
case "READ_DISTANCE":
|
|
541
664
|
return [
|
|
542
665
|
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
@@ -544,6 +667,13 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
544
667
|
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
545
668
|
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
546
669
|
].compactMap{$0}
|
|
670
|
+
case "WRITE_DISTANCE":
|
|
671
|
+
return [
|
|
672
|
+
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
673
|
+
HKObjectType.quantityType(forIdentifier: .distanceSwimming),
|
|
674
|
+
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
675
|
+
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
676
|
+
].compactMap{$0}
|
|
547
677
|
case "READ_MINDFULNESS":
|
|
548
678
|
return [HKObjectType.categoryType(forIdentifier: .mindfulSession)!].compactMap{$0}
|
|
549
679
|
case "READ_HRV":
|
|
@@ -647,6 +777,34 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
647
777
|
}
|
|
648
778
|
}
|
|
649
779
|
|
|
780
|
+
func permissionToHKSampleType(_ permission: String) -> [HKSampleType] {
|
|
781
|
+
switch permission {
|
|
782
|
+
case "WRITE_WORKOUTS":
|
|
783
|
+
return [HKObjectType.workoutType()].compactMap { $0 as? HKSampleType }
|
|
784
|
+
case "WRITE_ACTIVE_CALORIES":
|
|
785
|
+
fallthrough
|
|
786
|
+
case "WRITE_TOTAL_CALORIES":
|
|
787
|
+
return [
|
|
788
|
+
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
789
|
+
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
790
|
+
].compactMap { $0 as? HKSampleType }
|
|
791
|
+
case "WRITE_DISTANCE":
|
|
792
|
+
return [
|
|
793
|
+
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
794
|
+
HKObjectType.quantityType(forIdentifier: .distanceSwimming),
|
|
795
|
+
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
796
|
+
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
797
|
+
].compactMap { $0 as? HKSampleType }
|
|
798
|
+
case "WRITE_HEART_RATE":
|
|
799
|
+
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap { $0 as? HKSampleType }
|
|
800
|
+
case "WRITE_ROUTE":
|
|
801
|
+
return [HKSeriesType.workoutRoute()].compactMap { $0 as? HKSampleType }
|
|
802
|
+
default:
|
|
803
|
+
// For convenience, allow read permissions that are sample types to be added to the share set when requested.
|
|
804
|
+
return permissionToHKObjectType(permission).compactMap { $0 as? HKSampleType }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
650
808
|
private func mapBiologicalSex(_ biologicalSex: HKBiologicalSex) -> String {
|
|
651
809
|
switch biologicalSex {
|
|
652
810
|
case .female:
|
|
@@ -1291,7 +1449,223 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1291
1449
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
1292
1450
|
return f
|
|
1293
1451
|
}()
|
|
1452
|
+
|
|
1453
|
+
private func parseDate(_ value: Any?) -> Date? {
|
|
1454
|
+
if let date = value as? Date {
|
|
1455
|
+
return date
|
|
1456
|
+
}
|
|
1457
|
+
if let str = value as? String {
|
|
1458
|
+
if let parsed = isoDateFormatter.date(from: str) {
|
|
1459
|
+
return parsed
|
|
1460
|
+
}
|
|
1461
|
+
if let millis = Double(str) {
|
|
1462
|
+
return Date(timeIntervalSince1970: millis / 1000)
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if let number = value as? NSNumber {
|
|
1466
|
+
return Date(timeIntervalSince1970: number.doubleValue / 1000)
|
|
1467
|
+
}
|
|
1468
|
+
return nil
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private func workoutActivityType(from value: String) -> HKWorkoutActivityType? {
|
|
1472
|
+
let normalized = value.lowercased()
|
|
1473
|
+
let mapping: [String: UInt] = [
|
|
1474
|
+
"rock-climbing": 9,
|
|
1475
|
+
"climbing": 9,
|
|
1476
|
+
"hiking": 24,
|
|
1477
|
+
"running": 37,
|
|
1478
|
+
"walking": 52,
|
|
1479
|
+
"cycling": 13,
|
|
1480
|
+
"biking": 13,
|
|
1481
|
+
"strength-training": 50,
|
|
1482
|
+
"strength_training": 50,
|
|
1483
|
+
"traditional-strength-training": 50,
|
|
1484
|
+
"yoga": 57,
|
|
1485
|
+
"other": 3000
|
|
1486
|
+
]
|
|
1487
|
+
|
|
1488
|
+
if let raw = mapping[normalized] {
|
|
1489
|
+
return HKWorkoutActivityType(rawValue: raw)
|
|
1490
|
+
}
|
|
1491
|
+
return HKWorkoutActivityType.other
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private func sanitizeMetadata(_ metadata: JSObject?) -> [String: Any]? {
|
|
1495
|
+
guard let metadata = metadata else { return nil }
|
|
1496
|
+
var cleaned: [String: Any] = [:]
|
|
1497
|
+
for (key, value) in metadata {
|
|
1498
|
+
switch value {
|
|
1499
|
+
case let number as NSNumber:
|
|
1500
|
+
cleaned[key] = number
|
|
1501
|
+
case let string as NSString:
|
|
1502
|
+
cleaned[key] = string
|
|
1503
|
+
case let date as Date:
|
|
1504
|
+
cleaned[key] = date
|
|
1505
|
+
case let string as String:
|
|
1506
|
+
cleaned[key] = string
|
|
1507
|
+
default:
|
|
1508
|
+
continue
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return cleaned.isEmpty ? nil : cleaned
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private func buildRouteLocations(from value: Any?, defaultDate: Date) -> [CLLocation] {
|
|
1515
|
+
guard let routeItems = value as? [Any] else { return [] }
|
|
1516
|
+
var locations: [CLLocation] = []
|
|
1517
|
+
|
|
1518
|
+
for case let point as JSObject in routeItems {
|
|
1519
|
+
guard let lat = point["lat"] as? CLLocationDegrees,
|
|
1520
|
+
let lng = point["lng"] as? CLLocationDegrees else { continue }
|
|
1521
|
+
let alt = (point["alt"] as? NSNumber)?.doubleValue ?? 0
|
|
1522
|
+
let timestamp = parseDate(point["timestamp"]) ?? defaultDate
|
|
1523
|
+
let location = CLLocation(
|
|
1524
|
+
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng),
|
|
1525
|
+
altitude: alt,
|
|
1526
|
+
horizontalAccuracy: kCLLocationAccuracyBest,
|
|
1527
|
+
verticalAccuracy: kCLLocationAccuracyBest,
|
|
1528
|
+
timestamp: timestamp
|
|
1529
|
+
)
|
|
1530
|
+
locations.append(location)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return locations.sorted { $0.timestamp < $1.timestamp }
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private func buildHeartRateSamples(from value: Any?, defaultStart: Date, defaultEnd: Date) -> [HKQuantitySample] {
|
|
1537
|
+
guard let arr = value as? [Any],
|
|
1538
|
+
let type = HKObjectType.quantityType(forIdentifier: .heartRate) else { return [] }
|
|
1539
|
+
|
|
1540
|
+
let unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
1541
|
+
var samples: [HKQuantitySample] = []
|
|
1542
|
+
for case let point as JSObject in arr {
|
|
1543
|
+
guard let bpm = point["bpm"] as? NSNumber else { continue }
|
|
1544
|
+
let timestamp = parseDate(point["timestamp"]) ?? defaultStart
|
|
1545
|
+
let quantity = HKQuantity(unit: unit, doubleValue: bpm.doubleValue)
|
|
1546
|
+
let sample = HKQuantitySample(type: type, quantity: quantity, start: timestamp, end: timestamp)
|
|
1547
|
+
samples.append(sample)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Ensure samples fall within the workout window to avoid HK errors.
|
|
1551
|
+
return samples
|
|
1552
|
+
.filter { $0.startDate >= defaultStart && $0.endDate <= defaultEnd }
|
|
1553
|
+
.sorted { $0.startDate < $1.startDate }
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1294
1556
|
|
|
1557
|
+
@objc func saveWorkout(_ call: CAPPluginCall) {
|
|
1558
|
+
guard HKHealthStore.isHealthDataAvailable() else {
|
|
1559
|
+
call.reject("Health data is unavailable on this device.")
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
guard let activityTypeString = call.getString("activityType"),
|
|
1564
|
+
let startDateString = call.getString("startDate"),
|
|
1565
|
+
let endDateString = call.getString("endDate"),
|
|
1566
|
+
let startDate = parseDate(startDateString),
|
|
1567
|
+
let endDate = parseDate(endDateString) else {
|
|
1568
|
+
call.reject("Missing or invalid parameters")
|
|
1569
|
+
return
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
guard let activityType = workoutActivityType(from: activityTypeString) else {
|
|
1573
|
+
call.reject("Unsupported activityType: \(activityTypeString)")
|
|
1574
|
+
return
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
guard startDate < endDate else {
|
|
1578
|
+
call.reject("startDate must be before endDate")
|
|
1579
|
+
return
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
let energyQuantity: HKQuantity? = {
|
|
1583
|
+
if let calories = call.getDouble("calories") {
|
|
1584
|
+
return HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories)
|
|
1585
|
+
}
|
|
1586
|
+
return nil
|
|
1587
|
+
}()
|
|
1588
|
+
let distanceQuantity: HKQuantity? = {
|
|
1589
|
+
if let distance = call.getDouble("distance") {
|
|
1590
|
+
return HKQuantity(unit: HKUnit.meter(), doubleValue: distance)
|
|
1591
|
+
}
|
|
1592
|
+
return nil
|
|
1593
|
+
}()
|
|
1594
|
+
|
|
1595
|
+
let workout = HKWorkout(
|
|
1596
|
+
activityType: activityType,
|
|
1597
|
+
start: startDate,
|
|
1598
|
+
end: endDate,
|
|
1599
|
+
workoutEvents: nil,
|
|
1600
|
+
totalEnergyBurned: energyQuantity,
|
|
1601
|
+
totalDistance: distanceQuantity,
|
|
1602
|
+
metadata: sanitizeMetadata(call.getObject("metadata"))
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
let routeLocations = buildRouteLocations(from: call.getArray("route"), defaultDate: startDate)
|
|
1606
|
+
let heartRateSamples = buildHeartRateSamples(from: call.getArray("heartRateSamples"), defaultStart: startDate, defaultEnd: endDate)
|
|
1607
|
+
|
|
1608
|
+
healthStore.save(workout) { success, error in
|
|
1609
|
+
DispatchQueue.main.async {
|
|
1610
|
+
if let error = error {
|
|
1611
|
+
call.reject("Failed to save workout: \(error.localizedDescription)")
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
guard success else {
|
|
1615
|
+
call.reject("Failed to save workout")
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if routeLocations.isEmpty && heartRateSamples.isEmpty {
|
|
1620
|
+
call.resolve(["success": true, "id": workout.uuid.uuidString])
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
let group = DispatchGroup()
|
|
1625
|
+
var saveError: String?
|
|
1626
|
+
|
|
1627
|
+
if !routeLocations.isEmpty {
|
|
1628
|
+
group.enter()
|
|
1629
|
+
let routeBuilder = HKWorkoutRouteBuilder(healthStore: self.healthStore, device: nil)
|
|
1630
|
+
routeBuilder.insertRouteData(routeLocations) { inserted, insertError in
|
|
1631
|
+
if let insertError = insertError {
|
|
1632
|
+
saveError = "Failed to insert route: \(insertError.localizedDescription)"
|
|
1633
|
+
group.leave()
|
|
1634
|
+
return
|
|
1635
|
+
}
|
|
1636
|
+
routeBuilder.finishRoute(with: workout, metadata: nil) { _, finishError in
|
|
1637
|
+
if let finishError = finishError {
|
|
1638
|
+
saveError = "Failed to finish route: \(finishError.localizedDescription)"
|
|
1639
|
+
} else if !inserted {
|
|
1640
|
+
saveError = "Failed to insert route"
|
|
1641
|
+
}
|
|
1642
|
+
group.leave()
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if !heartRateSamples.isEmpty {
|
|
1648
|
+
group.enter()
|
|
1649
|
+
self.healthStore.add(heartRateSamples, to: workout) { hrSuccess, hrError in
|
|
1650
|
+
if let hrError = hrError {
|
|
1651
|
+
saveError = "Failed to save heart rate samples: \(hrError.localizedDescription)"
|
|
1652
|
+
} else if !hrSuccess {
|
|
1653
|
+
saveError = "Failed to save heart rate samples"
|
|
1654
|
+
}
|
|
1655
|
+
group.leave()
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
group.notify(queue: .main) {
|
|
1660
|
+
if let saveError = saveError {
|
|
1661
|
+
call.reject(saveError)
|
|
1662
|
+
} else {
|
|
1663
|
+
call.resolve(["success": true, "id": workout.uuid.uuidString])
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1295
1669
|
|
|
1296
1670
|
@objc func queryWorkouts(_ call: CAPPluginCall) {
|
|
1297
1671
|
guard let startDateString = call.getString("startDate"),
|
package/package.json
CHANGED
|
File without changes
|