Skip to content

Commit 3fb1b3f

Browse files
oakleafclaude
andcommitted
feat: register HealthKit observer queries in AppDelegate for background delivery
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) <noreply@anthropic.com>
1 parent b1594bf commit 3fb1b3f

8 files changed

Lines changed: 297 additions & 2 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-native-healthkit/app.plugin.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import {
22
type ConfigPlugin,
33
createRunOncePlugin,
4+
withAppDelegate,
45
withEntitlementsPlist,
56
withInfoPlist,
67
withPlugins,
78
} from '@expo/config-plugins'
89

910
import pkg from './package.json'
1011

11-
// please note that the BackgroundConfig currently doesn't actually enable background delivery for any types, but you
12-
// can set it to false if you don't want the entitlement
1312
type BackgroundConfig = boolean
1413

1514
type InfoPlistConfig = {
@@ -57,10 +56,49 @@ const withInfoPlistPlugin: ConfigPlugin<InfoPlistConfig> = (config, props) => {
5756
})
5857
}
5958

59+
const withAppDelegatePlugin: ConfigPlugin<{
60+
background?: BackgroundConfig
61+
}> = (config, props) => {
62+
if (props?.background === false) {
63+
return config
64+
}
65+
66+
return withAppDelegate(config, (configDelegate) => {
67+
const contents = configDelegate.modResults.contents
68+
69+
// Add import for HealthKit if not already present
70+
if (!contents.includes('import HealthKit')) {
71+
configDelegate.modResults.contents =
72+
configDelegate.modResults.contents.replace(
73+
/^(import .+\n)/m,
74+
'$1import HealthKit\n',
75+
)
76+
}
77+
78+
// Insert BackgroundDeliveryManager setup into didFinishLaunchingWithOptions
79+
const setupCall =
80+
' BackgroundDeliveryManager.shared.setupBackgroundObservers()\n'
81+
82+
if (
83+
!configDelegate.modResults.contents.includes('BackgroundDeliveryManager')
84+
) {
85+
// Match the opening of didFinishLaunchingWithOptions and insert after the opening brace
86+
configDelegate.modResults.contents =
87+
configDelegate.modResults.contents.replace(
88+
/(func application\(.+didFinishLaunchingWithOptions.+\{)\n/,
89+
`$1\n${setupCall}`,
90+
)
91+
}
92+
93+
return configDelegate
94+
})
95+
}
96+
6097
const healthkitAppPlugin: ConfigPlugin<AppPluginConfig> = (config, props) => {
6198
return withPlugins(config, [
6299
[withEntitlementsPlugin, props],
63100
[withInfoPlistPlugin, props],
101+
[withAppDelegatePlugin, props],
64102
])
65103
}
66104

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import Foundation
2+
import HealthKit
3+
4+
/// Manages HealthKit background delivery by registering observer queries at app launch,
5+
/// before the JS bridge is available. This is required by Apple — observer queries must
6+
/// be set up in `application(_:didFinishLaunchingWithOptions:)` to receive background
7+
/// delivery callbacks after the app has been terminated.
8+
///
9+
/// Usage from AppDelegate.swift:
10+
/// BackgroundDeliveryManager.shared.setupBackgroundObservers()
11+
///
12+
/// The types to observe are persisted in UserDefaults by `configureBackgroundTypes()`
13+
/// called from JS. On subsequent cold launches, the manager reads these and registers
14+
/// observers immediately, queuing any events until JS subscribes via `drainPendingEvents()`.
15+
@objc public class BackgroundDeliveryManager: NSObject {
16+
@objc public static let shared = BackgroundDeliveryManager()
17+
18+
private let healthStore = HKHealthStore()
19+
private let queue = DispatchQueue(label: "com.kingstinct.healthkit.background", attributes: .concurrent)
20+
private var observerQueries: [String: HKObserverQuery] = [:]
21+
private var pendingEvents: [(typeIdentifier: String, errorMessage: String?)] = []
22+
private var jsCallback: ((String, String?) -> Void)?
23+
private var isSetUp = false
24+
25+
static let typesKey = "com.kingstinct.healthkit.backgroundTypes"
26+
static let frequencyKey = "com.kingstinct.healthkit.backgroundFrequency"
27+
28+
private override init() {
29+
super.init()
30+
}
31+
32+
/// Call this from AppDelegate.didFinishLaunchingWithOptions to register observer queries
33+
/// for any previously configured background delivery types.
34+
@objc public func setupBackgroundObservers() {
35+
guard HKHealthStore.isHealthDataAvailable() else { return }
36+
37+
guard let typeIdentifiers = UserDefaults.standard.stringArray(forKey: BackgroundDeliveryManager.typesKey) else {
38+
return
39+
}
40+
41+
let frequencyRaw = UserDefaults.standard.integer(forKey: BackgroundDeliveryManager.frequencyKey)
42+
let frequency = HKUpdateFrequency(rawValue: frequencyRaw) ?? .immediate
43+
44+
registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency)
45+
}
46+
47+
/// Persist types and frequency, then register observers for the current session.
48+
/// Called from JS via CoreModule.configureBackgroundTypes().
49+
func configure(typeIdentifiers: [String], frequency: HKUpdateFrequency) {
50+
UserDefaults.standard.set(typeIdentifiers, forKey: BackgroundDeliveryManager.typesKey)
51+
UserDefaults.standard.set(frequency.rawValue, forKey: BackgroundDeliveryManager.frequencyKey)
52+
53+
// Tear down existing observers before re-registering
54+
tearDown()
55+
registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency)
56+
}
57+
58+
/// Subscribe a JS callback. Any events that arrived before JS was ready are flushed immediately.
59+
func setCallback(_ callback: @escaping (String, String?) -> Void) {
60+
queue.sync(flags: .barrier) {
61+
self.jsCallback = callback
62+
let events = self.pendingEvents
63+
self.pendingEvents = []
64+
65+
for event in events {
66+
callback(event.typeIdentifier, event.errorMessage)
67+
}
68+
}
69+
}
70+
71+
/// Remove the JS callback (e.g., on teardown).
72+
func removeCallback() {
73+
queue.sync(flags: .barrier) {
74+
self.jsCallback = nil
75+
}
76+
}
77+
78+
/// Returns any pending events and clears the queue. Used by CoreModule.subscribeToObserverQuery
79+
/// to flush events that arrived before JS subscribed.
80+
func drainPendingEvents() -> [(typeIdentifier: String, errorMessage: String?)] {
81+
return queue.sync(flags: .barrier) {
82+
let events = self.pendingEvents
83+
self.pendingEvents = []
84+
return events
85+
}
86+
}
87+
88+
/// Stop all observer queries and clear state.
89+
func tearDown() {
90+
queue.sync(flags: .barrier) {
91+
for (_, query) in self.observerQueries {
92+
self.healthStore.stop(query)
93+
}
94+
self.observerQueries = [:]
95+
self.isSetUp = false
96+
}
97+
}
98+
99+
/// Clear persisted configuration (disables background delivery on next launch).
100+
func clearConfiguration() {
101+
UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.typesKey)
102+
UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.frequencyKey)
103+
tearDown()
104+
}
105+
106+
private func registerObservers(typeIdentifiers: [String], frequency: HKUpdateFrequency) {
107+
queue.sync(flags: .barrier) {
108+
guard !self.isSetUp else { return }
109+
self.isSetUp = true
110+
}
111+
112+
for typeIdentifier in typeIdentifiers {
113+
guard let sampleType = sampleTypeFromString(typeIdentifier) else {
114+
print("[react-native-healthkit] BackgroundDeliveryManager: skipping unrecognized type \(typeIdentifier)")
115+
continue
116+
}
117+
118+
// Use nil predicate to catch all samples, including those written while the app was terminated.
119+
// The current subscribeToObserverQuery uses Date.init() which misses data from when the app was dead.
120+
let query = HKObserverQuery(
121+
sampleType: sampleType,
122+
predicate: nil
123+
) { [weak self] (_: HKObserverQuery, completionHandler: @escaping HKObserverQueryCompletionHandler, error: Error?) in
124+
self?.handleObserverCallback(
125+
typeIdentifier: typeIdentifier,
126+
error: error
127+
)
128+
// Must call the completion handler promptly so iOS knows we processed the update.
129+
completionHandler()
130+
}
131+
132+
healthStore.execute(query)
133+
134+
healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in
135+
if let error = error {
136+
print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery failed for \(typeIdentifier): \(error.localizedDescription)")
137+
} else if !success {
138+
print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery returned false for \(typeIdentifier)")
139+
}
140+
}
141+
142+
queue.sync(flags: .barrier) {
143+
self.observerQueries[typeIdentifier] = query
144+
}
145+
}
146+
}
147+
148+
private func handleObserverCallback(typeIdentifier: String, error: Error?) {
149+
let errorMessage = error?.localizedDescription
150+
151+
queue.sync(flags: .barrier) {
152+
if let callback = self.jsCallback {
153+
// JS is connected — dispatch to main thread for JSI safety
154+
DispatchQueue.main.async {
155+
callback(typeIdentifier, errorMessage)
156+
}
157+
} else {
158+
// JS not ready yet — queue the event for later
159+
self.pendingEvents.append((typeIdentifier: typeIdentifier, errorMessage: errorMessage))
160+
}
161+
}
162+
}
163+
164+
// Local type resolution that doesn't depend on NitroModules (which isn't available at AppDelegate time).
165+
// Uses the older factory APIs (quantityType(forIdentifier:) etc.) for iOS 13+ compatibility.
166+
private func sampleTypeFromString(_ identifier: String) -> HKSampleType? {
167+
if identifier.starts(with: "HKQuantityTypeIdentifier") {
168+
let typeId = HKQuantityTypeIdentifier(rawValue: identifier)
169+
return HKSampleType.quantityType(forIdentifier: typeId)
170+
}
171+
if identifier.starts(with: "HKCategoryTypeIdentifier") {
172+
let typeId = HKCategoryTypeIdentifier(rawValue: identifier)
173+
return HKSampleType.categoryType(forIdentifier: typeId)
174+
}
175+
if identifier == "HKWorkoutTypeIdentifier" {
176+
return HKSampleType.workoutType()
177+
}
178+
if identifier.starts(with: "HKCorrelationTypeIdentifier") {
179+
let typeId = HKCorrelationTypeIdentifier(rawValue: identifier)
180+
return HKSampleType.correlationType(forIdentifier: typeId)
181+
}
182+
if identifier == "HKAudiogramSampleType" {
183+
return HKObjectType.audiogramSampleType()
184+
}
185+
if identifier == "HKDataTypeIdentifierHeartbeatSeries" || identifier == "HKWorkoutRouteTypeIdentifier" {
186+
return HKObjectType.seriesType(forIdentifier: identifier)
187+
}
188+
if identifier == "HKElectrocardiogramType" {
189+
if #available(iOS 14.0, *) {
190+
return HKSampleType.electrocardiogramType()
191+
}
192+
return nil
193+
}
194+
return nil
195+
}
196+
}

