Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Spezi Standard Implementation #9

Merged
merged 17 commits into from
Aug 9, 2023
Merged
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", .upToNextMinor(from: "0.5.0"))
.package(url: "https://github.com/StanfordSpezi/Spezi", .upToNextMinor(from: "0.7.0"))
],
targets: [
.target(
Expand Down
7 changes: 3 additions & 4 deletions Sources/SpeziHealthKit/CollectSample/CollectSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,10 @@ public struct CollectSample: HealthKitDataSourceDescription {
}


public func dataSources<S: Standard>(
public func dataSources(
healthStore: HKHealthStore,
standard: S,
adapter: HealthKit<S>.HKSampleAdapter
standard: any HealthKitConstraint
) -> [any HealthKitDataSource] {
collectSamples.dataSources(healthStore: healthStore, standard: standard, adapter: adapter)
collectSamples.dataSources(healthStore: healthStore, standard: standard)
}
}
10 changes: 4 additions & 6 deletions Sources/SpeziHealthKit/CollectSample/CollectSamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,17 @@ public struct CollectSamples: HealthKitDataSourceDescription {
}


public func dataSources<S: Standard>(
public func dataSources(
healthStore: HKHealthStore,
standard: S,
adapter: HealthKit<S>.HKSampleAdapter
standard: any HealthKitConstraint
) -> [any HealthKitDataSource] {
sampleTypes.map { sampleType in
HealthKitSampleDataSource<S>(
HealthKitSampleDataSource(
healthStore: healthStore,
standard: standard,
sampleType: sampleType,
predicate: predicate,
deliverySetting: deliverySetting,
adapter: adapter
deliverySetting: deliverySetting
)
}
}
Expand Down
117 changes: 52 additions & 65 deletions Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ import Spezi
import SwiftUI


final class HealthKitSampleDataSource<ComponentStandard: Standard>: HealthKitDataSource {
final class HealthKitSampleDataSource: HealthKitDataSource {
let healthStore: HKHealthStore
let standard: ComponentStandard
let standard: any HealthKitConstraint

let sampleType: HKSampleType
let predicate: NSPredicate?
let deliverySetting: HealthKitDeliverySetting
let adapter: HealthKit<ComponentStandard>.HKSampleAdapter

var active = false

private lazy var anchorUserDefaultsKey = UserDefaults.Keys.healthKitAnchorPrefix.appending(sampleType.identifier)
Expand All @@ -29,31 +27,32 @@ final class HealthKitSampleDataSource<ComponentStandard: Standard>: HealthKitDat
}
}


required init( // swiftlint:disable:this function_default_parameter_at_end
// We disable the SwiftLint as we order the parameters in a logical order and
// therefore don't put the predicate at the end here.
// swiftlint:disable function_default_parameter_at_end
required init(
healthStore: HKHealthStore,
standard: ComponentStandard,
standard: any HealthKitConstraint,
sampleType: HKSampleType,
predicate: NSPredicate? = nil, // We order the parameters in a logical order and therefore don't put the predicate at the end here.
deliverySetting: HealthKitDeliverySetting,
adapter: HealthKit<ComponentStandard>.HKSampleAdapter
predicate: NSPredicate? = nil,
deliverySetting: HealthKitDeliverySetting
) {
self.healthStore = healthStore
self.standard = standard
self.sampleType = sampleType
self.deliverySetting = deliverySetting
self.adapter = adapter

if predicate == nil {
self.predicate = HKQuery.predicateForSamples(
withStart: HealthKitSampleDataSource<ComponentStandard>.loadDefaultQueryDate(for: sampleType),
withStart: HealthKitSampleDataSource.loadDefaultQueryDate(for: sampleType),
end: nil,
options: .strictEndDate
)
} else {
self.predicate = predicate
}
}
// swiftlint:enable function_default_parameter_at_end


private static func loadDefaultQueryDate(for sampleType: HKSampleType) -> Date {
Expand Down Expand Up @@ -105,69 +104,57 @@ final class HealthKitSampleDataSource<ComponentStandard: Standard>: HealthKitDat
return
}

switch deliverySetting {
case .manual:
await standard.registerDataSource(adapter.transform(anchoredSingleObjectQuery()))
case .anchorQuery:
active = true
await standard.registerDataSource(adapter.transform(anchoredContinousObjectQuery()))
case .background:
active = true
let healthKitSamples = healthStore.startObservation(for: [sampleType], withPredicate: predicate)
.flatMap { _ in
do {
switch deliverySetting {
case .manual:
anchoredSingleObjectQuery()
case .anchorQuery:
active = true
try await anchoredContinousObjectQuery()
case .background:
active = true
for try await _ in healthStore.startObservation(for: [sampleType], withPredicate: predicate) {
self.anchoredSingleObjectQuery()
}
await standard.registerDataSource(adapter.transform(healthKitSamples))
}
} catch {
print(error)
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
}
}


private func anchoredSingleObjectQuery() -> AsyncThrowingStream<DataChange<HKSample, HKSampleRemovalContext>, Error> {
AsyncThrowingStream { continuation in
Task {
let results = try await healthStore.anchoredSingleObjectQuery(
for: self.sampleType,
using: self.anchor,
withPredicate: predicate
)
self.anchor = results.anchor
for result in results.elements {
continuation.yield(result)
}
continuation.finish()
}
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 anchoredContinousObjectQuery() async -> any TypedAsyncSequence<DataChange<HKSample, HKSampleRemovalContext>> {
AsyncThrowingStream { continuation in
Task {
try await healthStore.requestAuthorization(toShare: [], read: [sampleType])

let anchorDescriptor = healthStore.anchorDescriptor(sampleType: sampleType, predicate: predicate, anchor: anchor)

let updateQueue = anchorDescriptor.results(for: healthStore)

do {
for try await results in updateQueue {
if Task.isCancelled {
continuation.finish()
return
}

for deletedObject in results.deletedObjects {
continuation.yield(.removal(HKSampleRemovalContext(id: deletedObject.uuid, sampleType: sampleType)))
}

for addedSample in results.addedSamples {
continuation.yield(.addition(addedSample))
}
self.anchor = results.newAnchor
}
} catch {
continuation.finish(throwing: error)
}
private func anchoredContinousObjectQuery() async throws {
try await healthStore.requestAuthorization(toShare: [], read: [sampleType])

let anchorDescriptor = healthStore.anchorDescriptor(sampleType: sampleType, predicate: predicate, anchor: anchor)

let updateQueue = anchorDescriptor.results(for: healthStore)

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

for deletedObject in results.deletedObjects {
await standard.remove(removalContext: HKSampleRemovalContext(id: deletedObject.uuid, sampleType: sampleType))
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,32 @@ extension HKSample: Identifiable {
}
}


extension HKHealthStore {
// We disable the SwiftLint as we order the parameters in a logical order and
// therefore don't put the predicate at the end here.
// swiftlint:disable function_default_parameter_at_end
func anchoredSingleObjectQuery(
for sampleType: HKSampleType,
using anchor: HKQueryAnchor? = nil,
withPredicate predicate: NSPredicate? = nil
) async throws -> (elements: [DataChange<HKSample, HKSampleRemovalContext>], anchor: HKQueryAnchor) {
withPredicate predicate: NSPredicate? = nil,
standard: any HealthKitConstraint
) async throws -> (HKQueryAnchor) {
try await self.requestAuthorization(toShare: [], read: [sampleType])

let anchorDescriptor = anchorDescriptor(sampleType: sampleType, predicate: predicate, anchor: anchor)

let result = try await anchorDescriptor.result(for: self)

var elements: [DataChange<HKSample, HKSampleRemovalContext>] = []
elements.reserveCapacity(result.deletedObjects.count + result.addedSamples.count)


for deletedObject in result.deletedObjects {
elements.append(.removal(HKSampleRemovalContext(id: deletedObject.uuid, sampleType: sampleType)))
await standard.remove(removalContext: HKSampleRemovalContext(id: deletedObject.uuid, sampleType: sampleType))
}

for addedSample in result.addedSamples {
elements.append(.addition(addedSample))
await standard.add(addedSample)
}
return (elements, result.newAnchor)

return (result.newAnchor)
}
// swiftlint:enable function_default_parameter_at_end


func anchorDescriptor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,19 @@ extension HKHealthStore {
return try await sampleQueryDescriptor.result(for: self)
}


// We disable the SwiftLint as we order the parameters in a logical order and
// therefore don't put the predicate at the end here.
// swiftlint:disable function_default_parameter_at_end
func sampleQueryStream(
for sampleType: HKSampleType,
withPredicate predicate: NSPredicate? = nil
) -> AsyncThrowingStream<DataChange<HKSample, HKSample.ID>, Error> {
AsyncThrowingStream { continuation in
Task {
for sample in try await sampleQuery(for: sampleType, withPredicate: predicate) {
continuation.yield(.addition(sample))
}
continuation.finish()
withPredicate predicate: NSPredicate? = nil,
standard: any HealthKitConstraint
) {
_Concurrency.Task {
for sample in try await sampleQuery(for: sampleType, withPredicate: predicate) {
await standard.add(sample)
}
}
}
// swiftlint:enable function_default_parameter_at_end
}
40 changes: 22 additions & 18 deletions Sources/SpeziHealthKit/HealthKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,25 @@ import Spezi
import SwiftUI


/// The ``HealthKit`` module enables the collection of HealthKit data and transforms it to the component's standard's base type using an `Adapter` (``HealthKit/HKSampleAdapter``)
/// The ``HealthKit`` module enables the collection of HealthKit data.
///
/// Use the ``HealthKit/init(_:adapter:)`` initializer to define different ``HealthKitDataSourceDescription``s to define the data collection.
/// Configuration for the ``SpeziHealthKit`` module.
///
/// Make sure that your standard in your Spezi Application conforms to the ``HealthKitConstraint``
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
/// protocol to receive HealthKit data.
/// ```swift
/// actor ExampleStandard: Standard, HealthKitConstraint {
/// func add(_ response: HKSample) async {
/// ...
/// }
///
/// func remove(removalContext: SpeziHealthKit.HKSampleRemovalContext) {
/// ...
/// }
/// }
/// ```
///
/// Use the ``HealthKit/init(_:)`` initializer to define different ``HealthKitDataSourceDescription``s to define the data collection.
/// You can, e.g., use ``CollectSample`` to collect a wide variaty of `HKSampleTypes`:
/// ```swift
/// class ExampleAppDelegate: SpeziAppDelegate {
Expand Down Expand Up @@ -41,27 +57,19 @@ import SwiftUI
/// HKQuantityType(.restingHeartRate),
/// deliverySetting: .manual()
/// )
/// } adapter: {
/// TestAppHealthKitAdapter()
/// }
/// }
/// }
/// }
/// }
/// ```
public final class HealthKit<ComponentStandard: Standard>: Module {
/// The ``HealthKit/HKSampleAdapter`` type defines the mapping of `HKSample`s to the component's standard's base type.
public typealias HKSampleAdapter = any Adapter<HKSample, HKSampleRemovalContext, ComponentStandard.BaseType, ComponentStandard.RemovalContext>


@StandardActor var standard: ComponentStandard

public final class HealthKit: Module {
@StandardActor var standard: any HealthKitConstraint
let healthStore: HKHealthStore
let healthKitDataSourceDescriptions: [HealthKitDataSourceDescription]
let adapter: HKSampleAdapter
lazy var healthKitComponents: [any HealthKitDataSource] = {
healthKitDataSourceDescriptions
.flatMap { $0.dataSources(healthStore: healthStore, standard: standard, adapter: adapter) }
.flatMap { $0.dataSources(healthStore: healthStore, standard: standard) }
}()

private var healthKitSampleTypes: Set<HKSampleType> {
Expand All @@ -85,10 +93,8 @@ public final class HealthKit<ComponentStandard: Standard>: Module {
/// Creates a new instance of the ``HealthKit`` module.
/// - Parameters:
/// - healthKitDataSourceDescriptions: The ``HealthKitDataSourceDescription``s define what data is collected by the ``HealthKit`` module. You can, e.g., use ``CollectSample`` to collect a wide variaty of `HKSampleTypes`.
/// - adapter: The ``HealthKit/HKSampleAdapter`` type defines the mapping of `HKSample`s to the component's standard's base type.
public init(
@HealthKitDataSourceDescriptionBuilder _ healthKitDataSourceDescriptions: () -> ([HealthKitDataSourceDescription]),
@AdapterBuilder<ComponentStandard.BaseType, ComponentStandard.RemovalContext> adapter: () -> (HKSampleAdapter)
@HealthKitDataSourceDescriptionBuilder _ healthKitDataSourceDescriptions: () -> ([HealthKitDataSourceDescription])
) {
precondition(
HKHealthStore.isHealthDataAvailable(),
Expand All @@ -103,10 +109,8 @@ public final class HealthKit<ComponentStandard: Standard>: Module {
)

let healthStore = HKHealthStore()
let adapter = adapter()
let healthKitDataSourceDescriptions = healthKitDataSourceDescriptions()

self.adapter = adapter
self.healthKitDataSourceDescriptions = healthKitDataSourceDescriptions
self.healthStore = healthStore
}
Expand Down
22 changes: 22 additions & 0 deletions Sources/SpeziHealthKit/HealthKitConstraint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import HealthKit
import Spezi


/// A Constraint which all `Standard` instances must conform to when using the Spezi HealthKit module.
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
public protocol HealthKitConstraint: Standard {
/// Adds a new `HKSample` to the ``HealthKit`` module
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
/// - Parameter response: The `HKSample` that should be added.
func add(_ response: HKSample) async
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved

/// Removes a `HKSampleRemovalContext` from the ``HealthKit`` module
/// - Parameter response: The `HKSampleRemovalContext` that contains information on the item that should be removed.
func remove(removalContext: HKSampleRemovalContext)
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
niallkehoe marked this conversation as resolved.
Show resolved Hide resolved
}
Loading