@kingstinct/react-native-healthkit 14.0.2 → 15.1.0

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.
Files changed (71) hide show
  1. package/README.md +32 -2
  2. package/app.plugin.js +49 -94
  3. package/app.plugin.ts +6 -40
  4. package/ios/BackgroundDeliveryManager.swift +95 -7
  5. package/ios/BackgroundLaunchHook.mm +41 -0
  6. package/ios/Constants.swift +24 -0
  7. package/ios/CoreModule.swift +64 -18
  8. package/ios/ExceptionCatcher.h +1 -0
  9. package/ios/ExceptionCatcher.mm +15 -0
  10. package/ios/Helpers.swift +29 -1
  11. package/ios/MemoryEstimation.swift +83 -0
  12. package/ios/QuantityTypeModule.swift +34 -26
  13. package/ios/Serializers.swift +18 -3
  14. package/ios/SourceProxy.swift +14 -5
  15. package/ios/WorkoutProxy.swift +41 -12
  16. package/lib/commonjs/generated/healthkit.generated.js +13 -1
  17. package/lib/commonjs/healthkit.ios.js +3 -1
  18. package/lib/commonjs/healthkit.js +4 -2
  19. package/lib/module/generated/healthkit.generated.js +12 -0
  20. package/lib/module/healthkit.ios.js +2 -0
  21. package/lib/module/healthkit.js +2 -0
  22. package/lib/typescript/generated/healthkit.generated.d.ts +16 -3
  23. package/lib/typescript/healthkit.d.ts +1 -0
  24. package/lib/typescript/healthkit.ios.d.ts +2 -0
  25. package/lib/typescript/specs/CoreModule.nitro.d.ts +6 -4
  26. package/lib/typescript/specs/SourceProxy.nitro.d.ts +1 -2
  27. package/lib/typescript/types/QuantityType.d.ts +3 -3
  28. package/lib/typescript/types/Source.d.ts +2 -2
  29. package/lib/typescript/types/Workouts.d.ts +1 -0
  30. package/nitrogen/generated/ios/ReactNativeHealthkit-Swift-Cxx-Bridge.cpp +16 -16
  31. package/nitrogen/generated/ios/ReactNativeHealthkit-Swift-Cxx-Bridge.hpp +23 -12
  32. package/nitrogen/generated/ios/c++/HybridCategoryTypeModuleSpecSwift.hpp +7 -4
  33. package/nitrogen/generated/ios/c++/HybridCorrelationTypeModuleSpecSwift.hpp +7 -4
  34. package/nitrogen/generated/ios/c++/HybridElectrocardiogramModuleSpecSwift.hpp +7 -4
  35. package/nitrogen/generated/ios/c++/HybridHeartbeatSeriesModuleSpecSwift.hpp +7 -4
  36. package/nitrogen/generated/ios/c++/HybridMedicationModuleSpecSwift.hpp +7 -4
  37. package/nitrogen/generated/ios/c++/HybridQuantityTypeModuleSpecSwift.hpp +7 -4
  38. package/nitrogen/generated/ios/c++/HybridStateOfMindModuleSpecSwift.hpp +7 -4
  39. package/nitrogen/generated/ios/c++/HybridWorkoutProxySpecSwift.hpp +3 -4
  40. package/nitrogen/generated/ios/c++/HybridWorkoutsModuleSpecSwift.hpp +6 -3
  41. package/nitrogen/generated/ios/swift/CategoryTypeIdentifier.swift +8 -0
  42. package/nitrogen/generated/ios/swift/CategoryTypeIdentifierWriteable.swift +8 -0
  43. package/nitrogen/generated/ios/swift/ObjectTypeIdentifier.swift +8 -0
  44. package/nitrogen/generated/ios/swift/QueryStatisticsResponse.swift +6 -13
  45. package/nitrogen/generated/ios/swift/QueryStatisticsResponseFromSingleSource.swift +4 -11
  46. package/nitrogen/generated/ios/swift/SampleTypeIdentifier.swift +8 -0
  47. package/nitrogen/generated/ios/swift/SampleTypeIdentifierWriteable.swift +8 -0
  48. package/nitrogen/generated/ios/swift/SourceRevision.swift +4 -11
  49. package/nitrogen/generated/ios/swift/WorkoutActivity.swift +7 -2
  50. package/nitrogen/generated/ios/swift/WorkoutActivityType.swift +8 -0
  51. package/nitrogen/generated/shared/c++/CategoryTypeIdentifier.hpp +67 -59
  52. package/nitrogen/generated/shared/c++/CategoryTypeIdentifierWriteable.hpp +67 -59
  53. package/nitrogen/generated/shared/c++/ObjectTypeIdentifier.hpp +202 -194
  54. package/nitrogen/generated/shared/c++/QueryStatisticsResponse.hpp +8 -9
  55. package/nitrogen/generated/shared/c++/QueryStatisticsResponseFromSingleSource.hpp +8 -9
  56. package/nitrogen/generated/shared/c++/SampleTypeIdentifier.hpp +195 -187
  57. package/nitrogen/generated/shared/c++/SampleTypeIdentifierWriteable.hpp +189 -181
  58. package/nitrogen/generated/shared/c++/SourceRevision.hpp +8 -9
  59. package/nitrogen/generated/shared/c++/WorkoutActivity.hpp +9 -3
  60. package/nitrogen/generated/shared/c++/WorkoutActivityType.hpp +4 -0
  61. package/package.json +5 -3
  62. package/src/generated/healthkit-schema.json +50 -5
  63. package/src/generated/healthkit.generated.ts +18 -2
  64. package/src/healthkit.ios.ts +3 -0
  65. package/src/healthkit.ts +5 -0
  66. package/src/specs/CoreModule.nitro.ts +6 -4
  67. package/src/specs/SourceProxy.nitro.ts +1 -1
  68. package/src/type-tests/generated-typing.ts +36 -0
  69. package/src/types/QuantityType.ts +3 -3
  70. package/src/types/Source.ts +2 -2
  71. package/src/types/Workouts.ts +1 -0
