Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tiny-yaks-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kingstinct/react-native-healthkit": patch
---

feat: register observer queries in AppDelegate for background delivery
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 40 additions & 2 deletions packages/react-native-healthkit/app.plugin.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import {
type ConfigPlugin,
createRunOncePlugin,
withAppDelegate,
withEntitlementsPlist,
withInfoPlist,
withPlugins,
} from '@expo/config-plugins'

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 = {
Expand Down Expand Up @@ -57,10 +56,49 @@ const withInfoPlistPlugin: ConfigPlugin<InfoPlistConfig> = (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<AppPluginConfig> = (config, props) => {
return withPlugins(config, [
[withEntitlementsPlugin, props],
[withInfoPlistPlugin, props],
[withAppDelegatePlugin, props],
])
}

Expand Down
196 changes: 196 additions & 0 deletions packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
24 changes: 24 additions & 0 deletions packages/react-native-healthkit/ios/CoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,30 @@ class CoreModule: HybridCoreModuleSpec {
}
}

func configureBackgroundTypes(
typeIdentifiers: [String], updateFrequency: UpdateFrequency
) -> Promise<Bool> {
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<Bool> {
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] {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -206,6 +208,8 @@ export default {
areObjectTypesAvailable,
areObjectTypesAvailableAsync,
isQuantityCompatibleWithUnit,
configureBackgroundTypes,
clearBackgroundTypes,
disableAllBackgroundDelivery,
disableBackgroundDelivery,
enableBackgroundDelivery,
Expand Down
10 changes: 10 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -463,6 +471,8 @@ const HealthkitModule = {
areObjectTypesAvailable,
areObjectTypesAvailableAsync,
isQuantityCompatibleWithUnit,
configureBackgroundTypes,
clearBackgroundTypes,
disableAllBackgroundDelivery,
disableBackgroundDelivery,
enableBackgroundDelivery,
Expand Down
20 changes: 20 additions & 0 deletions packages/react-native-healthkit/src/specs/CoreModule.nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,26 @@ export interface CoreModule extends HybridObject<{ ios: 'swift' }> {
*/
disableAllBackgroundDelivery(): Promise<boolean>

/**
* 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<boolean>

/**
* 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<boolean>

/**
* @see {@link https://developer.apple.com/documentation/healthkit/hkhealthstore/1614180-ishealthdataavailable Apple Docs }
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-healthkit/src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading