@flomentumsolutions/capacitor-health-extended 0.0.7 → 0.0.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.
@@ -16,5 +16,5 @@ Pod::Spec.new do |s|
16
16
  s.dependency 'Capacitor', '~> 6.2'
17
17
  s.dependency 'CapacitorCordova', '~> 6.2'
18
18
  # Match the Swift shipped with Xcode 16 (use 5.9 for Xcode 15.x)
19
- s.swift_version = '6.0'
19
+ s.swift_version = '5.9'
20
20
  end
@@ -6,6 +6,7 @@ import HealthKit
6
6
  * Please read the Capacitor iOS Plugin Development Guide
7
7
  * here: https://capacitorjs.com/docs/plugins/ios
8
8
  */
9
+ @MainActor
9
10
  @objc(HealthPlugin)
10
11
  public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
11
12
  public let identifier = "HealthPlugin"
@@ -22,6 +23,9 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
22
23
 
23
24
  let healthStore = HKHealthStore()
24
25
 
26
+ /// Serial queue to make route‑location mutations thread‑safe without locks
27
+ private let routeSyncQueue = DispatchQueue(label: "com.flomentum.healthplugin.routeSync")
28
+
25
29
  @objc func isHealthAvailable(_ call: CAPPluginCall) {
26
30
  let isAvailable = HKHealthStore.isHealthDataAvailable()
27
31
  call.resolve(["available": isAvailable])
@@ -296,42 +300,56 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
296
300
  let bucket = call.getString("bucket"),
297
301
  let startDate = self.isoDateFormatter.date(from: startDateString),
298
302
  let endDate = self.isoDateFormatter.date(from: endDateString) else {
299
- call.reject("Invalid parameters")
303
+ DispatchQueue.main.async {
304
+ call.reject("Invalid parameters")
305
+ }
300
306
  return
301
307
  }
302
-
303
- if(dataTypeString == "mindfulness") {
304
- self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) {result, error in
305
- if let error = error {
306
- call.reject(error.localizedDescription)
307
- } else if let result = result {
308
- call.resolve(["aggregatedData": result])
308
+ if dataTypeString == "mindfulness" {
309
+ self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) { result, error in
310
+ DispatchQueue.main.async {
311
+ if let error = error {
312
+ call.reject(error.localizedDescription)
313
+ } else if let result = result {
314
+ call.resolve(["aggregatedData": result])
315
+ }
309
316
  }
310
317
  }
311
318
  } else {
312
319
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
313
-
314
320
  guard let interval = calculateInterval(bucket: bucket) else {
315
- call.reject("Invalid bucket")
321
+ DispatchQueue.main.async {
322
+ call.reject("Invalid bucket")
323
+ }
316
324
  return
317
325
  }
318
-
319
326
  guard let dataType = aggregateTypeToHKQuantityType(dataTypeString) else {
320
- call.reject("Invalid data type")
327
+ DispatchQueue.main.async {
328
+ call.reject("Invalid data type")
329
+ }
321
330
  return
322
331
  }
323
-
324
332
  let options: HKStatisticsOptions = {
325
333
  switch dataType.aggregationStyle {
326
334
  case .cumulative:
327
335
  return .cumulativeSum
336
+ case .discreteAverage:
337
+ return .discreteAverage
338
+ @available(iOS 17.0, *)
339
+ case .discreteTemporallyWeighted:
340
+ return .discreteAverage
341
+ @available(iOS 17.0, *)
342
+ case .discreteEquivalentContinuousLevel:
343
+ return .discreteAverage
344
+ @available(iOS 17.0, *)
345
+ case .discreteArithmetic:
346
+ return .discreteAverage
328
347
  case .discrete:
329
- return .discreteAverage // or .discreteMin / Max when needed
348
+ return .discreteAverage
330
349
  @unknown default:
331
- return .cumulativeSum
350
+ return .discreteAverage
332
351
  }
333
352
  }()
334
-
335
353
  let query = HKStatisticsCollectionQuery(
336
354
  quantityType: dataType,
337
355
  quantitySamplePredicate: predicate,
@@ -339,139 +357,107 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
339
357
  anchorDate: startDate,
340
358
  intervalComponents: interval
341
359
  )
342
-
343
- query.initialResultsHandler = { query, result, error in
344
- if let error = error {
345
- call.reject("Error fetching aggregated data: \(error.localizedDescription)")
346
- return
347
- }
348
-
349
- var aggregatedSamples: [[String: Any]] = []
350
-
351
- result?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
352
- // Choose sum or average based on the options we picked
353
- let quantity: HKQuantity? = options.contains(.cumulativeSum)
354
- ? statistics.sumQuantity()
355
- : statistics.averageQuantity()
356
-
357
- guard let quantity = quantity else { return }
358
-
359
- // Time‑bounds of this bucket
360
- let bucketStart = statistics.startDate.timeIntervalSince1970 * 1000
361
- let bucketEnd = statistics.endDate.timeIntervalSince1970 * 1000
362
-
363
- // Map dataType correct HKUnit
364
- let unit: HKUnit = {
365
- switch dataTypeString {
366
- case "steps":
367
- return .count()
368
- case "active-calories", "total-calories":
369
- return .kilocalorie()
370
- case "distance":
371
- return .meter()
372
- case "weight":
373
- return .gramUnit(with: .kilo)
374
- case "height":
375
- return .meter()
376
- case "heart-rate":
377
- return HKUnit.count().unitDivided(by: HKUnit.minute())
378
- case "hrv":
379
- return HKUnit.secondUnit(with: .milli)
380
- case "mindfulness":
381
- return HKUnit.second()
382
- default:
383
- return .count()
384
- }
385
- }()
386
-
387
- let value = quantity.doubleValue(for: unit)
388
-
389
- aggregatedSamples.append([
390
- "startDate": bucketStart,
391
- "endDate": bucketEnd,
392
- "value": value
393
- ])
360
+ query.initialResultsHandler = { _, result, error in
361
+ DispatchQueue.main.async {
362
+ if let error = error {
363
+ call.reject("Error fetching aggregated data: \(error.localizedDescription)")
364
+ return
365
+ }
366
+ var aggregatedSamples: [[String: Any]] = []
367
+ result?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
368
+ let quantity: HKQuantity? = options.contains(.cumulativeSum)
369
+ ? statistics.sumQuantity()
370
+ : statistics.averageQuantity()
371
+ guard let quantity = quantity else { return }
372
+ let bucketStart = statistics.startDate.timeIntervalSince1970 * 1000
373
+ let bucketEnd = statistics.endDate.timeIntervalSince1970 * 1000
374
+ let unit: HKUnit = {
375
+ switch dataTypeString {
376
+ case "steps": return .count()
377
+ case "active-calories", "total-calories": return .kilocalorie()
378
+ case "distance": return .meter()
379
+ case "weight": return .gramUnit(with: .kilo)
380
+ case "height": return .meter()
381
+ case "heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
382
+ case "hrv": return HKUnit.secondUnit(with: .milli)
383
+ case "mindfulness": return HKUnit.second()
384
+ default: return .count()
385
+ }
386
+ }()
387
+ let value = quantity.doubleValue(for: unit)
388
+ aggregatedSamples.append([
389
+ "startDate": bucketStart,
390
+ "endDate": bucketEnd,
391
+ "value": value
392
+ ])
393
+ }
394
+ call.resolve(["aggregatedData": aggregatedSamples])
394
395
  }
395
-
396
- call.resolve(["aggregatedData": aggregatedSamples])
397
396
  }
398
-
399
397
  healthStore.execute(query)
400
398
  }
401
399
  }
402
-
400
+
403
401
  func queryMindfulnessAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
404
402
  guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
405
- completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "MindfulSession type unavailable"]))
403
+ DispatchQueue.main.async {
404
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "MindfulSession type unavailable"]))
405
+ }
406
406
  return
