@flomentumsolutions/capacitor-health-extended 0.0.8 → 0.0.10

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