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

Exposing Progress Object #22

Closed
wants to merge 13 commits into from
Closed
76 changes: 76 additions & 0 deletions Sources/SpeziHealthKit/BulkUpload/BulkUpload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// 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
//
// Created by Matthew Joerke and Bryant Jimenez

import HealthKit
import Spezi

/// Collects batches of `HKSampleType`s in the ``HealthKit`` module for upload.
public struct BulkUpload: HealthKitDataSourceDescription {
public let sampleTypes: Set<HKSampleType>
let predicate: NSPredicate
let deliverySetting: HealthKitDeliverySetting
let bulkSize: Int

/// - Parameters:
/// - sampleTypes: The set of `HKSampleType`s that should be collected
/// - predicate: A custom predicate that should be passed to the HealthKit query.
/// The default predicate collects all samples that have been collected from the first time that the user
/// provided the application authorization to collect the samples.
/// - deliverySetting: The ``HealthKitDeliverySetting`` that should be used to collect the sample type. `.manual` is the default argument used.
public init(
_ sampleTypes: Set<HKSampleType>,
predicate: NSPredicate,
bulkSize: Int = 100,
deliveryStartSetting: HealthKitDeliveryStartSetting = .manual
) {
self.sampleTypes = sampleTypes
self.predicate = predicate
self.bulkSize = bulkSize
self.deliverySetting = HealthKitDeliverySetting.anchorQuery(deliveryStartSetting, saveAnchor: true)
}

public func dataSources(healthStore: HKHealthStore, standard: any Standard) -> [any HealthKitDataSource] {
// Ensure the 'standard' actually conforms to 'BulkUploadConstraint' to use specific processBulk function.
guard let bulkUploadConstraint = standard as? any BulkUploadConstraint else {
preconditionFailure(
"""
The `Standard` defined in the `Configuration` does not conform to \(String(describing: (any HealthKitConstraint).self)).

Ensure that you define an appropriate standard in your configuration in your `SpeziAppDelegate` subclass ...
```
var configuration: Configuration {
Configuration(standard: \(String(describing: standard))()) {
// ...
}
}
```

... and that your standard conforms to \(String(describing: (any HealthKitConstraint).self)):

```swift
actor \(String(describing: standard)): Standard, \(String(describing: (any HealthKitConstraint).self)) {
// ...
}
```
"""
)
}

return sampleTypes.map { sampleType in
BulkUploadSampleDataSource(
healthStore: healthStore,
standard: bulkUploadConstraint,
sampleType: sampleType,
predicate: predicate,
deliverySetting: deliverySetting,
bulkSize: bulkSize
)
}
}
}
186 changes: 186 additions & 0 deletions Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//
// 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
//
// Created by Bryant Jimenez and Matthew Joerke

import HealthKit
import OSLog
import Spezi
import SwiftUI

@Observable
final class BulkUploadSampleDataSource: HealthKitDataSource {
let healthStore: HKHealthStore
let standard: any BulkUploadConstraint

let sampleType: HKSampleType
let predicate: NSPredicate?
let deliverySetting: HealthKitDeliverySetting
let bulkSize: Int
var active = false
var totalSamples: Int = 0
var processedSamples: Int = 0 {
didSet {
saveProcessedSamples()
}
}

// lazy variables cannot be observable
@ObservationIgnored private lazy var anchorUserDefaultsKey = UserDefaults.Keys.bulkUploadAnchorPrefix.appending(sampleType.identifier)
@ObservationIgnored private lazy var totalSamplesKey = UserDefaults.Keys.bulkUploadTotalSamplesPrefix.appending(sampleType.identifier)
@ObservationIgnored private lazy var processedSamplesKey = UserDefaults.Keys.bulkUploadProcessedSamplesPrefix.appending(sampleType.identifier)
@ObservationIgnored private lazy var anchor: HKQueryAnchor? = loadAnchor() {
didSet {
saveAnchor()
}
}

public var progress: Progress {

Check failure on line 42 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Lower ACL than Parent Violation: Ensure declarations have a lower access control level than their enclosing parent (lower_acl_than_parent)
let progress = Progress(totalUnitCount: Int64(totalSamples))
progress.completedUnitCount = Int64(processedSamples)
return progress
}

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

Check failure on line 50 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Superfluous Disable Command Violation: SwiftLint rule 'function_default_parameter_at_end' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
required init(
healthStore: HKHealthStore,
standard: any BulkUploadConstraint,
sampleType: HKSampleType,
predicate: NSPredicate,
deliverySetting: HealthKitDeliverySetting,
bulkSize: Int
) {
self.healthStore = healthStore
self.standard = standard
self.sampleType = sampleType
self.deliverySetting = deliverySetting
self.bulkSize = bulkSize
self.predicate = predicate

loadTotalSamplesOnce()
self.processedSamples = loadProcessedSamples()
}
// swiftlint:enable function_default_parameter_at_end

func askedForAuthorization() async {
guard askedForAuthorization(for: sampleType) && !deliverySetting.isManual && !active else {
return
}

await triggerManualDataSourceCollection()
}

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

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

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

do {
active = true
try await anchoredBulkUploadQuery()
} catch {
Logger.healthKit.error("Could not Process HealthKit data collection: \(error.localizedDescription)")
}
}