package/README.md CHANGED
@@ -121,13 +121,13 @@ Some imperative examples:
121
121
  'HKQuantityTypeIdentifierInsulinDelivery',
122
122
  'IU',
123
123
  5.5,
124
+ new Date(),
125
+ new Date(),
124
126
  {
125
- metadata: {
126
127
  // Metadata keys could be arbirtary string to store app-specific data.
127
128
  // To use built-in types from https://developer.apple.com/documentation/healthkit/samples/metadata_keys
128
129
  // you need to specify string values instead of variable names (by dropping MetadataKey from the name).
129
130
  HKInsulinDeliveryReason: HKInsulinDeliveryReason.basal,
130
- },
131
131
  }
132
132
  );
133
133
  ```
@@ -154,6 +154,36 @@ Example:
154
154
  // etc..
155
155
  ```
156
156
 
157
+ ### Memory considerations for workouts and other native objects
158
+
159
+ Workouts (and a few other results, like sources) are returned as *proxies*: native objects that keep the underlying `HKWorkout` alive so you can call follow-up functions such as `getWorkoutRoutes()`, `getStatistic()` or `getAllStatistics()` on them. The JavaScript garbage collector frees a proxy, and the HealthKit objects behind it, once it is no longer referenced. The library reports an estimate of each proxy's native size to the JS engine so it can schedule collections sensibly. Note that react-native-nitro-modules currently keeps a small fixed amount of bookkeeping (on the order of a hundred bytes) per native object it has handed to JS for the lifetime of the JS runtime, so memory will not return exactly to baseline even after collection. If you process large numbers of workouts in a loop you can release the HealthKit side eagerly by calling `dispose()` once you are done with a workout:
160
+
161
+ ```TypeScript
162
+ const workouts = await queryWorkoutSamples({ limit: 0 })
163
+
164
+ for (const workout of workouts) {
165
+ const routes = await workout.getWorkoutRoutes()
166
+ // ... do something with the routes
167
+ workout.dispose()
168
+ }
169
+ ```
170
+
171
+ After `dispose()` the proxy can no longer be used - calling any function or reading any property on it will throw. If you need to keep the data around, call `toJSON()` first to get a plain object and store that instead of the proxy:
172
+
173
+ ```TypeScript
174
+ const plainWorkouts = workouts.map((workout) => {
175
+ const json = workout.toJSON()
176
+ workout.dispose()
177
+ return json
178
+ })
179
+ ```
180
+
181
+ Other tips for keeping memory in check when working with large datasets:
182
+ - Query in chunks (for example per month, or with `limit` and anchors) instead of fetching everything at once.
183
+ - Prefer statistics queries (`queryStatisticsForQuantity`, `queryStatisticsCollectionForQuantity`) over fetching every sample when you only need aggregates.
184
+ - Keep plain data (from `toJSON()` or the sample queries) in React state rather than proxies.
185
+ - `sourceRevision.source` on samples and `sources` on statistics are plain objects, not proxies. To filter queries by a source, get a `SourceProxy` from `querySources()` and match it by `bundleIdentifier`.
186
+
157
187
  ## Migration to 9.0.0
158
188
 
