From 3fb1b3f37830adcc6a3a2ca3fc330fed90509bb7 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Sat, 4 Apr 2026 16:27:36 +0200 Subject: [PATCH 1/2] feat: register HealthKit observer queries in AppDelegate for background delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple requires HKObserverQuery to be set up in didFinishLaunchingWithOptions before the JS bridge boots. Without this, background delivery silently fails when iOS terminates and relaunches the app for a HealthKit update. This adds BackgroundDeliveryManager — a plain Swift singleton (not NitroModules) that reads persisted type identifiers from UserDefaults at app launch and registers observer queries + enableBackgroundDelivery immediately. Events that arrive before JS is ready are queued and flushed when JS subscribes. New JS API: - configureBackgroundTypes(types, frequency) — persist + register observers - clearBackgroundTypes() — clear config + stop observers Expo config plugin now injects BackgroundDeliveryManager.shared.setupBackgroundObservers() into AppDelegate.didFinishLaunchingWithOptions automatically. Fixes #51 Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 1 + packages/react-native-healthkit/app.plugin.ts | 42 +++- .../ios/BackgroundDeliveryManager.swift | 196 ++++++++++++++++++ .../ios/CoreModule.swift | 24 +++ .../src/healthkit.ios.ts | 4 + .../react-native-healthkit/src/healthkit.ts | 10 + .../src/specs/CoreModule.nitro.ts | 20 ++ .../react-native-healthkit/src/test-setup.ts | 2 + 8 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift diff --git a/bun.lock b/bun.lock index 11afc947..eee5f784 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-native-healthkit-mono", diff --git a/packages/react-native-healthkit/app.plugin.ts b/packages/react-native-healthkit/app.plugin.ts index 9053b731..44d1e1ff 100644 --- a/packages/react-native-healthkit/app.plugin.ts +++ b/packages/react-native-healthkit/app.plugin.ts @@ -1,6 +1,7 @@ import { type ConfigPlugin, createRunOncePlugin, + withAppDelegate, withEntitlementsPlist, withInfoPlist, withPlugins, @@ -8,8 +9,6 @@ import { import pkg from './package.json' -// please note that the BackgroundConfig currently doesn't actually enable background delivery for any types, but you -// can set it to false if you don't want the entitlement type BackgroundConfig = boolean type InfoPlistConfig = { @@ -57,10 +56,49 @@ const withInfoPlistPlugin: ConfigPlugin = (config, props) => { }) } +const withAppDelegatePlugin: ConfigPlugin<{ + background?: BackgroundConfig +}> = (config, props) => { + if (props?.background === false) { + return config + } + + return withAppDelegate(config, (configDelegate) => { + const contents = configDelegate.modResults.contents + + // Add import for HealthKit if not already present + if (!contents.includes('import HealthKit')) { + configDelegate.modResults.contents = + configDelegate.modResults.contents.replace( + /^(import .+\n)/m, + '$1import HealthKit\n', + ) + } + + // Insert BackgroundDeliveryManager setup into didFinishLaunchingWithOptions + const setupCall = + ' BackgroundDeliveryManager.shared.setupBackgroundObservers()\n' + + if ( + !configDelegate.modResults.contents.includes('BackgroundDeliveryManager') + ) { + // Match the opening of didFinishLaunchingWithOptions and insert after the opening brace + configDelegate.modResults.contents = + configDelegate.modResults.contents.replace( + /(func application\(.+didFinishLaunchingWithOptions.+\{)\n/, + `$1\n${setupCall}`, + ) + } + + return configDelegate + }) +} + const healthkitAppPlugin: ConfigPlugin = (config, props) => { return withPlugins(config, [ [withEntitlementsPlugin, props], [withInfoPlistPlugin, props], + [withAppDelegatePlugin, props], ]) } diff --git a/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift b/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift new file mode 100644 index 00000000..912a5ed2 --- /dev/null +++ b/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift @@ -0,0 +1,196 @@ +import Foundation +import HealthKit + +/// Manages HealthKit background delivery by registering observer queries at app launch, +/// before the JS bridge is available. This is required by Apple — observer queries must +/// be set up in `application(_:didFinishLaunchingWithOptions:)` to receive background +/// delivery callbacks after the app has been terminated. +/// +/// Usage from AppDelegate.swift: +/// BackgroundDeliveryManager.shared.setupBackgroundObservers() +/// +/// The types to observe are persisted in UserDefaults by `configureBackgroundTypes()` +/// called from JS. On subsequent cold launches, the manager reads these and registers +/// observers immediately, queuing any events until JS subscribes via `drainPendingEvents()`. +@objc public class BackgroundDeliveryManager: NSObject { + @objc public static let shared = BackgroundDeliveryManager() + + private let healthStore = HKHealthStore() + private let queue = DispatchQueue(label: "com.kingstinct.healthkit.background", attributes: .concurrent) + private var observerQueries: [String: HKObserverQuery] = [:] + private var pendingEvents: [(typeIdentifier: String, errorMessage: String?)] = [] + private var jsCallback: ((String, String?) -> Void)? + private var isSetUp = false + + static let typesKey = "com.kingstinct.healthkit.backgroundTypes" + static let frequencyKey = "com.kingstinct.healthkit.backgroundFrequency" + + private override init() { + super.init() + } + + /// Call this from AppDelegate.didFinishLaunchingWithOptions to register observer queries + /// for any previously configured background delivery types. + @objc public func setupBackgroundObservers() { + guard HKHealthStore.isHealthDataAvailable() else { return } + + guard let typeIdentifiers = UserDefaults.standard.stringArray(forKey: BackgroundDeliveryManager.typesKey) else { + return + } + + let frequencyRaw = UserDefaults.standard.integer(forKey: BackgroundDeliveryManager.frequencyKey) + let frequency = HKUpdateFrequency(rawValue: frequencyRaw) ?? .immediate + + registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency) + } + + /// Persist types and frequency, then register observers for the current session. + /// Called from JS via CoreModule.configureBackgroundTypes(). + func configure(typeIdentifiers: [String], frequency: HKUpdateFrequency) { + UserDefaults.standard.set(typeIdentifiers, forKey: BackgroundDeliveryManager.typesKey) + UserDefaults.standard.set(frequency.rawValue, forKey: BackgroundDeliveryManager.frequencyKey) + + // Tear down existing observers before re-registering + tearDown() + registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency) + } + + /// Subscribe a JS callback. Any events that arrived before JS was ready are flushed immediately. + func setCallback(_ callback: @escaping (String, String?) -> Void) { + queue.sync(flags: .barrier) { + self.jsCallback = callback + let events = self.pendingEvents + self.pendingEvents = [] + + for event in events { + callback(event.typeIdentifier, event.errorMessage) + } + } + } + + /// Remove the JS callback (e.g., on teardown). + func removeCallback() { + queue.sync(flags: .barrier) { + self.jsCallback = nil + } + } + + /// Returns any pending events and clears the queue. Used by CoreModule.subscribeToObserverQuery + /// to flush events that arrived before JS subscribed. + func drainPendingEvents() -> [(typeIdentifier: String, errorMessage: String?)] { + return queue.sync(flags: .barrier) { + let events = self.pendingEvents + self.pendingEvents = [] + return events + } + } + + /// Stop all observer queries and clear state. + func tearDown() { + queue.sync(flags: .barrier) { + for (_, query) in self.observerQueries { + self.healthStore.stop(query) + } + self.observerQueries = [:] + self.isSetUp = false + } + } + + /// Clear persisted configuration (disables background delivery on next launch). + func clearConfiguration() { + UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.typesKey) + UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.frequencyKey) + tearDown() + } + + private func registerObservers(typeIdentifiers: [String], frequency: HKUpdateFrequency) { + queue.sync(flags: .barrier) { + guard !self.isSetUp else { return } + self.isSetUp = true + } + + for typeIdentifier in typeIdentifiers { + guard let sampleType = sampleTypeFromString(typeIdentifier) else { + print("[react-native-healthkit] BackgroundDeliveryManager: skipping unrecognized type \(typeIdentifier)") + continue + } + + // Use nil predicate to catch all samples, including those written while the app was terminated. + // The current subscribeToObserverQuery uses Date.init() which misses data from when the app was dead. + let query = HKObserverQuery( + sampleType: sampleType, + predicate: nil + ) { [weak self] (_: HKObserverQuery, completionHandler: @escaping HKObserverQueryCompletionHandler, error: Error?) in + self?.handleObserverCallback( + typeIdentifier: typeIdentifier, + error: error + ) + // Must call the completion handler promptly so iOS knows we processed the update. + completionHandler() + } + + healthStore.execute(query) + + healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in + if let error = error { + print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery failed for \(typeIdentifier): \(error.localizedDescription)") + } else if !success { + print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery returned false for \(typeIdentifier)") + } + } + + queue.sync(flags: .barrier) { + self.observerQueries[typeIdentifier] = query + } + } + } + + private func handleObserverCallback(typeIdentifier: String, error: Error?) { + let errorMessage = error?.localizedDescription + + queue.sync(flags: .barrier) { + if let callback = self.jsCallback { + // JS is connected — dispatch to main thread for JSI safety + DispatchQueue.main.async { + callback(typeIdentifier, errorMessage) + } + } else { + // JS not ready yet — queue the event for later + self.pendingEvents.append((typeIdentifier: typeIdentifier, errorMessage: errorMessage)) + } + } + } + + // Local type resolution that doesn't depend on NitroModules (which isn't available at AppDelegate time). + // Uses the older factory APIs (quantityType(forIdentifier:) etc.) for iOS 13+ compatibility. + private func sampleTypeFromString(_ identifier: String) -> HKSampleType? { + if identifier.starts(with: "HKQuantityTypeIdentifier") { + let typeId = HKQuantityTypeIdentifier(rawValue: identifier) + return HKSampleType.quantityType(forIdentifier: typeId) + } + if identifier.starts(with: "HKCategoryTypeIdentifier") { + let typeId = HKCategoryTypeIdentifier(rawValue: identifier) + return HKSampleType.categoryType(forIdentifier: typeId) + } + if identifier == "HKWorkoutTypeIdentifier" { + return HKSampleType.workoutType() + } + if identifier.starts(with: "HKCorrelationTypeIdentifier") { + let typeId = HKCorrelationTypeIdentifier(rawValue: identifier) + return HKSampleType.correlationType(forIdentifier: typeId) + } + if identifier == "HKAudiogramSampleType" { + return HKObjectType.audiogramSampleType() + } + if identifier == "HKDataTypeIdentifierHeartbeatSeries" || identifier == "HKWorkoutRouteTypeIdentifier" { + return HKObjectType.seriesType(forIdentifier: identifier) + } + if identifier == "HKElectrocardiogramType" { + if #available(iOS 14.0, *) { + return HKSampleType.electrocardiogramType() + } + return nil + } + return nil + } +} diff --git a/packages/react-native-healthkit/ios/CoreModule.swift b/packages/react-native-healthkit/ios/CoreModule.swift index 83eef50f..9ff6e3aa 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -430,6 +430,30 @@ class CoreModule: HybridCoreModuleSpec { } } + func configureBackgroundTypes( + typeIdentifiers: [String], updateFrequency: UpdateFrequency + ) -> Promise { + return Promise.async { + guard let frequency = HKUpdateFrequency(rawValue: Int(updateFrequency.rawValue)) else { + throw runtimeErrorWithPrefix("Invalid update frequency rawValue: \(updateFrequency)") + } + + BackgroundDeliveryManager.shared.configure( + typeIdentifiers: typeIdentifiers, + frequency: frequency + ) + + return true + } + } + + func clearBackgroundTypes() -> Promise { + return Promise.async { + BackgroundDeliveryManager.shared.clearConfiguration() + return true + } + } + func unsubscribeQueries(queryIds: [String]) -> Double { let successCounts = queryIds.map { queryId in if let query = self._runningQueries[queryId] { diff --git a/packages/react-native-healthkit/src/healthkit.ios.ts b/packages/react-native-healthkit/src/healthkit.ios.ts index 41ec90e4..77570297 100644 --- a/packages/react-native-healthkit/src/healthkit.ios.ts +++ b/packages/react-native-healthkit/src/healthkit.ios.ts @@ -99,6 +99,8 @@ export const disableAllBackgroundDelivery = export const disableBackgroundDelivery = Core.disableBackgroundDelivery.bind(Core) export const enableBackgroundDelivery = Core.enableBackgroundDelivery.bind(Core) +export const configureBackgroundTypes = Core.configureBackgroundTypes.bind(Core) +export const clearBackgroundTypes = Core.clearBackgroundTypes.bind(Core) export const getBiologicalSex = Characteristics.getBiologicalSex.bind(Characteristics) export const getBloodType = Characteristics.getBloodType.bind(Characteristics) @@ -206,6 +208,8 @@ export default { areObjectTypesAvailable, areObjectTypesAvailableAsync, isQuantityCompatibleWithUnit, + configureBackgroundTypes, + clearBackgroundTypes, disableAllBackgroundDelivery, disableBackgroundDelivery, enableBackgroundDelivery, diff --git a/packages/react-native-healthkit/src/healthkit.ts b/packages/react-native-healthkit/src/healthkit.ts index df2dbc06..06737ec2 100644 --- a/packages/react-native-healthkit/src/healthkit.ts +++ b/packages/react-native-healthkit/src/healthkit.ts @@ -68,6 +68,14 @@ export const enableBackgroundDelivery = UnavailableFnFromModule( 'enableBackgroundDelivery', Promise.resolve(false), ) +export const configureBackgroundTypes = UnavailableFnFromModule( + 'configureBackgroundTypes', + Promise.resolve(false), +) +export const clearBackgroundTypes = UnavailableFnFromModule( + 'clearBackgroundTypes', + Promise.resolve(false), +) export const getPreferredUnits = UnavailableFnFromModule( 'getPreferredUnits', Promise.resolve([]), @@ -463,6 +471,8 @@ const HealthkitModule = { areObjectTypesAvailable, areObjectTypesAvailableAsync, isQuantityCompatibleWithUnit, + configureBackgroundTypes, + clearBackgroundTypes, disableAllBackgroundDelivery, disableBackgroundDelivery, enableBackgroundDelivery, diff --git a/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts b/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts index 0f02efee..efc43f9e 100644 --- a/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts +++ b/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts @@ -40,6 +40,26 @@ export interface CoreModule extends HybridObject<{ ios: 'swift' }> { */ disableAllBackgroundDelivery(): Promise + /** + * Configure background delivery types that will be registered natively in + * AppDelegate.didFinishLaunchingWithOptions — surviving app termination. + * Types and frequency are persisted to UserDefaults so they're available + * before the JS bridge boots on subsequent cold launches. + * + * Requires the Expo config plugin with `background: true` (default) or + * manual AppDelegate setup: `BackgroundDeliveryManager.shared.setupBackgroundObservers()` + */ + configureBackgroundTypes( + typeIdentifiers: string[], + updateFrequency: UpdateFrequency, + ): Promise + + /** + * Clear persisted background delivery configuration and stop all observer queries. + * After calling this, the app will no longer register observers on cold launch. + */ + clearBackgroundTypes(): Promise + /** * @see {@link https://developer.apple.com/documentation/healthkit/hkhealthstore/1614180-ishealthdataavailable Apple Docs } */ diff --git a/packages/react-native-healthkit/src/test-setup.ts b/packages/react-native-healthkit/src/test-setup.ts index 8d6a913d..159434d3 100644 --- a/packages/react-native-healthkit/src/test-setup.ts +++ b/packages/react-native-healthkit/src/test-setup.ts @@ -14,6 +14,8 @@ const mockModule = { disableAllBackgroundDelivery: jest.fn(), disableBackgroundDelivery: jest.fn(), enableBackgroundDelivery: jest.fn(), + configureBackgroundTypes: jest.fn(), + clearBackgroundTypes: jest.fn(), queryCategorySamplesWithAnchor: jest.fn(), queryQuantitySamplesWithAnchor: jest.fn(), getBiologicalSex: jest.fn(), From c7b2180e1a253be7402734a73e541d2d605d303a Mon Sep 17 00:00:00 2001 From: Robert Herber Date: Thu, 9 Apr 2026 00:57:13 +0200 Subject: [PATCH 2/2] Add observer queries for background delivery Register observer queries in AppDelegate to enable background delivery. --- .changeset/tiny-yaks-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tiny-yaks-fail.md diff --git a/.changeset/tiny-yaks-fail.md b/.changeset/tiny-yaks-fail.md new file mode 100644 index 00000000..e502ac4e --- /dev/null +++ b/.changeset/tiny-yaks-fail.md @@ -0,0 +1,5 @@ +--- +"@kingstinct/react-native-healthkit": patch +--- + +feat: register observer queries in AppDelegate for background delivery