private func anchoredBulkUploadQuery() async throws {
try await healthStore.requestAuthorization(toShare: [], read: [sampleType])

// create an anchor descriptor that reads a data batch of the defined bulkSize
var anchorDescriptor = HKAnchoredObjectQueryDescriptor(
predicates: [
.sample(type: sampleType, predicate: predicate)
],
anchor: anchor,
limit: bulkSize
)

// run query at least once
var result = try await anchorDescriptor.result(for: healthStore)

// continue reading bulkSize batches of data until theres no new data
repeat {
await standard.processBulk(samplesAdded: result.addedSamples, samplesDeleted: result.deletedObjects)
self.processedSamples += result.addedSamples.count + result.deletedObjects.count

// advance the anchor
anchor = result.newAnchor

anchorDescriptor = HKAnchoredObjectQueryDescriptor(
predicates: [
.sample(type: sampleType, predicate: predicate)
],
anchor: anchor,
limit: bulkSize
)
result = try await anchorDescriptor.result(for: healthStore)
} while (!result.addedSamples.isEmpty) || (!result.deletedObjects.isEmpty)
}

private func saveAnchor() {
if deliverySetting.saveAnchor {
guard let anchor,
let data = try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) else {
return
}

UserDefaults.standard.set(data, forKey: anchorUserDefaultsKey)
}
}

private func loadAnchor() -> HKQueryAnchor? {
guard deliverySetting.saveAnchor,
let userDefaultsData = UserDefaults.standard.data(forKey: anchorUserDefaultsKey),
let loadedAnchor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: userDefaultsData) else {
return nil
}

return loadedAnchor
}

private func loadTotalSamplesOnce() {
let cachedTotal = UserDefaults.standard.integer(forKey: totalSamplesKey)
if cachedTotal != 0 { // user defaults to 0 if key is missing
self.totalSamples = cachedTotal
} else {
// Initial query to fetch the total count of samples
_ = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: HKObjectQueryNoLimit,

Check failure on line 167 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Multiline Arguments Violation: Arguments should be either on the same line, or one per line (multiline_arguments)

Check failure on line 167 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Multiline Arguments Violation: Arguments should be either on the same line, or one per line (multiline_arguments)

Check failure on line 167 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Multiline Arguments Brackets Violation: Multiline arguments should have their surrounding brackets in a new line (multiline_arguments_brackets)
sortDescriptors: nil) { (query, results, error) in

Check failure on line 168 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Multiline Arguments Brackets Violation: Multiline arguments should have their surrounding brackets in a new line (multiline_arguments_brackets)

Check failure on line 168 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Unneeded Parentheses in Closure Argument Violation: Parentheses are not needed when declaring closure arguments (unneeded_parentheses_in_closure_argument)

Check failure on line 168 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Unused Closure Parameter Violation: Unused parameter in a closure should be replaced with _ (unused_closure_parameter)

Check failure on line 168 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Unused Closure Parameter Violation: Unused parameter in a closure should be replaced with _ (unused_closure_parameter)
guard let samples = results else {
print("Error computing total size of bulk upload: could not retrieve samples of current sample type")
return
}
UserDefaults.standard.set(samples.count, forKey: self.totalSamplesKey)
self.totalSamples = samples.count
}
}
}

private func saveProcessedSamples() {
UserDefaults.standard.set(processedSamples, forKey: processedSamplesKey)
}

private func loadProcessedSamples() -> Int {
return UserDefaults.standard.integer(forKey: processedSamplesKey)

Check failure on line 184 in Sources/SpeziHealthKit/BulkUpload/BulkUploadSampleDataSource.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Implicit Return Violation: Prefer implicit returns in closures, functions and getters (implicit_return)
}
}
35 changes: 35 additions & 0 deletions Sources/SpeziHealthKit/BulkUploadConstraint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// 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
//
// Created by Bryant Jimenez and Matthew Joerke

import HealthKit
import Spezi