159
189
  There are a lot of under-the-hood changes in version 9.0.0, some of them are breaking (although I've tried to reduce it as much as possible).
package/app.plugin.js CHANGED
@@ -1,94 +1,49 @@
1
- const {
2
- withPlugins,
3
- createRunOncePlugin,
4
- withEntitlementsPlist,
5
- withInfoPlist,
6
- } = require('@expo/config-plugins')
7
-
8
- /**
9
- * @typedef ConfigPlugin
10
- * @type {import('@expo/config-plugins').ConfigPlugin<T>}
11
- * @template T = void
12
- */
13
-
14
- /**
15
- * @typedef InfoPlistConfig
16
- * @type {{
17
- * NSHealthShareUsageDescription?: string | boolean,
18
- * NSHealthUpdateUsageDescription?: string | boolean
19
- * }}
20
- */
21
-
22
- /**
23
- * @typedef AppPluginConfig
24
- * @type {InfoPlistConfig & { background?: boolean }}
25
- */
26
-
27
- /**
28
- * @type {ConfigPlugin<{background: boolean}>}
29
- */
30
- const withEntitlementsPlugin = (
31
- config,
32
- /**
33
- * @type {{background: boolean} | undefined}
34
- * */
35
- props,
36
- ) =>
37
- withEntitlementsPlist(config, (config) => {
38
- config.modResults['com.apple.developer.healthkit'] = true
39
-
40
- // background is enabled by default, but possible to opt-out from
41
- // (haven't seen any drawbacks from having it enabled)
42
- if (props?.background !== false) {
43
- config.modResults['com.apple.developer.healthkit.background-delivery'] =
44
- true
45
- }
46
-
47
- return config
48
- })
49
-
50
- /**
51
- * @type {ConfigPlugin<InfoPlistConfig>}
52
- */
53
- const withInfoPlistPlugin = (
54
- config,
55
- /**
56
- * @type {{NSHealthShareUsageDescription?: string | true, NSHealthUpdateUsageDescription?: string | false} | undefined}
57
- * */
58
- props,
59
- ) =>
60
- withInfoPlist(config, (config) => {
61
- config.modResults.NSHealthShareUsageDescription =
62
- typeof props?.NSHealthShareUsageDescription === 'string'
63
- ? props.NSHealthShareUsageDescription
64
- : typeof config.modResults.NSHealthShareUsageDescription === 'string'
65
- ? config.modResults.NSHealthShareUsageDescription
66
- : `${config.name} wants to read your health data`
67
-
68
- if (props?.NSHealthUpdateUsageDescription !== false) {
69
- config.modResults.NSHealthUpdateUsageDescription =
70
- typeof props?.NSHealthUpdateUsageDescription === 'string'
71
- ? props.NSHealthUpdateUsageDescription
72
- : typeof config.modResults.NSHealthUpdateUsageDescription === 'string'
73
- ? config.modResults.NSHealthUpdateUsageDescription
74
- : `${config.name} wants to update your health data`
75
- }
76
-
77
- return config
78
- })
79
-
80
- const pkg = require('./package.json')
81
-
82
- /**
83
- * @type {ConfigPlugin<AppPluginConfig>}
84
- */
85
- const healthkitAppPlugin = (config, props) =>
86
- withPlugins(config, [
87
- [withEntitlementsPlugin, props],
88
- [withInfoPlistPlugin, props],
89
- ])
90
-
91
- /**
92
- * @type {ConfigPlugin<AppPluginConfig>}
93
- */
94
- module.exports = createRunOncePlugin(healthkitAppPlugin, pkg.name, pkg.version)
1
+ // GENERATED FILE - do not edit by hand (gitignored).
2
+ // Compiled from app.plugin.ts by `bun run build:plugin`.
3
+ // Expo loads this file, not the .ts source, so edit app.plugin.ts and rebuild.
4
+ "use strict";
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const config_plugins_1 = require("@expo/config-plugins");
10
+ const package_json_1 = __importDefault(require("./package.json"));
11
+ const withEntitlementsPlugin = (config, props) => {
12
+ return (0, config_plugins_1.withEntitlementsPlist)(config, (configPlist) => {
13
+ configPlist.modResults['com.apple.developer.healthkit'] = true;
14
+ // background is enabled by default, but possible to opt-out from
15
+ // (haven't seen any drawbacks from having it enabled)
16
+ if (props?.background !== false) {
17
+ configPlist.modResults['com.apple.developer.healthkit.background-delivery'] = true;
18
+ }
19
+ return configPlist;
20
+ });
21
+ };
22
+ const withInfoPlistPlugin = (config, props) => {
23
+ return (0, config_plugins_1.withInfoPlist)(config, (configPlist) => {
24
+ const existingShareDescription = configPlist.modResults.NSHealthShareUsageDescription;
25
+ configPlist.modResults.NSHealthShareUsageDescription =
26
+ typeof props?.NSHealthShareUsageDescription === 'string'
27
+ ? props.NSHealthShareUsageDescription
28
+ : typeof existingShareDescription === 'string'
29
+ ? existingShareDescription
30
+ : `${config.name ?? package_json_1.default.name} wants to read your health data`;
31
+ if (props?.NSHealthUpdateUsageDescription !== false) {
32
+ const existingUpdateDescription = configPlist.modResults.NSHealthUpdateUsageDescription;
33
+ configPlist.modResults.NSHealthUpdateUsageDescription =
34
+ typeof props?.NSHealthUpdateUsageDescription === 'string'
35
+ ? props.NSHealthUpdateUsageDescription
36
+ : typeof existingUpdateDescription === 'string'
37
+ ? existingUpdateDescription
38
+ : `${config.name ?? package_json_1.default.name} wants to update your health data`;
39
+ }
40
+ return configPlist;
41
+ });
42
+ };
43
+ const healthkitAppPlugin = (config, props) => {
44
+ return (0, config_plugins_1.withPlugins)(config, [
45
+ [withEntitlementsPlugin, props],
46
+ [withInfoPlistPlugin, props],
47
+ ]);
48
+ };
49
+ exports.default = (0, config_plugins_1.createRunOncePlugin)(healthkitAppPlugin, package_json_1.default.name, package_json_1.default.version);
package/app.plugin.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  type ConfigPlugin,
3
3
  createRunOncePlugin,
4
- withAppDelegate,
5
4
  withEntitlementsPlist,
6
5
  withInfoPlist,
7
6
  withPlugins,
@@ -17,6 +16,12 @@ type InfoPlistConfig = {
17
16
  }
18
17
 
19
18
  type AppPluginConfig = InfoPlistConfig & {
19
+ /**
20
+ * Adds the background-delivery entitlement. Enabled by default; set to
21
+ * `false` to opt out. Launch-time observer registration needs no AppDelegate
22
+ * change: the pod hooks UIApplicationDidFinishLaunchingNotification itself
23
+ * (see ios/BackgroundLaunchHook.mm).
24
+ */
20
25
  background?: BackgroundConfig
21
26
  }
22
27
 
@@ -64,49 +69,10 @@ const withInfoPlistPlugin: ConfigPlugin<InfoPlistConfig> = (config, props) => {
64
69
  })