packages/react-native-healthkit/ios/CoreModule.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,30 @@ class CoreModule: HybridCoreModuleSpec {
430430
}
431431
}
432432

433+
func configureBackgroundTypes(
434+
typeIdentifiers: [String], updateFrequency: UpdateFrequency
435+
) -> Promise<Bool> {
436+
return Promise.async {
437+
guard let frequency = HKUpdateFrequency(rawValue: Int(updateFrequency.rawValue)) else {
438+
throw runtimeErrorWithPrefix("Invalid update frequency rawValue: \(updateFrequency)")
439+
}
440+
441+
BackgroundDeliveryManager.shared.configure(
442+
typeIdentifiers: typeIdentifiers,
443+
frequency: frequency
444+
)
445+
446+
return true
447+
}
448+
}
449+
450+
func clearBackgroundTypes() -> Promise<Bool> {
451+
return Promise.async {
452+
BackgroundDeliveryManager.shared.clearConfiguration()
453+
return true
454+
}
455+
}
456+
433457
func unsubscribeQueries(queryIds: [String]) -> Double {
434458
let successCounts = queryIds.map { queryId in
435459
if let query = self._runningQueries[queryId] {

packages/react-native-healthkit/src/healthkit.ios.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export const disableAllBackgroundDelivery =
9999
export const disableBackgroundDelivery =
100100
Core.disableBackgroundDelivery.bind(Core)
101101
export const enableBackgroundDelivery = Core.enableBackgroundDelivery.bind(Core)
102+
export const configureBackgroundTypes = Core.configureBackgroundTypes.bind(Core)
103+
export const clearBackgroundTypes = Core.clearBackgroundTypes.bind(Core)
102104
export const getBiologicalSex =
103105
Characteristics.getBiologicalSex.bind(Characteristics)
104106
export const getBloodType = Characteristics.getBloodType.bind(Characteristics)
@@ -206,6 +208,8 @@ export default {
206208
areObjectTypesAvailable,
207209
areObjectTypesAvailableAsync,
208210
isQuantityCompatibleWithUnit,
211+
configureBackgroundTypes,
212+
clearBackgroundTypes,
209213
disableAllBackgroundDelivery,
210214
disableBackgroundDelivery,
211215
enableBackgroundDelivery,

packages/react-native-healthkit/src/healthkit.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ export const enableBackgroundDelivery = UnavailableFnFromModule(
6868
'enableBackgroundDelivery',
6969
Promise.resolve(false),
7070
)
71+
export const configureBackgroundTypes = UnavailableFnFromModule(
72+
'configureBackgroundTypes',
73+
Promise.resolve(false),
74+
)
75+
export const clearBackgroundTypes = UnavailableFnFromModule(
76+
'clearBackgroundTypes',
77+
Promise.resolve(false),
78+
)
7179
export const getPreferredUnits = UnavailableFnFromModule(
7280
'getPreferredUnits',
7381
Promise.resolve([]),
@@ -463,6 +471,8 @@ const HealthkitModule = {
463471
areObjectTypesAvailable,
464472
areObjectTypesAvailableAsync,
465473
isQuantityCompatibleWithUnit,
474+
configureBackgroundTypes,
475+
clearBackgroundTypes,
466476
disableAllBackgroundDelivery,
467477
disableBackgroundDelivery,
468478
enableBackgroundDelivery,

packages/react-native-healthkit/src/specs/CoreModule.nitro.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,26 @@ export interface CoreModule extends HybridObject<{ ios: 'swift' }> {
4040
*/
4141
disableAllBackgroundDelivery(): Promise<boolean>
4242

43+
/**
44+
* Configure background delivery types that will be registered natively in
45+
* AppDelegate.didFinishLaunchingWithOptions — surviving app termination.
46+
* Types and frequency are persisted to UserDefaults so they're available
47+
* before the JS bridge boots on subsequent cold launches.
48+
*
49+
* Requires the Expo config plugin with `background: true` (default) or
50+
* manual AppDelegate setup: `BackgroundDeliveryManager.shared.setupBackgroundObservers()`
51+
*/
52+
configureBackgroundTypes(
53+
typeIdentifiers: string[],
54+
updateFrequency: UpdateFrequency,
55+
): Promise<boolean>
56+
57+
/**
58+
* Clear persisted background delivery configuration and stop all observer queries.
59+
* After calling this, the app will no longer register observers on cold launch.
60+
*/
61+
clearBackgroundTypes(): Promise<boolean>
62+
4363
/**
4464
* @see {@link https://developer.apple.com/documentation/healthkit/hkhealthstore/1614180-ishealthdataavailable Apple Docs }
4565
*/

packages/react-native-healthkit/src/test-setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const mockModule = {
1414
disableAllBackgroundDelivery: jest.fn(),
1515
disableBackgroundDelivery: jest.fn(),
1616
enableBackgroundDelivery: jest.fn(),
17+
configureBackgroundTypes: jest.fn(),
18+
clearBackgroundTypes: jest.fn(),
1719
queryCategorySamplesWithAnchor: jest.fn(),
1820
queryQuantitySamplesWithAnchor: jest.fn(),
1921
getBiologicalSex: jest.fn(),

0 commit comments

Comments
 (0)