Skip to content

Commit

Permalink
Update to Spezi 1.2.0 and Improve Background Sample Delivery (#19)
Browse files Browse the repository at this point in the history
# Update to Spezi 1.2.0 and Improve Background Sample Delivery

## ♻️ Current situation & Problem
- HealthKit datatypes are not delivered on the first launch of the app
if the settings are set to background mode
- Spezi 1.2.0 simplified the lifecycle of Spezi Modules


## ⚙️ Release Notes 
- Fixes the issue that HealthKit datatypes are not delivered on the
first launch of the app if the settings are set to background mode
- Updates to Spezi 1.2.0

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
PSchmiedmayer authored Mar 8, 2024
1 parent 4252621 commit 3562808
Show file tree
Hide file tree
Showing 22 changed files with 334 additions and 187 deletions.
2 changes: 2 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,8 @@ deployment_target: # Availability checks or attributes shouldn’t be using olde
excluded: # paths to ignore during linting. Takes precedence over `included`.
- .build
- .swiftpm
- .derivedData
- Tests/UITests/.derivedData

closure_body_length: # Closure bodies should not span too many lines.
- 35 # warning - default: 20
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ let package = Package(
.library(name: "SpeziHealthKit", targets: ["SpeziHealthKit"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0")
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.0")
],
targets: [
.target(
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ class ExampleAppDelegate: SpeziAppDelegate {
)
CollectSample(
HKQuantityType(.stepCount),
deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch)
deliverySetting: .background(.automatic)
)
CollectSample(
HKQuantityType(.pushCount),
deliverySetting: .anchorQuery(.manual)
)
CollectSample(
HKQuantityType(.activeEnergyBurned),
deliverySetting: .anchorQuery(.afterAuthorizationAndApplicationWillLaunch)
deliverySetting: .anchorQuery(.automatic)
)
CollectSample(
HKQuantityType(.restingHeartRate),
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziHealthKit/CollectSample/CollectSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import Spezi
/// CollectSample(
/// HKQuantityType(.stepCount),
/// predicate: predicateOneMonth,
/// deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch)
/// deliverySetting: .background(.automatic)
/// )
/// ```
public struct CollectSample: HealthKitDataSourceDescription {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,60 +80,78 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
}

Task {
await triggerDataSourceCollection()
await triggerManualDataSourceCollection()
}
}

func willFinishLaunchingWithOptions() {
func startAutomaticDataCollection() {
guard askedForAuthorization(for: sampleType) else {
return
}

switch deliverySetting {
case let .anchorQuery(startSetting, _) where startSetting == .afterAuthorizationAndApplicationWillLaunch,
let .background(startSetting, _) where startSetting == .afterAuthorizationAndApplicationWillLaunch:
case let .anchorQuery(startSetting, _) where startSetting == .automatic,
let .background(startSetting, _) where startSetting == .automatic:
Task {
await triggerDataSourceCollection()
await triggerManualDataSourceCollection()
}
default:
break
}
}

func triggerDataSourceCollection() async {
func triggerManualDataSourceCollection() async {
guard !active else {
return
}

do {
switch deliverySetting {
case .manual:
anchoredSingleObjectQuery()
try await anchoredSingleObjectQuery()
case .anchorQuery:
active = true
try await anchoredContinuousObjectQuery()
case .background:
active = true
for try await _ in healthStore.startObservation(for: [sampleType], withPredicate: predicate) {
self.anchoredSingleObjectQuery()
try await healthStore.startBackgroundDelivery(for: [sampleType]) { result in
Task {
guard case let .success((sampleTypes, completionHandler)) = result else {
return
}

guard sampleTypes.contains(self.sampleType) else {
Logger.healthKit.warning("Recieved Observation query types (\(sampleTypes)) are not corresponding to the CollectSample type \(self.sampleType)")
completionHandler()
return
}

do {
try await self.anchoredSingleObjectQuery()
Logger.healthKit.debug("Successfully processed background update for \(self.sampleType)")
} catch {
Logger.healthKit.error("Could not query samples in a background update for \(self.sampleType): \(error)")
}

// Provide feedback to HealthKit that the data has been processed: https://developer.apple.com/documentation/healthkit/hkobserverquerycompletionhandler
completionHandler()
}
}
}
} catch {
Logger.healthKit.error("\(error.localizedDescription)")
Logger.healthKit.error("Could not Process HealthKit data collection: \(error.localizedDescription)")
}
}


private func anchoredSingleObjectQuery() {
Task {
let resultsAnchor = try await healthStore.anchoredSingleObjectQuery(
for: self.sampleType,
using: self.anchor,
withPredicate: predicate,
standard: self.standard
)
self.anchor = resultsAnchor
}
private func anchoredSingleObjectQuery() async throws {
let resultsAnchor = try await healthStore.anchoredSingleObjectQuery(
for: self.sampleType,
using: self.anchor,
withPredicate: predicate,
standard: self.standard
)
self.anchor = resultsAnchor
}

private func anchoredContinuousObjectQuery() async throws {
Expand All @@ -143,19 +161,21 @@ final class HealthKitSampleDataSource: HealthKitDataSource {

let updateQueue = anchorDescriptor.results(for: healthStore)

for try await results in updateQueue {
if Task.isCancelled {
return
}

for deletedObject in results.deletedObjects {
await standard.remove(sample: deletedObject)
}

for addedSample in results.addedSamples {
await standard.add(sample: addedSample)
Task {
for try await results in updateQueue {
if Task.isCancelled {
return
}

for deletedObject in results.deletedObjects {
await standard.remove(sample: deletedObject)
}

for addedSample in results.addedSamples {
await standard.add(sample: addedSample)
}
self.anchor = results.newAnchor
}
self.anchor = results.newAnchor
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import HealthKit
import OSLog
import Spezi


Expand All @@ -15,48 +16,36 @@ extension HKHealthStore {
private static let activeObservationsLock = NSLock()


func startObservation(
func startBackgroundDelivery(
for sampleTypes: Set<HKSampleType>,
withPredicate predicate: NSPredicate? = nil
) -> AsyncThrowingStream<(Set<HKSampleType>, HKObserverQueryCompletionHandler), Error> {
AsyncThrowingStream { continuation in
Task {
do {
try await enableBackgroundDelivery(for: sampleTypes)
} catch {
continuation.finish(throwing: error)
}

var queryDescriptors: [HKQueryDescriptor] = []
for sampleType in sampleTypes {
queryDescriptors.append(
HKQueryDescriptor(sampleType: sampleType, predicate: predicate)
)
}

let observerQuery = HKObserverQuery(queryDescriptors: queryDescriptors) { _, samples, completionHandler, error in
guard error == nil,
let samples else {
continuation.finish(throwing: error)
completionHandler()
return
}

continuation.yield((samples, completionHandler))
}

self.execute(observerQuery)

continuation.onTermination = { @Sendable _ in
self.stop(observerQuery)
self.disableBackgroundDelivery(for: sampleTypes)
}
withPredicate predicate: NSPredicate? = nil,
observerQuery: @escaping (Result<(sampleTypes: Set<HKSampleType>, completionHandler: HKObserverQueryCompletionHandler), Error>) -> Void
) async throws {
var queryDescriptors: [HKQueryDescriptor] = []
for sampleType in sampleTypes {
queryDescriptors.append(
HKQueryDescriptor(sampleType: sampleType, predicate: predicate)
)
}

let observerQuery = HKObserverQuery(queryDescriptors: queryDescriptors) { query, samples, completionHandler, error in
guard error == nil,
let samples else {
Logger.healthKit.error("Failed HealthKit background delivery for observer query \(query) with error: \(error)")
observerQuery(.failure(error ?? NSError(domain: "Spezi HealthKit", code: -1)))
completionHandler()
return
}

observerQuery(.success((samples, completionHandler)))
}

self.execute(observerQuery)
try await enableBackgroundDelivery(for: sampleTypes)
}


func enableBackgroundDelivery(
private func enableBackgroundDelivery(
for objectTypes: Set<HKObjectType>,
frequency: HKUpdateFrequency = .immediate
) async throws {
Expand All @@ -72,13 +61,14 @@ extension HKHealthStore {
}
}
} catch {
Logger.healthKit.error("Could not enable HealthKit Backgound access for \(objectTypes): \(error.localizedDescription)")
// Revert all changes as enable background delivery for the object types failed.
disableBackgroundDelivery(for: enabledObjectTypes)
}
}


func disableBackgroundDelivery(
private func disableBackgroundDelivery(
for objectTypes: Set<HKObjectType>
) {
for objectType in objectTypes {
Expand All @@ -87,7 +77,7 @@ extension HKHealthStore {
let newActiveObservation = activeObservation - 1
if newActiveObservation <= 0 {
HKHealthStore.activeObservations[objectType] = nil
Task {
Task { @MainActor in
try await self.disableBackgroundDelivery(for: objectType)
}
} else {
Expand Down
25 changes: 11 additions & 14 deletions Sources/SpeziHealthKit/HealthKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ import SwiftUI
/// )
/// CollectSample(
/// HKQuantityType(.stepCount),
/// deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch)
/// deliverySetting: .background(.automatic)
/// )
/// CollectSample(
/// HKQuantityType(.pushCount),
/// deliverySetting: .anchorQuery(.manual)
/// )
/// CollectSample(
/// HKQuantityType(.activeEnergyBurned),
/// deliverySetting: .anchorQuery(.afterAuthorizationAndApplicationWillLaunch)
/// deliverySetting: .anchorQuery(.automatic)
/// )
/// CollectSample(
/// HKQuantityType(.restingHeartRate),
Expand All @@ -67,7 +67,7 @@ import SwiftUI
/// }
/// ```
@Observable
public final class HealthKit: Module, LifecycleHandler, EnvironmentAccessible, DefaultInitializable {
public final class HealthKit: Module, EnvironmentAccessible, DefaultInitializable {
@ObservationIgnored @StandardActor private var standard: any HealthKitConstraint
private let healthStore: HKHealthStore
private var healthKitDataSourceDescriptions: [HealthKitDataSourceDescription] = []
Expand Down Expand Up @@ -132,6 +132,12 @@ public final class HealthKit: Module, LifecycleHandler, EnvironmentAccessible, D
}


public func configure() {
for healthKitComponent in healthKitComponents {
healthKitComponent.startAutomaticDataCollection()
}
}

/// Displays the user interface to ask for authorization for all HealthKit data defined by the ``HealthKitDataSourceDescription``s.
///
/// Call this function when you want to start HealthKit data collection.
Expand All @@ -154,7 +160,7 @@ public final class HealthKit: Module, LifecycleHandler, EnvironmentAccessible, D
healthKitDataSourceDescriptions.append(healthKitDataSourceDescription)
let dataSources = healthKitDataSourceDescription.dataSources(healthStore: healthStore, standard: standard)
for dataSource in dataSources {
dataSource.willFinishLaunchingWithOptions()
dataSource.startAutomaticDataCollection()
}
}

Expand All @@ -164,21 +170,12 @@ public final class HealthKit: Module, LifecycleHandler, EnvironmentAccessible, D
}
}


@_documentation(visibility: internal)
public func willFinishLaunchingWithOptions(_ application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]) {
for healthKitComponent in healthKitComponents {
healthKitComponent.willFinishLaunchingWithOptions()
}
}


/// Triggers any ``HealthKitDeliverySetting/manual(safeAnchor:)`` collections and starts the collection for all ``HealthKitDeliveryStartSetting/manual`` HealthKit data collections.
public func triggerDataSourceCollection() async {
await withTaskGroup(of: Void.self) { group in
for healthKitComponent in healthKitComponents {
group.addTask {
await healthKitComponent.triggerDataSourceCollection()
await healthKitComponent.triggerManualDataSourceCollection()
}
}
await group.waitForAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ public protocol HealthKitDataSource {
/// Called after the used was asked for authorization.
func askedForAuthorization()
/// Called to trigger the manual data collection.
func triggerDataSourceCollection() async
/// Called when the application finished launching with options.
func willFinishLaunchingWithOptions()
func triggerManualDataSourceCollection() async
/// Called to start the automatic data collection.
func startAutomaticDataCollection()
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ public enum HealthKitDeliverySetting: Equatable {
case manual(safeAnchor: Bool = true)
/// The HealthKit data is collected based on the `HealthKitDeliveryStartSetting` and constantly listens to updates while the application is running.
/// If `safeAnchor` is enabled the `HKQueryAnchor` is persisted across multiple application launches using the user defaults.
case anchorQuery(HealthKitDeliveryStartSetting = .afterAuthorizationAndApplicationWillLaunch, saveAnchor: Bool = true)
case anchorQuery(HealthKitDeliveryStartSetting = .automatic, saveAnchor: Bool = true)
/// The HealthKit data is collected based on the `HealthKitDeliveryStartSetting` and constantly listens to updates even if the application is not running.
/// If `safeAnchor` is enabled the `HKQueryAnchor` is persisted across multiple application launches using the user defaults.
case background(HealthKitDeliveryStartSetting = .afterAuthorizationAndApplicationWillLaunch, saveAnchor: Bool = true)
case background(HealthKitDeliveryStartSetting = .automatic, saveAnchor: Bool = true)


var saveAnchor: Bool {
Expand Down
Loading

0 comments on commit 3562808

Please sign in to comment.