@capgo/capacitor-health 8.2.7 → 8.2.9
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 +164 -20
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthDataType.kt +11 -1
- package/android/src/main/java/app/capgo/plugin/health/HealthManager.kt +225 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthPlugin.kt +49 -0
- package/dist/docs.json +257 -0
- package/dist/esm/definitions.d.ts +44 -2
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.js +3 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +3 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +3 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/HealthPlugin/Health.swift +289 -12
- package/ios/Sources/HealthPlugin/HealthPlugin.swift +32 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ npx cap sync
|
|
|
60
60
|
This plugin now uses [Health Connect](https://developer.android.com/health-and-fitness/guides/health-connect) instead of Google Fit. Make sure your app meets the requirements below:
|
|
61
61
|
|
|
62
62
|
1. **Min SDK 26+.** Health Connect is only available on Android 8.0 (API 26) and above. The plugin's Gradle setup already targets this level.
|
|
63
|
-
2. **Declare Health permissions.** The plugin manifest ships with the required `<uses-permission>` declarations (`READ_/WRITE_STEPS`, `READ_/WRITE_DISTANCE`, `READ_/WRITE_ACTIVE_CALORIES_BURNED`, `READ_/WRITE_HEART_RATE`, `READ_/WRITE_WEIGHT`). Your app does not need to duplicate them, but you must surface a user-facing rationale because the permissions are considered health sensitive.
|
|
63
|
+
2. **Declare Health permissions.** The plugin manifest ships with the required `<uses-permission>` declarations for basic data types (`READ_/WRITE_STEPS`, `READ_/WRITE_DISTANCE`, `READ_/WRITE_ACTIVE_CALORIES_BURNED`, `READ_/WRITE_HEART_RATE`, `READ_/WRITE_WEIGHT`, `READ_/WRITE_SLEEP`, `READ_/WRITE_RESPIRATORY_RATE`, `READ_/WRITE_OXYGEN_SATURATION`, `READ_/WRITE_RESTING_HEART_RATE`, `READ_/WRITE_HEART_RATE_VARIABILITY`). Your app does not need to duplicate them, but you must surface a user-facing rationale because the permissions are considered health sensitive.
|
|
64
64
|
3. **Ensure Health Connect is installed.** Devices on Android 14+ include it by default. For earlier versions the user must install _Health Connect by Android_ from the Play Store. The `Health.isAvailable()` helper exposes the current status so you can prompt accordingly.
|
|
65
65
|
4. **Request runtime access.** The plugin opens the Health Connect permission UI when you call `requestAuthorization`. You should still handle denial flows (e.g., show a message if `checkAuthorization` reports missing scopes).
|
|
66
66
|
5. **Provide a Privacy Policy.** Health Connect requires apps to display a privacy policy explaining how health data is used. See the [Privacy Policy Setup](#privacy-policy-setup) section below.
|
|
@@ -151,14 +151,19 @@ await Health.saveSample({
|
|
|
151
151
|
|
|
152
152
|
### Supported data types
|
|
153
153
|
|
|
154
|
-
| Identifier
|
|
155
|
-
|
|
|
156
|
-
| `steps`
|
|
157
|
-
| `distance`
|
|
158
|
-
| `calories`
|
|
159
|
-
| `heartRate`
|
|
160
|
-
| `weight`
|
|
161
|
-
| `
|
|
154
|
+
| Identifier | Default unit | Notes |
|
|
155
|
+
| ----------------------- | ------------- | -------------------------------------------------------- |
|
|
156
|
+
| `steps` | `count` | Step count deltas |
|
|
157
|
+
| `distance` | `meter` | Walking / running distance |
|
|
158
|
+
| `calories` | `kilocalorie` | Active energy burned |
|
|
159
|
+
| `heartRate` | `bpm` | Beats per minute |
|
|
160
|
+
| `weight` | `kilogram` | Body mass |
|
|
161
|
+
| `sleep` | `minute` | Sleep sessions with duration and states |
|
|
162
|
+
| `respiratoryRate` | `bpm` | Breaths per minute |
|
|
163
|
+
| `oxygenSaturation` | `percent` | Blood oxygen saturation (SpO2) |
|
|
164
|
+
| `restingHeartRate` | `bpm` | Resting heart rate |
|
|
165
|
+
| `heartRateVariability` | `millisecond` | Heart rate variability (HRV) |
|
|
166
|
+
| `workouts` | N/A | Workout sessions (read-only, use with `queryWorkouts()`) |
|
|
162
167
|
|
|
163
168
|
All write operations expect the default unit shown above. On Android the `metadata` option is currently ignored by Health Connect.
|
|
164
169
|
|
|
@@ -196,6 +201,79 @@ while (result.anchor) {
|
|
|
196
201
|
}
|
|
197
202
|
```
|
|
198
203
|
|
|
204
|
+
### New Data Types Examples
|
|
205
|
+
|
|
206
|
+
**Sleep data:**
|
|
207
|
+
```ts
|
|
208
|
+
// Request permission for sleep data
|
|
209
|
+
await Health.requestAuthorization({
|
|
210
|
+
read: ['sleep'],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Read sleep sessions from the past week
|
|
214
|
+
const { samples } = await Health.readSamples({
|
|
215
|
+
dataType: 'sleep',
|
|
216
|
+
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
217
|
+
endDate: new Date().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
samples.forEach(sample => {
|
|
221
|
+
console.log(`Sleep: ${sample.value} minutes, state: ${sample.sleepState}`);
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Respiratory rate, oxygen saturation, and HRV:**
|
|
226
|
+
```ts
|
|
227
|
+
// Request permission
|
|
228
|
+
await Health.requestAuthorization({
|
|
229
|
+
read: ['respiratoryRate', 'oxygenSaturation', 'restingHeartRate', 'heartRateVariability'],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Read respiratory rate
|
|
233
|
+
const { samples: respiratoryRate } = await Health.readSamples({
|
|
234
|
+
dataType: 'respiratoryRate',
|
|
235
|
+
startDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
|
236
|
+
endDate: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Read oxygen saturation (SpO2)
|
|
240
|
+
const { samples: oxygenSat } = await Health.readSamples({
|
|
241
|
+
dataType: 'oxygenSaturation',
|
|
242
|
+
startDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
|
243
|
+
endDate: new Date().toISOString(),
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Aggregated Queries
|
|
248
|
+
|
|
249
|
+
For large date ranges, use `queryAggregated()` to get aggregated data efficiently instead of fetching individual samples:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// Get daily step totals for the past month
|
|
253
|
+
const { samples } = await Health.queryAggregated({
|
|
254
|
+
dataType: 'steps',
|
|
255
|
+
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
256
|
+
endDate: new Date().toISOString(),
|
|
257
|
+
bucket: 'day', // Options: 'hour', 'day', 'week', 'month'
|
|
258
|
+
aggregation: 'sum', // Options: 'sum', 'average', 'min', 'max'
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
samples.forEach(sample => {
|
|
262
|
+
console.log(`${sample.startDate}: ${sample.value} ${sample.unit}`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Get average heart rate by day
|
|
266
|
+
const { samples: avgHR } = await Health.queryAggregated({
|
|
267
|
+
dataType: 'heartRate',
|
|
268
|
+
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
269
|
+
endDate: new Date().toISOString(),
|
|
270
|
+
bucket: 'day',
|
|
271
|
+
aggregation: 'average',
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Note:** Aggregated queries are not supported for sleep, respiratory rate, oxygen saturation, and heart rate variability data types. These are instantaneous measurements and should use `readSamples()` instead. Aggregation is supported for: steps, distance, calories, heart rate, weight, and resting heart rate.
|
|
276
|
+
|
|
199
277
|
## API
|
|
200
278
|
|
|
201
279
|
<docgen-index>
|
|
@@ -209,6 +287,7 @@ while (result.anchor) {
|
|
|
209
287
|
* [`openHealthConnectSettings()`](#openhealthconnectsettings)
|
|
210
288
|
* [`showPrivacyPolicy()`](#showprivacypolicy)
|
|
211
289
|
* [`queryWorkouts(...)`](#queryworkouts)
|
|
290
|
+
* [`queryAggregated(...)`](#queryaggregated)
|
|
212
291
|
* [Interfaces](#interfaces)
|
|
213
292
|
* [Type Aliases](#type-aliases)
|
|
214
293
|
|
|
@@ -361,6 +440,27 @@ Supported on iOS (HealthKit) and Android (Health Connect).
|
|
|
361
440
|
--------------------
|
|
362
441
|
|
|
363
442
|
|
|
443
|
+
### queryAggregated(...)
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
queryAggregated(options: QueryAggregatedOptions) => Promise<QueryAggregatedResult>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Queries aggregated health data from the native health store.
|
|
450
|
+
Aggregates data into time buckets (hour, day, week, month) with operations like sum, average, min, or max.
|
|
451
|
+
This is more efficient than fetching individual samples for large date ranges.
|
|
452
|
+
|
|
453
|
+
Supported on iOS (HealthKit) and Android (Health Connect).
|
|
454
|
+
|
|
455
|
+
| Param | Type | Description |
|
|
456
|
+
| ------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
457
|
+
| **`options`** | <code><a href="#queryaggregatedoptions">QueryAggregatedOptions</a></code> | Query options including data type, date range, bucket size, and aggregation type |
|
|
458
|
+
|
|
459
|
+
**Returns:** <code>Promise<<a href="#queryaggregatedresult">QueryAggregatedResult</a>></code>
|
|
460
|
+
|
|
461
|
+
--------------------
|
|
462
|
+
|
|
463
|
+
|
|
364
464
|
### Interfaces
|
|
365
465
|
|
|
366
466
|
|
|
@@ -400,15 +500,16 @@ Supported on iOS (HealthKit) and Android (Health Connect).
|
|
|
400
500
|
|
|
401
501
|
#### HealthSample
|
|
402
502
|
|
|
403
|
-
| Prop | Type |
|
|
404
|
-
| ---------------- | --------------------------------------------------------- |
|
|
405
|
-
| **`dataType`** | <code><a href="#healthdatatype">HealthDataType</a></code> |
|
|
406
|
-
| **`value`** | <code>number</code> |
|
|
407
|
-
| **`unit`** | <code><a href="#healthunit">HealthUnit</a></code> |
|
|
408
|
-
| **`startDate`** | <code>string</code> |
|
|
409
|
-
| **`endDate`** | <code>string</code> |
|
|
410
|
-
| **`sourceName`** | <code>string</code> |
|
|
411
|
-
| **`sourceId`** | <code>string</code> |
|
|
503
|
+
| Prop | Type | Description |
|
|
504
|
+
| ---------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
505
|
+
| **`dataType`** | <code><a href="#healthdatatype">HealthDataType</a></code> | |
|
|
506
|
+
| **`value`** | <code>number</code> | |
|
|
507
|
+
| **`unit`** | <code><a href="#healthunit">HealthUnit</a></code> | |
|
|
508
|
+
| **`startDate`** | <code>string</code> | |
|
|
509
|
+
| **`endDate`** | <code>string</code> | |
|
|
510
|
+
| **`sourceName`** | <code>string</code> | |
|
|
511
|
+
| **`sourceId`** | <code>string</code> | |
|
|
512
|
+
| **`sleepState`** | <code><a href="#sleepstate">SleepState</a></code> | For sleep data, indicates the sleep state (e.g., 'asleep', 'awake', 'rem', 'deep', 'light'). |
|
|
412
513
|
|
|
413
514
|
|
|
414
515
|
#### QueryOptions
|
|
@@ -469,17 +570,50 @@ Supported on iOS (HealthKit) and Android (Health Connect).
|
|
|
469
570
|
| **`anchor`** | <code>string</code> | Anchor for pagination. Use the anchor returned from a previous query to continue from that point. On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken. Omit this parameter to start from the beginning. |
|
|
470
571
|
|
|
471
572
|
|
|
573
|
+
#### QueryAggregatedResult
|
|
574
|
+
|
|
575
|
+
| Prop | Type |
|
|
576
|
+
| ------------- | ------------------------------- |
|
|
577
|
+
| **`samples`** | <code>AggregatedSample[]</code> |
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
#### AggregatedSample
|
|
581
|
+
|
|
582
|
+
| Prop | Type | Description |
|
|
583
|
+
| --------------- | ------------------------------------------------- | ---------------------------------- |
|
|
584
|
+
| **`startDate`** | <code>string</code> | ISO 8601 start date of the bucket. |
|
|
585
|
+
| **`endDate`** | <code>string</code> | ISO 8601 end date of the bucket. |
|
|
586
|
+
| **`value`** | <code>number</code> | Aggregated value for the bucket. |
|
|
587
|
+
| **`unit`** | <code><a href="#healthunit">HealthUnit</a></code> | Unit of the aggregated value. |
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
#### QueryAggregatedOptions
|
|
591
|
+
|
|
592
|
+
| Prop | Type | Description |
|
|
593
|
+
| ----------------- | ----------------------------------------------------------- | -------------------------------------------------------- |
|
|
594
|
+
| **`dataType`** | <code><a href="#healthdatatype">HealthDataType</a></code> | The type of data to aggregate from the health store. |
|
|
595
|
+
| **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day). |
|
|
596
|
+
| **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now). |
|
|
597
|
+
| **`bucket`** | <code><a href="#buckettype">BucketType</a></code> | Time bucket for aggregation (defaults to 'day'). |
|
|
598
|
+
| **`aggregation`** | <code><a href="#aggregationtype">AggregationType</a></code> | Aggregation operation to perform (defaults to 'sum'). |
|
|
599
|
+
|
|
600
|
+
|
|
472
601
|
### Type Aliases
|
|
473
602
|
|
|
474
603
|
|
|
475
604
|
#### HealthDataType
|
|
476
605
|
|
|
477
|
-
<code>'steps' | 'distance' | 'calories' | 'heartRate' | 'weight'</code>
|
|
606
|
+
<code>'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'respiratoryRate' | 'oxygenSaturation' | 'restingHeartRate' | 'heartRateVariability'</code>
|
|
478
607
|
|
|
479
608
|
|
|
480
609
|
#### HealthUnit
|
|
481
610
|
|
|
482
|
-
<code>'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram'</code>
|
|
611
|
+
<code>'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'percent' | 'millisecond'</code>
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
#### SleepState
|
|
615
|
+
|
|
616
|
+
<code>'inBed' | 'asleep' | 'awake' | 'rem' | 'deep' | 'light'</code>
|
|
483
617
|
|
|
484
618
|
|
|
485
619
|
#### Record
|
|
@@ -493,6 +627,16 @@ Construct a type with a set of properties K of type T
|
|
|
493
627
|
|
|
494
628
|
<code>'running' | 'cycling' | 'walking' | 'swimming' | 'yoga' | 'strengthTraining' | 'hiking' | 'tennis' | 'basketball' | 'soccer' | 'americanFootball' | 'baseball' | 'crossTraining' | 'elliptical' | 'rowing' | 'stairClimbing' | 'traditionalStrengthTraining' | 'waterFitness' | 'waterPolo' | 'waterSports' | 'wrestling' | 'other'</code>
|
|
495
629
|
|
|
630
|
+
|
|
631
|
+
#### BucketType
|
|
632
|
+
|
|
633
|
+
<code>'hour' | 'day' | 'week' | 'month'</code>
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
#### AggregationType
|
|
637
|
+
|
|
638
|
+
<code>'sum' | 'average' | 'min' | 'max'</code>
|
|
639
|
+
|
|
496
640
|
</docgen-api>
|
|
497
641
|
|
|
498
642
|
### Credits:
|
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
|
|
10
10
|
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
|
|
11
11
|
<uses-permission android:name="android.permission.health.WRITE_WEIGHT" />
|
|
12
|
+
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
|
13
|
+
<uses-permission android:name="android.permission.health.WRITE_SLEEP" />
|
|
14
|
+
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
|
|
15
|
+
<uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE" />
|
|
16
|
+
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
|
|
17
|
+
<uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION" />
|
|
18
|
+
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
|
|
19
|
+
<uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE" />
|
|
20
|
+
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
|
|
21
|
+
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE_VARIABILITY" />
|
|
22
|
+
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
|
12
23
|
|
|
13
24
|
<!-- Query for Health Connect availability -->
|
|
14
25
|
<queries>
|
|
@@ -4,7 +4,12 @@ import androidx.health.connect.client.permission.HealthPermission
|
|
|
4
4
|
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
5
5
|
import androidx.health.connect.client.records.DistanceRecord
|
|
6
6
|
import androidx.health.connect.client.records.HeartRateRecord
|
|
7
|
+
import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
|
|
8
|
+
import androidx.health.connect.client.records.OxygenSaturationRecord
|
|
7
9
|
import androidx.health.connect.client.records.Record
|
|
10
|
+
import androidx.health.connect.client.records.RespiratoryRateRecord
|
|
11
|
+
import androidx.health.connect.client.records.RestingHeartRateRecord
|
|
12
|
+
import androidx.health.connect.client.records.SleepSessionRecord
|
|
8
13
|
import androidx.health.connect.client.records.StepsRecord
|
|
9
14
|
import androidx.health.connect.client.records.WeightRecord
|
|
10
15
|
import kotlin.reflect.KClass
|
|
@@ -18,7 +23,12 @@ enum class HealthDataType(
|
|
|
18
23
|
DISTANCE("distance", DistanceRecord::class, "meter"),
|
|
19
24
|
CALORIES("calories", ActiveCaloriesBurnedRecord::class, "kilocalorie"),
|
|
20
25
|
HEART_RATE("heartRate", HeartRateRecord::class, "bpm"),
|
|
21
|
-
WEIGHT("weight", WeightRecord::class, "kilogram")
|
|
26
|
+
WEIGHT("weight", WeightRecord::class, "kilogram"),
|
|
27
|
+
SLEEP("sleep", SleepSessionRecord::class, "minute"),
|
|
28
|
+
RESPIRATORY_RATE("respiratoryRate", RespiratoryRateRecord::class, "bpm"),
|
|
29
|
+
OXYGEN_SATURATION("oxygenSaturation", OxygenSaturationRecord::class, "percent"),
|
|
30
|
+
RESTING_HEART_RATE("restingHeartRate", RestingHeartRateRecord::class, "bpm"),
|
|
31
|
+
HEART_RATE_VARIABILITY("heartRateVariability", HeartRateVariabilityRmssdRecord::class, "millisecond");
|
|
22
32
|
|
|
23
33
|
val readPermission: String
|
|
24
34
|
get() = HealthPermission.getReadPermission(recordClass)
|
|
@@ -7,7 +7,12 @@ import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
|
7
7
|
import androidx.health.connect.client.records.DistanceRecord
|
|
8
8
|
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
9
9
|
import androidx.health.connect.client.records.HeartRateRecord
|
|
10
|
+
import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
|
|
11
|
+
import androidx.health.connect.client.records.OxygenSaturationRecord
|
|
10
12
|
import androidx.health.connect.client.records.Record
|
|
13
|
+
import androidx.health.connect.client.records.RespiratoryRateRecord
|
|
14
|
+
import androidx.health.connect.client.records.RestingHeartRateRecord
|
|
15
|
+
import androidx.health.connect.client.records.SleepSessionRecord
|
|
11
16
|
import androidx.health.connect.client.records.StepsRecord
|
|
12
17
|
import androidx.health.connect.client.records.WeightRecord
|
|
13
18
|
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
@@ -15,6 +20,8 @@ import androidx.health.connect.client.time.TimeRangeFilter
|
|
|
15
20
|
import androidx.health.connect.client.units.Energy
|
|
16
21
|
import androidx.health.connect.client.units.Length
|
|
17
22
|
import androidx.health.connect.client.units.Mass
|
|
23
|
+
import androidx.health.connect.client.units.Percentage
|
|
24
|
+
import androidx.health.connect.client.units.Power
|
|
18
25
|
import androidx.health.connect.client.records.metadata.Metadata
|
|
19
26
|
import java.time.Duration
|
|
20
27
|
import com.getcapacitor.JSArray
|
|
@@ -148,6 +155,62 @@ class HealthManager {
|
|
|
148
155
|
samples.add(sample.time to payload)
|
|
149
156
|
}
|
|
150
157
|
}
|
|
158
|
+
HealthDataType.SLEEP -> readRecords(client, SleepSessionRecord::class, startTime, endTime, limit) { record ->
|
|
159
|
+
// For sleep sessions, calculate duration in minutes
|
|
160
|
+
val durationMinutes = Duration.between(record.startTime, record.endTime).toMinutes().toDouble()
|
|
161
|
+
val payload = createSamplePayload(
|
|
162
|
+
dataType,
|
|
163
|
+
record.startTime,
|
|
164
|
+
record.endTime,
|
|
165
|
+
durationMinutes,
|
|
166
|
+
record.metadata
|
|
167
|
+
)
|
|
168
|
+
// Add sleep stage if available (map from sleep session stages)
|
|
169
|
+
// Note: SleepSessionRecord doesn't have individual stages in the main record
|
|
170
|
+
// Individual sleep stages would be in SleepStageRecord, but for simplicity
|
|
171
|
+
// we'll just return the session duration
|
|
172
|
+
samples.add(record.startTime to payload)
|
|
173
|
+
}
|
|
174
|
+
HealthDataType.RESPIRATORY_RATE -> readRecords(client, RespiratoryRateRecord::class, startTime, endTime, limit) { record ->
|
|
175
|
+
val payload = createSamplePayload(
|
|
176
|
+
dataType,
|
|
177
|
+
record.time,
|
|
178
|
+
record.time,
|
|
179
|
+
record.rate,
|
|
180
|
+
record.metadata
|
|
181
|
+
)
|
|
182
|
+
samples.add(record.time to payload)
|
|
183
|
+
}
|
|
184
|
+
HealthDataType.OXYGEN_SATURATION -> readRecords(client, OxygenSaturationRecord::class, startTime, endTime, limit) { record ->
|
|
185
|
+
val payload = createSamplePayload(
|
|
186
|
+
dataType,
|
|
187
|
+
record.time,
|
|
188
|
+
record.time,
|
|
189
|
+
record.percentage.value,
|
|
190
|
+
record.metadata
|
|
191
|
+
)
|
|
192
|
+
samples.add(record.time to payload)
|
|
193
|
+
}
|
|
194
|
+
HealthDataType.RESTING_HEART_RATE -> readRecords(client, RestingHeartRateRecord::class, startTime, endTime, limit) { record ->
|
|
195
|
+
val payload = createSamplePayload(
|
|
196
|
+
dataType,
|
|
197
|
+
record.time,
|
|
198
|
+
record.time,
|
|
199
|
+
record.beatsPerMinute.toDouble(),
|
|
200
|
+
record.metadata
|
|
201
|
+
)
|
|
202
|
+
samples.add(record.time to payload)
|
|
203
|
+
}
|
|
204
|
+
HealthDataType.HEART_RATE_VARIABILITY -> readRecords(client, HeartRateVariabilityRmssdRecord::class, startTime, endTime, limit) { record ->
|
|
205
|
+
val payload = createSamplePayload(
|
|
206
|
+
dataType,
|
|
207
|
+
record.time,
|
|
208
|
+
record.time,
|
|
209
|
+
record.heartRateVariabilityMillis,
|
|
210
|
+
record.metadata
|
|
211
|
+
)
|
|
212
|
+
samples.add(record.time to payload)
|
|
213
|
+
}
|
|
151
214
|
}
|
|
152
215
|
|
|
153
216
|
val sorted = samples.sortedBy { it.first }
|
|
@@ -246,6 +309,47 @@ class HealthManager {
|
|
|
246
309
|
)
|
|
247
310
|
client.insertRecords(listOf(record))
|
|
248
311
|
}
|
|
312
|
+
HealthDataType.SLEEP -> {
|
|
313
|
+
val record = SleepSessionRecord(
|
|
314
|
+
startTime = startTime,
|
|
315
|
+
startZoneOffset = zoneOffset(startTime),
|
|
316
|
+
endTime = endTime,
|
|
317
|
+
endZoneOffset = zoneOffset(endTime)
|
|
318
|
+
)
|
|
319
|
+
client.insertRecords(listOf(record))
|
|
320
|
+
}
|
|
321
|
+
HealthDataType.RESPIRATORY_RATE -> {
|
|
322
|
+
val record = RespiratoryRateRecord(
|
|
323
|
+
time = startTime,
|
|
324
|
+
zoneOffset = zoneOffset(startTime),
|
|
325
|
+
rate = value
|
|
326
|
+
)
|
|
327
|
+
client.insertRecords(listOf(record))
|
|
328
|
+
}
|
|
329
|
+
HealthDataType.OXYGEN_SATURATION -> {
|
|
330
|
+
val record = OxygenSaturationRecord(
|
|
331
|
+
time = startTime,
|
|
332
|
+
zoneOffset = zoneOffset(startTime),
|
|
333
|
+
percentage = Percentage(value)
|
|
334
|
+
)
|
|
335
|
+
client.insertRecords(listOf(record))
|
|
336
|
+
}
|
|
337
|
+
HealthDataType.RESTING_HEART_RATE -> {
|
|
338
|
+
val record = RestingHeartRateRecord(
|
|
339
|
+
time = startTime,
|
|
340
|
+
zoneOffset = zoneOffset(startTime),
|
|
341
|
+
beatsPerMinute = value.toBpmLong()
|
|
342
|
+
)
|
|
343
|
+
client.insertRecords(listOf(record))
|
|
344
|
+
}
|
|
345
|
+
HealthDataType.HEART_RATE_VARIABILITY -> {
|
|
346
|
+
val record = HeartRateVariabilityRmssdRecord(
|
|
347
|
+
time = startTime,
|
|
348
|
+
zoneOffset = zoneOffset(startTime),
|
|
349
|
+
heartRateVariabilityMillis = value
|
|
350
|
+
)
|
|
351
|
+
client.insertRecords(listOf(record))
|
|
352
|
+
}
|
|
249
353
|
}
|
|
250
354
|
}
|
|
251
355
|
|
|
@@ -293,6 +397,127 @@ class HealthManager {
|
|
|
293
397
|
return java.lang.Math.round(this.coerceAtLeast(0.0))
|
|
294
398
|
}
|
|
295
399
|
|
|
400
|
+
suspend fun queryAggregated(
|
|
401
|
+
client: HealthConnectClient,
|
|
402
|
+
dataType: HealthDataType,
|
|
403
|
+
startTime: Instant,
|
|
404
|
+
endTime: Instant,
|
|
405
|
+
bucket: String,
|
|
406
|
+
aggregation: String
|
|
407
|
+
): JSObject {
|
|
408
|
+
// Sleep aggregation is not directly supported like other metrics
|
|
409
|
+
if (dataType == HealthDataType.SLEEP) {
|
|
410
|
+
throw IllegalArgumentException("Aggregated queries are not supported for sleep data. Use readSamples instead.")
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Instantaneous measurement records don't support aggregation in Health Connect
|
|
414
|
+
// These data types should use readSamples instead
|
|
415
|
+
if (dataType == HealthDataType.RESPIRATORY_RATE ||
|
|
416
|
+
dataType == HealthDataType.OXYGEN_SATURATION ||
|
|
417
|
+
dataType == HealthDataType.HEART_RATE_VARIABILITY) {
|
|
418
|
+
throw IllegalArgumentException("Aggregated queries are not supported for ${dataType.identifier}. Use readSamples instead.")
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
val samples = JSArray()
|
|
422
|
+
|
|
423
|
+
// Determine bucket size
|
|
424
|
+
// Note: Monthly buckets use 30 days as an approximation, which may not align exactly
|
|
425
|
+
// with calendar months. This provides consistent bucket sizes but users should be aware
|
|
426
|
+
// that "month" buckets don't correspond to actual calendar months (Jan, Feb, etc.).
|
|
427
|
+
val bucketDuration = when (bucket) {
|
|
428
|
+
"hour" -> Duration.ofHours(1)
|
|
429
|
+
"day" -> Duration.ofDays(1)
|
|
430
|
+
"week" -> Duration.ofDays(7)
|
|
431
|
+
"month" -> Duration.ofDays(30) // Approximation: not calendar months
|
|
432
|
+
else -> Duration.ofDays(1)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Create time buckets
|
|
436
|
+
var currentStart = startTime
|
|
437
|
+
while (currentStart.isBefore(endTime)) {
|
|
438
|
+
val currentEnd = currentStart.plus(bucketDuration).let {
|
|
439
|
+
if (it.isAfter(endTime)) endTime else it
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
val metrics = when (dataType) {
|
|
444
|
+
HealthDataType.STEPS -> setOf(StepsRecord.COUNT_TOTAL)
|
|
445
|
+
HealthDataType.DISTANCE -> setOf(DistanceRecord.DISTANCE_TOTAL)
|
|
446
|
+
HealthDataType.CALORIES -> setOf(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
|
|
447
|
+
HealthDataType.HEART_RATE -> setOf(HeartRateRecord.BPM_AVG, HeartRateRecord.BPM_MAX, HeartRateRecord.BPM_MIN)
|
|
448
|
+
HealthDataType.WEIGHT -> setOf(WeightRecord.WEIGHT_AVG, WeightRecord.WEIGHT_MAX, WeightRecord.WEIGHT_MIN)
|
|
449
|
+
HealthDataType.RESTING_HEART_RATE -> setOf(RestingHeartRateRecord.BPM_AVG, RestingHeartRateRecord.BPM_MAX, RestingHeartRateRecord.BPM_MIN)
|
|
450
|
+
else -> throw IllegalArgumentException("Unsupported data type for aggregation: ${dataType.identifier}")
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
val aggregateRequest = AggregateRequest(
|
|
454
|
+
metrics = metrics,
|
|
455
|
+
timeRangeFilter = TimeRangeFilter.between(currentStart, currentEnd)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
val result = client.aggregate(aggregateRequest)
|
|
459
|
+
|
|
460
|
+
// Extract the appropriate aggregated value based on the aggregation type and data type
|
|
461
|
+
val value: Double? = when (dataType) {
|
|
462
|
+
HealthDataType.STEPS -> when (aggregation) {
|
|
463
|
+
"sum" -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
464
|
+
else -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
465
|
+
}
|
|
466
|
+
HealthDataType.DISTANCE -> when (aggregation) {
|
|
467
|
+
"sum" -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
468
|
+
else -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
469
|
+
}
|
|
470
|
+
HealthDataType.CALORIES -> when (aggregation) {
|
|
471
|
+
"sum" -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
472
|
+
else -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
473
|
+
}
|
|
474
|
+
HealthDataType.HEART_RATE -> when (aggregation) {
|
|
475
|
+
"average" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
476
|
+
"max" -> result[HeartRateRecord.BPM_MAX]?.toDouble()
|
|
477
|
+
"min" -> result[HeartRateRecord.BPM_MIN]?.toDouble()
|
|
478
|
+
else -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
479
|
+
}
|
|
480
|
+
HealthDataType.WEIGHT -> when (aggregation) {
|
|
481
|
+
"average" -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
|
|
482
|
+
"max" -> result[WeightRecord.WEIGHT_MAX]?.inKilograms
|
|
483
|
+
"min" -> result[WeightRecord.WEIGHT_MIN]?.inKilograms
|
|
484
|
+
else -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
|
|
485
|
+
}
|
|
486
|
+
HealthDataType.RESTING_HEART_RATE -> when (aggregation) {
|
|
487
|
+
"average" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
488
|
+
"max" -> result[RestingHeartRateRecord.BPM_MAX]?.toDouble()
|
|
489
|
+
"min" -> result[RestingHeartRateRecord.BPM_MIN]?.toDouble()
|
|
490
|
+
else -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
491
|
+
}
|
|
492
|
+
else -> null
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Only add the sample if we have a value
|
|
496
|
+
if (value != null) {
|
|
497
|
+
val sample = JSObject().apply {
|
|
498
|
+
put("startDate", formatter.format(currentStart))
|
|
499
|
+
put("endDate", formatter.format(currentEnd))
|
|
500
|
+
put("value", value)
|
|
501
|
+
put("unit", dataType.unit)
|
|
502
|
+
}
|
|
503
|
+
samples.put(sample)
|
|
504
|
+
}
|
|
505
|
+
} catch (e: CancellationException) {
|
|
506
|
+
throw e
|
|
507
|
+
} catch (e: SecurityException) {
|
|
508
|
+
android.util.Log.d("HealthManager", "Permission denied for aggregation: ${e.message}", e)
|
|
509
|
+
} catch (e: Exception) {
|
|
510
|
+
android.util.Log.d("HealthManager", "Aggregation failed for bucket: ${e.message}", e)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
currentStart = currentEnd
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return JSObject().apply {
|
|
517
|
+
put("samples", samples)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
296
521
|
suspend fun queryWorkouts(
|
|
297
522
|
client: HealthConnectClient,
|
|
298
523
|
workoutType: String?,
|
|
@@ -385,6 +385,55 @@ class HealthPlugin : Plugin() {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
@PluginMethod
|
|
389
|
+
fun queryAggregated(call: PluginCall) {
|
|
390
|
+
val identifier = call.getString("dataType")
|
|
391
|
+
if (identifier.isNullOrBlank()) {
|
|
392
|
+
call.reject("dataType is required")
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
val dataType = HealthDataType.from(identifier)
|
|
397
|
+
if (dataType == null) {
|
|
398
|
+
call.reject("Unsupported data type: $identifier")
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
val bucket = call.getString("bucket") ?: "day"
|
|
403
|
+
val aggregation = call.getString("aggregation") ?: "sum"
|
|
404
|
+
|
|
405
|
+
val startInstant = try {
|
|
406
|
+
manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
|
|
407
|
+
} catch (e: DateTimeParseException) {
|
|
408
|
+
call.reject(e.message, null, e)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
val endInstant = try {
|
|
413
|
+
manager.parseInstant(call.getString("endDate"), Instant.now())
|
|
414
|
+
} catch (e: DateTimeParseException) {
|
|
415
|
+
call.reject(e.message, null, e)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (endInstant.isBefore(startInstant)) {
|
|
420
|
+
call.reject("endDate must be greater than or equal to startDate")
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
pluginScope.launch {
|
|
425
|
+
val client = getClientOrReject(call) ?: return@launch
|
|
426
|
+
try {
|
|
427
|
+
val result = manager.queryAggregated(client, dataType, startInstant, endInstant, bucket, aggregation)
|
|
428
|
+
call.resolve(result)
|
|
429
|
+
} catch (e: IllegalArgumentException) {
|
|
430
|
+
call.reject(e.message ?: "Unsupported aggregation.", null, e)
|
|
431
|
+
} catch (e: Exception) {
|
|
432
|
+
call.reject(e.message ?: "Failed to query aggregated data.", null, e)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
388
437
|
companion object {
|
|
389
438
|
private const val DEFAULT_LIMIT = 100
|
|
390
439
|
private val DEFAULT_PAST_DURATION: Duration = Duration.ofDays(1)
|