65
70
  }
66
71
 
67
- const withAppDelegatePlugin: ConfigPlugin<{
68
- background?: BackgroundConfig
69
- }> = (config, props) => {
70
- if (props?.background === false) {
71
- return config
72
- }
73
-
74
- return withAppDelegate(config, (configDelegate) => {
75
- const contents = configDelegate.modResults.contents
76
-
77
- // Add import for HealthKit if not already present
78
- if (!contents.includes('import HealthKit')) {
79
- configDelegate.modResults.contents =
80
- configDelegate.modResults.contents.replace(
81
- /^(import .+\n)/m,
82
- '$1import HealthKit\n',
83
- )
84
- }
85
-
86
- // Insert BackgroundDeliveryManager setup into didFinishLaunchingWithOptions
87
- const setupCall =
88
- ' BackgroundDeliveryManager.shared.setupBackgroundObservers()\n'
89
-
90
- if (
91
- !configDelegate.modResults.contents.includes('BackgroundDeliveryManager')
92
- ) {
93
- // Match the opening of didFinishLaunchingWithOptions and insert after the opening brace
94
- configDelegate.modResults.contents =
95
- configDelegate.modResults.contents.replace(
96
- /(func application\(.+didFinishLaunchingWithOptions.+\{)\n/,
97
- `$1\n${setupCall}`,
98
- )
99
- }
100
-
101
- return configDelegate
102
- })
103
- }
104
-
105
72
  const healthkitAppPlugin: ConfigPlugin<AppPluginConfig> = (config, props) => {
106
73
  return withPlugins(config, [
107
74
  [withEntitlementsPlugin, props],
108
75
  [withInfoPlistPlugin, props],
109
- [withAppDelegatePlugin, props],
110
76
  ])
111
77
  }
112
78
 
@@ -1,18 +1,23 @@
1
1
  import Foundation
2
2
  import HealthKit
3
+ import os.log
4
+
5
+ private let bgLog = OSLog(subsystem: "com.kingstinct.healthkit", category: "BackgroundDelivery")
3
6
 
4
7
  /// Manages HealthKit background delivery by registering observer queries at app launch,
5
8
  /// before the JS bridge is available. This is required by Apple — observer queries must
6
9
  /// be set up in `application(_:didFinishLaunchingWithOptions:)` to receive background
7
10
  /// delivery callbacks after the app has been terminated.
8
11
  ///
9
- /// Usage from AppDelegate.swift:
10
- /// BackgroundDeliveryManager.shared.setupBackgroundObservers()
12
+ /// `BackgroundLaunchHook.mm` calls `setupBackgroundObservers()` on
13
+ /// UIApplicationDidFinishLaunchingNotification, so no AppDelegate change is needed.
14
+ /// The `@objc(BackgroundDeliveryManager)` name is what that hook looks up at runtime.
11
15
  ///
12
16
  /// The types to observe are persisted in UserDefaults by `configureBackgroundTypes()`
13
17
  /// called from JS. On subsequent cold launches, the manager reads these and registers
14
18
  /// observers immediately, queuing any events until JS subscribes via `drainPendingEvents()`.
15
- @objc public class BackgroundDeliveryManager: NSObject {
19
+ @objc(BackgroundDeliveryManager)
20
+ public class BackgroundDeliveryManager: NSObject {
16
21
  @objc public static let shared = BackgroundDeliveryManager()
17
22
 
18
23
  private let healthStore = HKHealthStore()
@@ -20,6 +25,11 @@ import HealthKit
20
25
  private var observerQueries: [String: HKObserverQuery] = [:]
21
26
  private var pendingEvents: [(typeIdentifier: String, errorMessage: String?)] = []
22
27
  private var jsCallback: ((String, String?) -> Void)?
28
+ // Per-type callbacks, checked before the global `jsCallback` above. Lets
29
+ // CoreModule.subscribeToObserverQuery route a JS subscription for a
30
+ // background-configured type through this manager's already-running
31
+ // HKObserverQuery instead of registering a second, independent one.
32
+ private var typeCallbacks: [String: (String, String?) -> Void] = [:]
23
33
  private var isSetUp = false
24
34
 
25
35
  static let typesKey = "com.kingstinct.healthkit.backgroundTypes"
@@ -29,18 +39,23 @@ import HealthKit
29
39
  super.init()
30
40
  }
31
41
 
32
- /// Call this from AppDelegate.didFinishLaunchingWithOptions to register observer queries
33
- /// for any previously configured background delivery types.
42
+ /// Registers observer queries for any previously configured background delivery
43
+ /// types. Called at launch by `BackgroundLaunchHook.mm`; safe to call again.
34
44
  @objc public func setupBackgroundObservers() {
35
45
  guard HKHealthStore.isHealthDataAvailable() else { return }
36
46
 
37
47
  guard let typeIdentifiers = UserDefaults.standard.stringArray(forKey: BackgroundDeliveryManager.typesKey) else {
48
+ os_log("launch: no background delivery types configured, nothing to register", log: bgLog, type: .debug)
38
49
  return
39
50
  }
40
51
 
41
52
  let frequencyRaw = UserDefaults.standard.integer(forKey: BackgroundDeliveryManager.frequencyKey)
42
53
  let frequency = HKUpdateFrequency(rawValue: frequencyRaw) ?? .immediate
43
54
 
55
+ os_log(
56
+ "launch: registering %d background observer(s): %{public}@", log: bgLog, type: .info,
57
+ typeIdentifiers.count, typeIdentifiers.joined(separator: ", "))
58
+
44
59
  registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency)
45
60
  }
46
61
 
@@ -85,6 +100,37 @@ import HealthKit
85
100
  }
86
101
  }
