@flomentumsolutions/capacitor-health-extended 0.4.3 → 0.4.5

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/Package.swift CHANGED
@@ -19,7 +19,9 @@ let package = Package(
19
19
  .target(
20
20
  name: "HealthPluginPlugin",
21
21
  dependencies: [
22
- .product(name: "Capacitor", package: "capacitor-swift-pm")
22
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
23
+ // Capacitor binary depends on Cordova; add it so Xcode/SPM can find the module.
24
+ .product(name: "Cordova", package: "capacitor-swift-pm")
23
25
  ],
24
26
  path: "ios/Sources/HealthPluginPlugin"
25
27
  )
package/README.md CHANGED
@@ -334,7 +334,7 @@ Query aggregated data
334
334
  **Returns:** <code>Promise&lt;<a href="#queryaggregatedresponse">QueryAggregatedResponse</a>&gt;</code>
335
335
 
336
336
  - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.
337
- - On iOS `total-calories` is derived as active + basal energy (Android uses Health Connect's total calories when available).
337
+ - `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.
338
338
  - Weight/height aggregation returns the latest sample per day (no averaging).
339
339
  - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.
340
340
  - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).
@@ -374,6 +374,8 @@ Query latest sample for a specific data type
374
374
 
375
375
  **Returns:** <code>Promise&lt;<a href="#querylatestsampleresponse">QueryLatestSampleResponse</a>&gt;</code>
376
376
 
377
+ - 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.
378
+
377
379
  --------------------
378
380
 
379
381
 
@@ -804,7 +804,72 @@ class HealthPlugin : Plugin() {
804
804
  }
805
805
  }
806
806
 
807
+ private suspend fun sumActiveAndBasalCalories(
808
+ timeRange: TimeRangeFilter,
809
+ includeTotalMetricFallback: Boolean = false
810
+ ): Double? {
811
+ val metrics = mutableSetOf<AggregateMetric<*>>()
812
+ val includeActive = hasPermission(CapHealthPermission.READ_ACTIVE_CALORIES)
813
+ val includeBasal = hasPermission(CapHealthPermission.READ_BASAL_CALORIES)
814
+ val includeTotal = includeTotalMetricFallback && hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)
815
+
816
+ if (includeActive) metrics.add(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
817
+ if (includeBasal) metrics.add(BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL)
818
+ if (includeTotal) metrics.add(TotalCaloriesBurnedRecord.ENERGY_TOTAL)
819
+
820
+ if (metrics.isEmpty()) {
821
+ return null
822
+ }
823
+
824
+ val aggregation = healthConnectClient.aggregate(
825
+ AggregateRequest(
826
+ metrics = metrics,
827
+ timeRangeFilter = timeRange,
828
+ dataOriginFilter = emptySet()
829
+ )
830
+ )
831
+
832
+ val active = if (includeActive) {
833
+ aggregation[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
834
+ } else null
835
+ val basal = if (includeBasal) {
836
+ aggregation[BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL]?.inKilocalories
837
+ } else null
838
+ val total = if (includeTotal) {
839
+ aggregation[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
840
+ } else null
841
+
842
+ return when {
843
+ active != null || basal != null -> (active ?: 0.0) + (basal ?: 0.0)
844
+ includeTotal -> total
845
+ else -> null
846
+ }
847
+ }
848
+
807
849
  private suspend fun readLatestTotalCalories(): JSObject {
850
+ val zone = ZoneId.systemDefault()
851
+ val now = LocalDateTime.now(zone)
852
+
853
+ val derivedTotal = try {
854
+ sumActiveAndBasalCalories(
855
+ TimeRangeFilter.between(
856
+ now.toLocalDate().atStartOfDay(),
857
+ now
858
+ )
859
+ )
860
+ } catch (e: Exception) {
861
+ Log.w(tag, "readLatestTotalCalories: Failed to derive active+basal calories, falling back to total metric", e)
862
+ null
863
+ }
864
+
865
+ if (derivedTotal != null) {
866
+ return JSObject().apply {
867
+ put("value", derivedTotal)
868
+ put("timestamp", now.atZone(zone).toInstant().toEpochMilli())
869
+ put("unit", "kcal")
870
+ }
871
+ }
872
+
808
873
  if (!hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)) {
809
874
  throw Exception("Permission for total calories not granted")
810
875
  }
@@ -952,13 +1017,17 @@ class HealthPlugin : Plugin() {
952
1017
  }
953
1018
 
954
1019
  else -> {
955
- val metricAndMapper = getMetricAndMapper(dataType)
956
- val r = queryAggregatedMetric(
957
- metricAndMapper,
958
- timeRange,
959
- period
960
- )
961
- r.forEach { aggregatedList.put(it.toJs()) }
1020
+ val aggregated = if (dataType == "total-calories") {
1021
+ aggregateTotalCalories(timeRange, period)
1022
+ } else {
1023
+ val metricAndMapper = getMetricAndMapper(dataType)
1024
+ queryAggregatedMetric(
1025
+ metricAndMapper,
1026
+ timeRange,
1027
+ period
1028
+ )
1029
+ }
1030
+ aggregated.forEach { aggregatedList.put(it.toJs()) }
962
1031
  }
963
1032
  }
964
1033
 
@@ -1068,6 +1137,51 @@ class HealthPlugin : Plugin() {
1068
1137
 
1069
1138
  }
1070
1139
 
1140
+ private suspend fun aggregateTotalCalories(
1141
+ timeRange: TimeRangeFilter,
1142
+ period: Period
1143
+ ): List<AggregatedSample> {
1144
+ val includeActive = hasPermission(CapHealthPermission.READ_ACTIVE_CALORIES)
1145
+ val includeBasal = hasPermission(CapHealthPermission.READ_BASAL_CALORIES)
1146
+ val includeTotal = hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)
1147
+
1148
+ val metrics = mutableSetOf<AggregateMetric<*>>()
1149
+ if (includeActive) metrics.add(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
1150
+ if (includeBasal) metrics.add(BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL)
1151
+ if (includeTotal) metrics.add(TotalCaloriesBurnedRecord.ENERGY_TOTAL)
1152
+
1153
+ if (metrics.isEmpty()) {
1154
+ return emptyList()
1155
+ }
1156
+
1157
+ val response: List<AggregationResultGroupedByPeriod> = healthConnectClient.aggregateGroupByPeriod(
1158
+ AggregateGroupByPeriodRequest(
1159
+ metrics = metrics,
1160
+ timeRangeFilter = timeRange,
1161
+ timeRangeSlicer = period
1162
+ )
1163
+ )
1164
+
1165
+ return response.map {
1166
+ val active = if (includeActive) {
1167
+ it.result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
1168
+ } else null
1169
+ val basal = if (includeBasal) {
1170
+ it.result[BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL]?.inKilocalories
1171
+ } else null
1172
+ val total = if (includeTotal) {
1173
+ it.result[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
1174
+ } else null
1175
+
1176
+ val value = when {
1177
+ active != null || basal != null -> (active ?: 0.0) + (basal ?: 0.0)
1178
+ else -> total ?: 0.0
1179
+ }
1180
+
1181
+ AggregatedSample(it.startTime, it.endTime, value)
1182
+ }
1183
+ }
1184
+
1071
1185
  private suspend fun aggregateHrvByPeriod(
1072
1186
  timeRange: TimeRangeFilter,
1073
1187
  period: Period
@@ -1409,9 +1523,12 @@ class HealthPlugin : Plugin() {
1409
1523
  addWorkoutMetric(workout, workoutObject, getMetricAndMapper("steps"))
1410
1524
  }
1411
1525
 
1412
- val readTotalCaloriesResult = addWorkoutMetric(workout, workoutObject, getMetricAndMapper("total-calories"))
1413
- if(!readTotalCaloriesResult) {
1414
- addWorkoutMetric(workout, workoutObject, getMetricAndMapper("active-calories"))
1526
+ val derivedTotalAdded = addWorkoutTotalCalories(workout, workoutObject)
1527
+ if (!derivedTotalAdded) {
1528
+ val readTotalCaloriesResult = addWorkoutMetric(workout, workoutObject, getMetricAndMapper("total-calories"))
1529
+ if(!readTotalCaloriesResult) {
1530
+ addWorkoutMetric(workout, workoutObject, getMetricAndMapper("active-calories"))
1531
+ }
1415
1532
  }
1416
1533
 
1417
1534
  addWorkoutMetric(workout, workoutObject, getMetricAndMapper("distance"))
@@ -1451,6 +1568,27 @@ class HealthPlugin : Plugin() {
1451
1568
  }
1452
1569
  }
1453
1570
 
1571
+ private suspend fun addWorkoutTotalCalories(
1572
+ workout: ExerciseSessionRecord,
1573
+ jsWorkout: JSObject
1574
+ ): Boolean {
1575
+ return try {
1576
+ val totalCalories = sumActiveAndBasalCalories(
1577
+ TimeRangeFilter.between(workout.startTime, workout.endTime),
1578
+ includeTotalMetricFallback = true
1579
+ )
1580
+ if (totalCalories != null) {
1581
+ jsWorkout.put("calories", totalCalories)
1582
+ true
1583
+ } else {
1584
+ false
1585
+ }
1586
+ } catch (e: Exception) {
1587
+ Log.e(tag, "addWorkoutTotalCalories: Failed to derive calories", e)
1588
+ false
1589
+ }
1590
+ }
1591
+
1454
1592
  private suspend fun addWorkoutMetric(
1455
1593
  workout: ExerciseSessionRecord,
1456
1594
  jsWorkout: JSObject,
@@ -161,26 +161,87 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
161
161
  call.reject("Sleep type not available")
162
162
  return
163
163
  }
164
-
165
- let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
166
- let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
167
-
168
- let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
169
- guard let sleepSample = samples?.first as? HKCategorySample else {
170
- if let error = error {
171
- call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
164
+
165
+ let endDate = Date()
166
+ let startDate = Calendar.current.date(byAdding: .hour, value: -36, to: endDate) ?? endDate.addingTimeInterval(-36 * 3600)
167
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictEndDate)
168
+
169
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
170
+ if let error = error {
171
+ call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
172
+ return
173
+ }
174
+ guard let categorySamples = samples as? [HKCategorySample], !categorySamples.isEmpty else {
175
+ call.reject("No sleep sample found", "NO_SAMPLE")
176
+ return
177
+ }
178
+
179
+ let asleepValues: Set<Int> = {
180
+ if #available(iOS 16.0, *) {
181
+ return Set([
182
+ HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue,
183
+ HKCategoryValueSleepAnalysis.asleepCore.rawValue,
184
+ HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
185
+ HKCategoryValueSleepAnalysis.asleepREM.rawValue
186
+ ])
172
187
  } else {
173
- call.reject("No sleep sample found", "NO_SAMPLE")
188
+ // Pre-iOS 16 only exposes the legacy "asleep" value.
189
+ return Set([HKCategoryValueSleepAnalysis.asleep.rawValue])
190
+ }
191
+ }()
192
+
193
+ func isAsleep(_ value: Int) -> Bool {
194
+ if asleepValues.contains(value) {
195
+ return true
174
196
  }
197
+ // Fallback: treat any non in-bed/awake as asleep
198
+ return value != HKCategoryValueSleepAnalysis.inBed.rawValue &&
199
+ value != HKCategoryValueSleepAnalysis.awake.rawValue
200
+ }
201
+
202
+ let asleepSamples = categorySamples
203
+ .filter { isAsleep($0.value) }
204
+ .sorted { $0.startDate < $1.startDate }
205
+
206
+ guard !asleepSamples.isEmpty else {
207
+ call.reject("No sleep sample found", "NO_SAMPLE")
175
208
  return
176
209
  }
177
- let durationMinutes = sleepSample.endDate.timeIntervalSince(sleepSample.startDate) / 60
210
+
211
+ let maxGap: TimeInterval = 90 * 60 // 90 minutes separates sessions
212
+ var sessions: [(start: Date, end: Date, duration: TimeInterval)] = []
213
+ var currentStart: Date?
214
+ var currentEnd: Date?
215
+ var currentDuration: TimeInterval = 0
216
+
217
+ for sample in asleepSamples {
218
+ if let lastEnd = currentEnd, sample.startDate.timeIntervalSince(lastEnd) > maxGap {
219
+ sessions.append((start: currentStart ?? lastEnd, end: lastEnd, duration: currentDuration))
220
+ currentStart = nil
221
+ currentEnd = nil
222
+ currentDuration = 0
223
+ }
224
+ if currentStart == nil { currentStart = sample.startDate }
225
+ currentEnd = sample.endDate
226
+ currentDuration += sample.endDate.timeIntervalSince(sample.startDate)
227
+ }
228
+ if let start = currentStart, let end = currentEnd {
229
+ sessions.append((start: start, end: end, duration: currentDuration))
230
+ }
231
+
232
+ guard !sessions.isEmpty else {
233
+ call.reject("No sleep sample found", "NO_SAMPLE")
234
+ return
235
+ }
236
+
237
+ let minSessionDuration: TimeInterval = 3 * 3600 // prefer sessions 3h+
238
+ let preferredSession = sessions.reversed().first { $0.duration >= minSessionDuration } ?? sessions.last!
239
+
178
240
  call.resolve([
179
- "value": durationMinutes,
180
- "timestamp": sleepSample.startDate.timeIntervalSince1970 * 1000,
181
- "endTimestamp": sleepSample.endDate.timeIntervalSince1970 * 1000,
182
- "unit": "min",
183
- "metadata": ["state": sleepSample.value]
241
+ "value": preferredSession.duration / 60,
242
+ "timestamp": preferredSession.start.timeIntervalSince1970 * 1000,
243
+ "endTimestamp": preferredSession.end.timeIntervalSince1970 * 1000,
244
+ "unit": "min"
184
245
  ])
185
246
  }
186
247
  healthStore.execute(query)
@@ -538,7 +599,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
538
599
  if bucket == "day" {
539
600
  startDate = calendar.startOfDay(for: rawStartDate)
540
601
  let endDayStart = calendar.startOfDay(for: rawEndDate)
541
- endDate = calendar.date(byAdding: .day, to: endDayStart) ?? endDayStart
602
+ endDate = calendar.date(byAdding: .day, value: 1, to: endDayStart) ?? endDayStart
542
603
  }
543
604
  if dataTypeString == "mindfulness" {
544
605
  self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) { result, error in
@@ -725,8 +786,9 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
725
786
  let calendar = Calendar.current
726
787
  if let categorySamples = samples as? [HKCategorySample], error == nil {
727
788
  for sample in categorySamples {
728
- // Ignore in-bed samples; we care about actual sleep
789
+ // Ignore in-bed and awake samples; we care about actual sleep
729
790
  if sample.value == HKCategoryValueSleepAnalysis.inBed.rawValue { continue }
791
+ if sample.value == HKCategoryValueSleepAnalysis.awake.rawValue { continue }
730
792
  let startOfDay = calendar.startOfDay(for: sample.startDate)
731
793
  let duration = sample.endDate.timeIntervalSince(sample.startDate)
732
794
  dailyDurations[startOfDay, default: 0] += duration
@@ -821,52 +883,55 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
821
883
  healthStore.execute(query)
822
884
  }
823
885
 
886
+ private func sumEnergy(
887
+ _ identifier: HKQuantityTypeIdentifier,
888
+ startDate: Date,
889
+ endDate: Date,
890
+ completion: @escaping (Double?, Error?) -> Void
891
+ ) {
892
+ guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
893
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "Quantity type unavailable"]))
894
+ return
895
+ }
896
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
897
+ let query = HKStatisticsQuery(
898
+ quantityType: type,
899
+ quantitySamplePredicate: predicate,
900
+ options: .cumulativeSum
901
+ ) { _, result, error in
902
+ if let error = error {
903
+ completion(nil, error)
904
+ return
905
+ }
906
+ let value = result?.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie())
907
+ completion(value, nil)
908
+ }
909
+ healthStore.execute(query)
910
+ }
911
+
824
912
  private func queryLatestTotalCalories(_ call: CAPPluginCall) {
825
913
  let unit = HKUnit.kilocalorie()
826
914
  let group = DispatchGroup()
915
+ let now = Date()
916
+ let startOfDay = Calendar.current.startOfDay(for: now)
827
917
 
828
- var activeValue: Double?
829
- var basalValue: Double?
830
- var activeDate: Date?
831
- var basalDate: Date?
918
+ var activeTotal: Double?
919
+ var basalTotal: Double?
832
920
  var queryError: Error?
833
921
  let basalSupported = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) != nil
834
922
 
835
- func fetchLatest(_ identifier: HKQuantityTypeIdentifier, completion: @escaping (Double?, Date?, Error?) -> Void) {
836
- guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
837
- completion(nil, nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "Quantity type unavailable"]))
838
- return
839
- }
840
- let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
841
- let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
842
- let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sort]) { _, samples, error in
843
- if let error = error {
844
- completion(nil, nil, error)
845
- return
846
- }
847
- guard let sample = samples?.first as? HKQuantitySample else {
848
- completion(nil, nil, nil)
849
- return
850
- }
851
- completion(sample.quantity.doubleValue(for: unit), sample.startDate, nil)
852
- }
853
- healthStore.execute(query)
854
- }
855
-
856
923
  group.enter()
857
- fetchLatest(.activeEnergyBurned) { value, date, error in
858
- activeValue = value
859
- activeDate = date
860
- queryError = queryError ?? error
924
+ sumEnergy(.activeEnergyBurned, startDate: startOfDay, endDate: now) { value, error in
925
+ activeTotal = value
926
+ if queryError == nil { queryError = error }
861
927
  group.leave()
862
928
  }
863
929
 
864
930
  if basalSupported {
865
931
  group.enter()
866
- fetchLatest(.basalEnergyBurned) { value, date, error in
867
- basalValue = value
868
- basalDate = date
869
- queryError = queryError ?? error
932
+ sumEnergy(.basalEnergyBurned, startDate: startOfDay, endDate: now) { value, error in
933
+ basalTotal = value
934
+ if queryError == nil { queryError = error }
870
935
  group.leave()
871
936
  }
872
937
  }
@@ -876,15 +941,14 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
876
941
  call.reject("Error fetching total calories: \(error.localizedDescription)")
877
942
  return
878
943
  }
879
- guard activeValue != nil || basalValue != nil else {
944
+ guard activeTotal != nil || basalTotal != nil else {
880
945
  call.reject("No sample found", "NO_SAMPLE")
881
946
  return
882
947
  }
883
- let total = (activeValue ?? 0) + (basalValue ?? 0)
884
- let timestamp = max(activeDate?.timeIntervalSince1970 ?? 0, basalDate?.timeIntervalSince1970 ?? 0) * 1000
948
+ let total = (activeTotal ?? 0) + (basalTotal ?? 0)
885
949
  call.resolve([
886
950
  "value": total,
887
- "timestamp": timestamp,
951
+ "timestamp": now.timeIntervalSince1970 * 1000,
888
952
  "unit": unit.unitString
889
953
  ])
890
954
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Capacitor plugin for Apple HealthKit and Google Health Connect Platform",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",