407
407
  }
408
-
409
408
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
410
409
  let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
411
- guard let categorySamples = samples as? [HKCategorySample], error == nil else {
412
- completion(nil, error)
413
- return
414
- }
415
-
416
- // Aggregate total time per day
417
-
418
410
  var dailyDurations: [Date: TimeInterval] = [:]
419
411
  let calendar = Calendar.current
420
-
421
- for sample in categorySamples {
422
- let startOfDay = calendar.startOfDay(for: sample.startDate)
423
- let duration = sample.endDate.timeIntervalSince(sample.startDate)
424
-
425
- if let existingDuration = dailyDurations[startOfDay] {
426
- dailyDurations[startOfDay] = existingDuration + duration
427
- } else {
428
- dailyDurations[startOfDay] = duration
412
+ if let categorySamples = samples as? [HKCategorySample], error == nil {
413
+ for sample in categorySamples {
414
+ let startOfDay = calendar.startOfDay(for: sample.startDate)
415
+ let duration = sample.endDate.timeIntervalSince(sample.startDate)
416
+ dailyDurations[startOfDay, default: 0] += duration
417
+ }
418
+ var aggregatedSamples: [[String: Any]] = []
419
+ let dayComponent = DateComponents(day: 1)
420
+ for (date, duration) in dailyDurations {
421
+ aggregatedSamples.append([
422
+ "startDate": date,
423
+ "endDate": calendar.date(byAdding: dayComponent, to: date) as Any,
424
+ "value": duration
425
+ ])
426
+ }
427
+ DispatchQueue.main.async {
428
+ completion(aggregatedSamples, nil)
429
+ }
430
+ } else {
431
+ DispatchQueue.main.async {
432
+ completion(nil, error)
429
433
  }
430
434
  }
431
-
432
- var aggregatedSamples: [[String: Any]] = []
433
- var dayComponent = DateComponents()
434
- dayComponent.day = 1
435
- dailyDurations.forEach { (dateAndDuration) in
436
- aggregatedSamples.append([
437
- "startDate": dateAndDuration.key as Any,
438
- "endDate": calendar.date(byAdding: dayComponent, to: dateAndDuration.key) as Any,
439
- "value": dateAndDuration.value
440
- ])
441
- }
442
-
443
- completion(aggregatedSamples, nil)
444
435
  }
445
-
446
436
  healthStore.execute(query)
447
437
  }