87
102
 
103
+ /// Subscribe a JS callback for a specific type. Any events for this type
104
+ /// that arrived before JS subscribed (e.g. from a background wake, queued
105
+ /// in `pendingEvents` above) are flushed immediately.
106
+ func setCallback(typeIdentifier: String, callback: @escaping (String, String?) -> Void) {
107
+ queue.sync(flags: .barrier) {
108
+ self.typeCallbacks[typeIdentifier] = callback
109
+ let matching = self.pendingEvents.filter { $0.typeIdentifier == typeIdentifier }
110
+ self.pendingEvents.removeAll { $0.typeIdentifier == typeIdentifier }
111
+ for event in matching {
112
+ callback(typeIdentifier, event.errorMessage)
113
+ }
114
+ }
115
+ }
116
+
117
+ /// Remove the per-type JS callback (e.g., on unsubscribe).
118
+ func removeCallback(typeIdentifier: String) {
119
+ queue.sync(flags: .barrier) {
120
+ self.typeCallbacks.removeValue(forKey: typeIdentifier)
121
+ }
122
+ }
123
+
124
+ /// True if `typeIdentifier` has a native HKObserverQuery registered by
125
+ /// `registerObservers` — i.e. background delivery is configured for it.
126
+ /// CoreModule.subscribeToObserverQuery checks this before deciding whether
127
+ /// to reuse this manager's observer or register its own.
128
+ func isBackgroundConfigured(typeIdentifier: String) -> Bool {
129
+ return queue.sync {
130
+ self.observerQueries[typeIdentifier] != nil
131
+ }
132
+ }
133
+
88
134
  /// Stop all observer queries and clear state.
89
135
  func tearDown() {
90
136
  queue.sync(flags: .barrier) {
@@ -92,6 +138,7 @@ import HealthKit
92
138
  self.healthStore.stop(query)
93
139
  }
94
140
  self.observerQueries = [:]
141
+ self.typeCallbacks = [:]
95
142
  self.isSetUp = false
96
143
  }
