@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 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 | 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
- | `workouts` | N/A | Workout sessions (read-only, use with `queryWorkouts()`) |
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&lt;<a href="#queryaggregatedresult">QueryAggregatedResult</a>&gt;</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)