448
-
449
-
450
-
451
- private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping(Double?) -> Void) {
452
-
453
-
438
+
439
+ private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping (Double?) -> Void) {
454
440
  guard let quantityType = dataType else {
455
- completion(nil)
441
+ DispatchQueue.main.async {
442
+ completion(nil)
443
+ }
456
444
  return
457
445
  }
458
-
459
446
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
460
-
461
447
  let query = HKStatisticsQuery(
462
448
  quantityType: quantityType,
463
449
  quantitySamplePredicate: predicate,
464
450
  options: .cumulativeSum
465
451
  ) { _, result, _ in
466
- guard let result = result, let sum = result.sumQuantity() else {
467
- completion(0.0)
468
- return
452
+ let value: Double? = {
453
+ guard let result = result, let sum = result.sumQuantity() else { return 0.0 }
454
+ return sum.doubleValue(for: HKUnit.count())
455
+ }()
456
+ DispatchQueue.main.async {
457
+ completion(value)
469
458
  }
470
- completion(sum.doubleValue(for: HKUnit.count()))
471
459
  }
472
-
473
460
  healthStore.execute(query)
474
-
475
461
  }
476
462
 
477
463
 
@@ -509,30 +495,28 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
509
495
  call.reject("Invalid parameters")
510
496
  return
511
497
  }
512
-
513
-
514
-
515
- // Create a predicate to filter workouts by date
498
+
516
499
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
517
-
518
- let workoutQuery = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, samples, error in
500
+ let workoutQuery = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] query, samples, error in
501
+ guard let self = self else { return }
519
502
  if let error = error {
520
- call.reject("Error querying workouts: \(error.localizedDescription)")
503
+ DispatchQueue.main.async {
504
+ call.reject("Error querying workouts: \(error.localizedDescription)")
505
+ }
521
506
  return
522
507
  }
523
-
524
508
  guard let workouts = samples as? [HKWorkout] else {
525
- call.resolve(["workouts": []])
509
+ DispatchQueue.main.async {
510
+ call.resolve(["workouts": []])
511
+ }
526
512
  return
527
513
  }