97
144
  }
@@ -121,6 +168,11 @@ import HealthKit
121
168
  sampleType: sampleType,
122
169
  predicate: nil
123
170
  ) { [weak self] (_: HKObserverQuery, completionHandler: @escaping HKObserverQueryCompletionHandler, error: Error?) in
171
+ // handleObserverCallback opens a beginBackgroundTask() window as its
172
+ // first, synchronous action, before this returns to call
173
+ // completionHandler() — otherwise iOS is free to suspend the process
174
+ // the moment HealthKit's own completion handler fires, possibly
175
+ // before Hermes has finished booting to run the JS callback.
124
176
  self?.handleObserverCallback(
125
177
  typeIdentifier: typeIdentifier,
126
178
  error: error
@@ -147,18 +199,54 @@ import HealthKit
147
199
 
148
200
  private func handleObserverCallback(typeIdentifier: String, error: Error?) {
149
201
  let errorMessage = error?.localizedDescription
202
+ os_log("observer fired: %{public}@", log: bgLog, type: .info, typeIdentifier)
203
+
204
+ // Ask iOS for real execution time before doing anything else, so the
205
+ // process survives long enough for Hermes to boot and the JS callback to
206
+ // run — see the comment at this query's registration above.
207
+ var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
208
+ let endTask: () -> Void = { [weak self] in
209
+ self?.queue.sync(flags: .barrier) {
210
+ guard backgroundTaskId != .invalid else { return }
211
+ UIApplication.shared.endBackgroundTask(backgroundTaskId)
212
+ backgroundTaskId = .invalid
213
+ }
214
+ }
215
+ backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "healthkit-observer-\(typeIdentifier)") {
216
+ os_log("background task expired: %{public}@", log: bgLog, type: .info, typeIdentifier)
217
+ endTask()
218
+ }
219
+ os_log("background task started: %{public}@", log: bgLog, type: .info, typeIdentifier)
150
220
 
151
221
  queue.sync(flags: .barrier) {
152
- if let callback = self.jsCallback {
222
+ if let callback = self.typeCallbacks[typeIdentifier] {
223
+ DispatchQueue.main.async {
224
+ os_log("dispatching to per-type JS callback: %{public}@", log: bgLog, type: .info, typeIdentifier)
225
+ callback(typeIdentifier, errorMessage)
226
+ }
227
+ } else if let callback = self.jsCallback {
153
228
  // JS is connected — dispatch to main thread for JSI safety
154
229
  DispatchQueue.main.async {
230
+ os_log("dispatching to global JS callback: %{public}@", log: bgLog, type: .info, typeIdentifier)
155
231
  callback(typeIdentifier, errorMessage)
156
232
  }
157
233
  } else {
158
234
  // JS not ready yet — queue the event for later
235
+ os_log("no JS callback yet, queuing: %{public}@", log: bgLog, type: .info, typeIdentifier)
159
236
  self.pendingEvents.append((typeIdentifier: typeIdentifier, errorMessage: errorMessage))
160
237
  }
161
238
  }
239
+
240
+ // Hold the task open for a fixed, generous window rather than trying to
241
+ // ack precisely when JS finishes — Hermes boot plus the JS callback's own
242
+ // async work (HealthKit reads + Supabase upsert) is unpredictable, and a
243
+ // completion-ack channel back from JS isn't worth building on the first
244
+ // pass for a personal-use app. First place to revisit if verification
245
+ // shows this window is too tight.
246
+ DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
247
+ os_log("background task window elapsed: %{public}@", log: bgLog, type: .info, typeIdentifier)
248
+ endTask()
249
+ }
162
250
  }
163
251
 
164
252
  // Local type resolution that doesn't depend on NitroModules (which isn't available at AppDelegate time).
@@ -169,7 +257,7 @@ import HealthKit
169
257
  return HKSampleType.quantityType(forIdentifier: typeId)
170
258
  }
171
259
  if identifier.starts(with: "HKCategoryTypeIdentifier") {
172
- let typeId = HKCategoryTypeIdentifier(rawValue: identifier)
260
+ let typeId = hkCategoryTypeIdentifier(fromIdentifierName: identifier)
173
261
  return HKSampleType.categoryType(forIdentifier: typeId)
174
262
  }