/// A Constraint which your `Standard` instance must conform to when using the Spezi HealthKit BulkUpload module.
///
///
/// Make sure that your standard in your Spezi Application conforms to the ``BulkUploadConstraint``
/// protocol to upload HealthKit data in configurable bulk sizes.
/// The ``BulkUploadConstraint/processBulk(samplesAdded:samplesDeleted:)`` function is triggered once for every collected batch of HealthKit samples returned by the anchor query.
/// ```swift
/// actor ExampleStandard: Standard, BulkUploadConstraint {
/// // Add the collected batch of HKSamples to your application, as well as any backoff mechanisms (e.g. wait a specific amount after each upload).
/// func add_bulk(sample: HKSample) async {
/// ...
/// }
/// }
/// ```
///
public protocol BulkUploadConstraint: Standard {
/// Notifies the `Standard` about the addition of a batch of HealthKit ``HKSample`` samples instance.
/// - Parameter samplesAdded: The batch of `HKSample`s that should be added.
/// - Parameter objectsDeleted: The batch of `HKSample`s that were deleted from the HealthStore. Included if needed to account for rate limiting
/// when uploading to a cloud provider.
func processBulk(samplesAdded: [HKSample], samplesDeleted: [HKDeletedObject]) async
}
2 changes: 1 addition & 1 deletion Sources/SpeziHealthKit/CollectSample/CollectSample.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public struct CollectSample: HealthKitDataSourceDescription {

public func dataSources(
healthStore: HKHealthStore,
standard: any HealthKitConstraint
standard: any Standard
) -> [any HealthKitDataSource] {
collectSamples.dataSources(healthStore: healthStore, standard: standard)
}
Expand Down
31 changes: 28 additions & 3 deletions Sources/SpeziHealthKit/CollectSample/CollectSamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,37 @@ public struct CollectSamples: HealthKitDataSourceDescription {

public func dataSources(
healthStore: HKHealthStore,
standard: any HealthKitConstraint
standard: any Standard
) -> [any HealthKitDataSource] {
sampleTypes.map { sampleType in
guard let healthKitConstraint = standard as? any HealthKitConstraint else {
preconditionFailure(
"""
The `Standard` defined in the `Configuration` does not conform to \(String(describing: (any HealthKitConstraint).self)).

Ensure that you define an appropriate standard in your configuration in your `SpeziAppDelegate` subclass ...
```
var configuration: Configuration {
Configuration(standard: \(String(describing: standard))()) {
// ...
}
}
```

... and that your standard conforms to \(String(describing: (any HealthKitConstraint).self)):

```swift
actor \(String(describing: standard)): Standard, \(String(describing: (any HealthKitConstraint).self)) {
// ...
}
```
"""
)
}

return sampleTypes.map { sampleType in
HealthKitSampleDataSource(
healthStore: healthStore,
standard: standard,
standard: healthKitConstraint,
sampleType: sampleType,
predicate: predicate,
deliverySetting: deliverySetting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ extension UserDefaults {
static let healthKitRequestedSampleTypes = "Spezi.HealthKit.RequestedSampleTypes"
static let healthKitAnchorPrefix = "Spezi.HealthKit.Anchors."
static let healthKitDefaultPredicateDatePrefix = "Spezi.HealthKit.DefaultPredicateDate."
static let bulkUploadAnchorPrefix = "Spezi.BulkUpload.Anchors."
static let bulkUploadTotalSamplesPrefix = "Spezi.BulkUpload.Total."
static let bulkUploadProcessedSamplesPrefix = "Spezi.BulkUpload.Processed."
static let bulkUploadDefaultPredicateDatePrefix = "Spezi.BulkUpload.DefaultPredicateDate."
}
}
17 changes: 16 additions & 1 deletion Sources/SpeziHealthKit/HealthKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,27 @@ import SwiftUI
/// ```
@Observable
public final class HealthKit: Module, EnvironmentAccessible, DefaultInitializable {
@ObservationIgnored @StandardActor private var standard: any HealthKitConstraint
@ObservationIgnored @StandardActor private var standard: any Standard
private let healthStore: HKHealthStore
private var initialHealthKitDataSourceDescriptions: [HealthKitDataSourceDescription] = []
private var healthKitDataSourceDescriptions: [HealthKitDataSourceDescription] = []
@ObservationIgnored private var healthKitComponents: [any HealthKitDataSource] = []

public var progress: Progress {
var totalUnitCount: Int64 = 0
var completedUnitCount: Int64 = 0
for dataSource in healthKitComponents {
if let bulkDataSource = dataSource as? BulkUploadSampleDataSource {
let individualProgress = bulkDataSource.progress
totalUnitCount += individualProgress.totalUnitCount
completedUnitCount += individualProgress.completedUnitCount
}
}
let macroProgress = Progress(totalUnitCount: totalUnitCount)
macroProgress.completedUnitCount = completedUnitCount
return macroProgress
}


private var healthKitSampleTypes: Set<HKSampleType> {
(initialHealthKitDataSourceDescriptions + healthKitDataSourceDescriptions).reduce(into: Set()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ public protocol HealthKitDataSourceDescription {
/// - Parameters:
/// - healthStore: The `HKHealthStore` instance that the queries should be performed on.
/// - standard: The `Standard` instance that is used in the software system.
func dataSources(healthStore: HKHealthStore, standard: any HealthKitConstraint) -> [HealthKitDataSource]
func dataSources(healthStore: HKHealthStore, standard: any Standard) -> [HealthKitDataSource]
}
Loading