528
-
529
- var workoutList: [[String: Any]] = []
514
+ let outerGroup = DispatchGroup()
515
+ var workoutResults: [[String: Any]] = []
530
516
  var errors: [String: String] = [:]
531
- let dispatchGroup = DispatchGroup()
532
-
533
- // Process each workout
534
517
  for workout in workouts {
535
- var workoutDict: [String: Any] = [
518
+ outerGroup.enter()
519
+ var localDict: [String: Any] = [
536
520
  "startDate": workout.startDate,
537
521
  "endDate": workout.endDate,
538
522
  "workoutType": self.workoutTypeMapping[workout.workoutActivityType.rawValue, default: "other"],
@@ -543,66 +527,52 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
543
527
  "calories": workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0,
544
528
  "distance": workout.totalDistance?.doubleValue(for: .meter()) ?? 0
545
529
  ]
546
-
547
-
548
- var heartRateSamples: [[String: Any]] = []
549
- var routeSamples: [[String: Any]] = []
550
-
551
- // Query heart rate data if requested
530
+ let innerGroup = DispatchGroup()
531
+ var localHeartRates: [[String: Any]] = []
532
+ var localRoutes: [[String: Any]] = []
552
533
  if includeHeartRate {
553
- dispatchGroup.enter()
554
- self.queryHeartRate(for: workout, completion: { (heartRates, error) in
555
- if(error != nil) {
556
- errors["heart-rate"] = error
557
- }
558
- heartRateSamples = heartRates
559
- dispatchGroup.leave()
560
- })
534
+ innerGroup.enter()
535
+ self.queryHeartRate(for: workout) { rates, error in
536
+ localHeartRates = rates
537
+ if let error = error { errors["heart-rate"] = error }
538
+ innerGroup.leave()
539
+ }
561
540
  }
562
-
563
- // Query route data if requested
564
541
  if includeRoute {
565
- dispatchGroup.enter()
566
- self.queryRoute(for: workout, completion: { (routes, error) in
567
- if(error != nil) {
568
- errors["route"] = error
569
- }
570
- routeSamples = routes
571
- dispatchGroup.leave()
572
- })
542
+ innerGroup.enter()
543
+ self.queryRoute(for: workout) { routes, error in
544
+ localRoutes = routes
545
+ if let error = error { errors["route"] = error }
546
+ innerGroup.leave()
547
+ }
573
548
  }
574
-
575
549
  if includeSteps {
576
- dispatchGroup.enter()
577
- self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount), completion:{ (steps) in
578
- if(steps != nil) {
579
- workoutDict["steps"] = steps
550
+ innerGroup.enter()
551
+ self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount)) { steps in
552
+ if let steps = steps {
553
+ localDict["steps"] = steps
580
554
  }
581
- dispatchGroup.leave()
582
- })
555
+ innerGroup.leave()
556
+ }
583
557
  }
584
-
585
- dispatchGroup.notify(queue: .main) {
586
- workoutDict["heartRate"] = heartRateSamples
587
- workoutDict["route"] = routeSamples
588
- workoutList.append(workoutDict)
558
+ innerGroup.notify(queue: .main) {
559
+ localDict["heartRate"] = localHeartRates
560
+ localDict["route"] = localRoutes
561
+ workoutResults.append(localDict)
562
+ outerGroup.leave()
589
563
  }
590
-
591
-
592
564
  }
593
-
594
- dispatchGroup.notify(queue: .main) {
595
- call.resolve(["workouts": workoutList, "errors": errors])
565
+ outerGroup.notify(queue: .main) {
566
+ call.resolve(["workouts": workoutResults, "errors": errors])
596
567
  }
597
568
  }
598
-
599
569
  healthStore.execute(workoutQuery)
600
570
  }
601
571
 
602
572
 
603
573
 
604
574
  // MARK: - Query Heart Rate Data
605
- private func queryHeartRate(for workout: HKWorkout, completion: @escaping ([[String: Any]], String?) -> Void) {
575
+ private func queryHeartRate(for workout: HKWorkout, completion: @escaping @Sendable ([[String: Any]], String?) -> Void) {
606
576
  let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate)!
607
577
  let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
608
578
 
@@ -633,30 +603,29 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
633
603
  }