175
263
  if identifier == "HKWorkoutTypeIdentifier" {
@@ -0,0 +1,41 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <UIKit/UIKit.h>
3
+
4
+ /// Registers the launch-time observer setup without any AppDelegate changes.
5
+ ///
6
+ /// `+load` runs when the binary is loaded, before `main`. It subscribes to
7
+ /// UIApplicationDidFinishLaunchingNotification, which UIKit posts synchronously
8
+ /// right after `application(_:didFinishLaunchingWithOptions:)` returns and
9
+ /// before the run loop continues, so observer queries are still registered
10
+ /// during launch as HealthKit background delivery requires. The manager is
11
+ /// looked up by name at runtime: importing this pod's Swift module from the
12
+ /// app's AppDelegate would drag NitroModules' C++ headers into a target
13
+ /// compiled without C++ interop and fail to build.
14
+ @interface BackgroundLaunchHook : NSObject
15
+ @end
16
+
17
+ @implementation BackgroundLaunchHook
18
+
19
+ + (void)load {
20
+ [[NSNotificationCenter defaultCenter]
21
+ addObserverForName:UIApplicationDidFinishLaunchingNotification
22
+ object:nil
23
+ queue:nil
24
+ usingBlock:^(NSNotification *_Nonnull note) {
25
+ Class managerClass = NSClassFromString(@"BackgroundDeliveryManager");
26
+ SEL sharedSelector = NSSelectorFromString(@"shared");
27
+ SEL setupSelector = NSSelectorFromString(@"setupBackgroundObservers");
28
+ if (managerClass == nil || ![managerClass respondsToSelector:sharedSelector]) {
29
+ return;
30
+ }
31
+ #pragma clang diagnostic push
32
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
33
+ id shared = [managerClass performSelector:sharedSelector];
34
+ if ([shared respondsToSelector:setupSelector]) {
35
+ [shared performSelector:setupSelector];
36
+ }
37
+ #pragma clang diagnostic pop
38
+ }];
39
+ }
40
+
41
+ @end
@@ -34,3 +34,27 @@ let SpeedUnit = HKUnit(from: "m/s") // HKUnit.meter().unitDivided(by: HKUnit.se
34
34
  let METUnit = HKUnit(from: "kcal/hr·kg")
35
35
 
36
36
  let DEFAULT_QUERY_LIMIT = 20
37
+
38
+ /// Apple renamed `HKCategoryTypeIdentifierAudioExposureEvent` to
39
+ /// `HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent` in iOS 14, but the new
40
+ /// constant still carries the old raw string at runtime. Building the identifier from the
41
+ /// modern name as a raw string therefore makes `categoryType(forIdentifier:)` return nil,
42
+ /// and samples read back from HealthKit report the legacy name. These helpers translate
43
+ /// in both directions so JS only ever sees the modern identifier.
44
+ let HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME =
45
+ "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent"
46
+ let HKCategoryTypeIdentifierAudioExposureEvent_NAME = "HKCategoryTypeIdentifierAudioExposureEvent"
47
+
48
+ func hkCategoryTypeIdentifier(fromIdentifierName name: String) -> HKCategoryTypeIdentifier {
49
+ if name == HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME {
50
+ return .environmentalAudioExposureEvent
51
+ }
52
+ return HKCategoryTypeIdentifier(rawValue: name)
53
+ }
54
+
55
+ func categoryTypeIdentifierName(fromHealthKitIdentifier identifier: String) -> String {
56
+ if identifier == HKCategoryTypeIdentifierAudioExposureEvent_NAME {
57
+ return HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME
58
+ }
59
+ return identifier
60
+ }
@@ -157,21 +157,27 @@ class CoreModule: HybridCoreModuleSpec {
157
157
  returning: .unnecessary
158
158
  )
159
159
  }