634
604
 
635
605
  // MARK: - Query Route Data
636
- private func queryRoute(for workout: HKWorkout, completion: @escaping ([[String: Any]], String?) -> Void) {
606
+ private func queryRoute(for workout: HKWorkout, completion: @escaping @Sendable ([[String: Any]], String?) -> Void) {
637
607
  let routeType = HKSeriesType.workoutRoute()
638
608
  let predicate = HKQuery.predicateForObjects(from: workout)
639
609
 
640
- let routeQuery = HKSampleQuery(sampleType: routeType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, samples, error in
641
- guard let routes = samples as? [HKWorkoutRoute], error == nil else {
642
- completion([], error?.localizedDescription)
643
- return
644
- }
645
-
646
- var routeLocations: [[String: Any]] = []
647
- let routeDispatchGroup = DispatchGroup()
648
-
649
- // Query locations for each route
650
- for route in routes {
651
- routeDispatchGroup.enter()
652
- self.queryLocations(for: route) { locations in
653
- routeLocations.append(contentsOf: locations)
654
- routeDispatchGroup.leave()
610
+ let routeQuery = HKSampleQuery(sampleType: routeType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
611
+ guard let self = self else { return }
612
+ if let routes = samples as? [HKWorkoutRoute], error == nil {
613
+ let routeDispatchGroup = DispatchGroup()
614
+ var allLocations: [[String: Any]] = []
615
+ for route in routes {
616
+ routeDispatchGroup.enter()
617
+ self.queryLocations(for: route) { locations in
618
+ allLocations.append(contentsOf: locations)
619
+ routeDispatchGroup.leave()
620
+ }
621
+ }
622
+ routeDispatchGroup.notify(queue: .main) {
623
+ completion(allLocations, nil)
624
+ }
625
+ } else {
626
+ DispatchQueue.main.async {
627
+ completion([], error?.localizedDescription)
655
628
  }
656
- }
657
-
658
- routeDispatchGroup.notify(queue: .main) {
659
- completion(routeLocations, nil)
660
629
  }
661
630
  }
662
631
 
@@ -664,30 +633,38 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
664
633
  }
665
634
 
666
635
  // MARK: - Query Route Locations
667
- private func queryLocations(for route: HKWorkoutRoute, completion: @escaping ([[String: Any]]) -> Void) {
636
+ private func queryLocations(for route: HKWorkoutRoute, completion: @escaping @Sendable ([[String: Any]]) -> Void) {
668
637
  var routeLocations: [[String: Any]] = []
669
-
670
- let locationQuery = HKWorkoutRouteQuery(route: route) { query, locations, done, error in
638
+
639
+ let locationQuery = HKWorkoutRouteQuery(route: route) { [weak self] _, locations, done, error in
640
+ guard let self = self else { return }
671
641
  guard let locations = locations, error == nil else {
672
- completion([])
642
+ DispatchQueue.main.async {
643
+ completion([])
644
+ }
673
645
  return
674
646
  }
675
-
676
- for location in locations {
677
- let locationDict: [String: Any] = [
678
- "timestamp": location.timestamp,
679
- "lat": location.coordinate.latitude,
680
- "lng": location.coordinate.longitude,
681
- "alt": location.altitude
682
- ]
683
- routeLocations.append(locationDict)
684
- }
685
-
686
- if done {
687
- completion(routeLocations)
647
+
648
+ // Append on a dedicated serial queue so we’re race‑free without NSLock
649
+ self.routeSyncQueue.async {
650
+ for location in locations {
651
+ let locationDict: [String: Any] = [
652
+ "timestamp": location.timestamp,
653
+ "lat": location.coordinate.latitude,
654
+ "lng": location.coordinate.longitude,
655
+ "alt": location.altitude
656
+ ]
657
+ routeLocations.append(locationDict)
658
+ }
659
+
660
+ if done {
661
+ DispatchQueue.main.async {
662
+ completion(routeLocations)
663
+ }
664
+ }
688
665
  }
689
666
  }
690
-
667
+
691
668
  healthStore.execute(locationQuery)
692
669
  }
693
670
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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",