160
- return store.getRequestStatusForAuthorization(toShare: toShare, read: toRead) {
161
- status, error in
162
- DispatchQueue.main.async {
163
- if let error = error {
164
- continuation.resume(throwing: error)
165
- } else {
166
- if let authStatus = AuthorizationRequestStatus(rawValue: Int32(status.rawValue)) {
167
- continuation.resume(returning: authStatus)
160
+ var caughtError: NSError?
161
+ let started = RunBlockCatchingObjCExceptions({
162
+ store.getRequestStatusForAuthorization(toShare: toShare, read: toRead) {
163
+ status, error in
164
+ DispatchQueue.main.async {
165
+ if let error = error {
166
+ continuation.resume(throwing: error)
168
167
  } else {
169
- continuation.resume(
170
- throwing: runtimeErrorWithPrefix(
171
- "Unrecognized authStatus returned: \(status.rawValue)"))
168
+ if let authStatus = AuthorizationRequestStatus(rawValue: Int32(status.rawValue)) {
169
+ continuation.resume(returning: authStatus)
170
+ } else {
171
+ continuation.resume(
172
+ throwing: runtimeErrorWithPrefix(
173
+ "Unrecognized authStatus returned: \(status.rawValue)"))
174
+ }
172
175
  }
173
176
  }
174
177
  }
178
+ }, &caughtError)
179
+ if !started, let caughtError {
180
+ continuation.resume(throwing: caughtError)
175
181
  }
176
182
  }
177
183
  }
@@ -183,14 +189,20 @@ class CoreModule: HybridCoreModuleSpec {
183
189
  let toRead = objectTypesFromArray(typeIdentifiers: toRequest.toRead ?? [])
184
190
 
185
191
  return try await withCheckedThrowingContinuation { continuation in
186
- store.requestAuthorization(toShare: share, read: toRead) { status, error in
187
- DispatchQueue.main.async {
188
- if let error = error {
189
- continuation.resume(throwing: error)
190
- } else {
191
- continuation.resume(returning: status)
192
+ var caughtError: NSError?
193
+ let started = RunBlockCatchingObjCExceptions({
194
+ store.requestAuthorization(toShare: share, read: toRead) { status, error in
195
+ DispatchQueue.main.async {
196
+ if let error = error {
197
+ continuation.resume(throwing: error)
198
+ } else {
199
+ continuation.resume(returning: status)
200
+ }
192
201
  }
193
202
  }
203
+ }, &caughtError)
204
+ if !started, let caughtError {
205
+ continuation.resume(throwing: caughtError)
194
206
  }
195
207
  }
196
208
  }
@@ -308,7 +320,11 @@ class CoreModule: HybridCoreModuleSpec {
308
320
  }
309
321
 
310
322
  func isProtectedDataAvailableAsync() -> Promise<Bool> {
311
- return Promise.resolved(withResult: UIApplication.shared.isProtectedDataAvailable)
323
+ return Promise.async {
324
+ return await MainActor.run {
325
+ UIApplication.shared.isProtectedDataAvailable
326
+ }
327
+ }
312
328
  }
313
329
 
314
330
  func isHealthDataAvailable() -> Bool {
@@ -349,6 +365,10 @@ class CoreModule: HybridCoreModuleSpec {
349
365
  }
350
366
 
351
367
  var _runningQueries: [String: HKQuery] = [:]
368
+ // queryId -> typeIdentifier for subscriptions routed through
369
+ // BackgroundDeliveryManager instead of getting their own HKObserverQuery
370
+ // (see subscribeToObserverQuery below).
371
+ var _backgroundRoutedQueries: [String: String] = [:]
352
372
 
353
373
  func deleteObjects(objectTypeIdentifier: SampleTypeIdentifierWriteable, filter: FilterForSamples)
354
374
  -> Promise<Double> {
@@ -377,6 +397,25 @@ class CoreModule: HybridCoreModuleSpec {
377
397
  typeIdentifier: SampleTypeIdentifier,
378
398
  callback: @escaping (OnChangeCallbackArgs) -> Void
379
399
  ) throws -> String {
400
+ let typeIdString = typeIdentifier.stringValue
401
+
402
+ if BackgroundDeliveryManager.shared.isBackgroundConfigured(typeIdentifier: typeIdString) {
403
+ // BackgroundDeliveryManager already has an HKObserverQuery running for
404
+ // this type (registered at launch, survives termination) — route
405
+ // through it instead of registering a second, independent observer for
406
+ // the same type.
407
+ let queryId = UUID().uuidString
408
+ BackgroundDeliveryManager.shared.setCallback(typeIdentifier: typeIdString) {
409
+ (_, errorMessage) in
410
+ DispatchQueue.main.async {
411
+ callback(
412
+ OnChangeCallbackArgs(typeIdentifier: typeIdentifier, errorMessage: errorMessage))
413
+ }
414
+ }
415
+ self._backgroundRoutedQueries[queryId] = typeIdString
416
+ return queryId
417
+ }
418
+
380
419
  let sampleType = try sampleTypeFrom(sampleTypeIdentifier: typeIdentifier)
381
420
 
382
421
  let predicate = HKQuery.predicateForSamples(
@@ -411,6 +450,13 @@ class CoreModule: HybridCoreModuleSpec {
411
450
  }
412
451
 
413
452
  func unsubscribeQuery(queryId: String) throws -> Bool {
453
+ if let typeIdString = self._backgroundRoutedQueries[queryId] {
454
+ BackgroundDeliveryManager.shared.removeCallback(typeIdentifier: typeIdString)
455
+ self._backgroundRoutedQueries.removeValue(forKey: queryId)
456
+
457
+ return true
458
+ }
459
+
414
460
  guard let query = self._runningQueries[queryId] else {
415
461
  warnWithPrefix("unsubscribeQuery: Query with id \(queryId) not found")
416
462
 
@@ -2,3 +2,4 @@
2
2
  #import <HealthKit/HealthKit.h>
3
3
 
4
4
  HKUnit * _Nullable HKUnitFromStringCatchingExceptions(NSString * _Nonnull unitString, NSError * _Nullable * _Nullable outError);
5
+ BOOL RunBlockCatchingObjCExceptions(void (NS_NOESCAPE ^_Nonnull block)(void), NSError * _Nullable * _Nullable outError);