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

Support Strict Concurrency #23

Merged
merged 8 commits into from
Aug 13, 2024
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
2 changes: 2 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ jobs:
uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: SpeziHealthKit.xcresult TestApp.xcresult
secrets:
token: ${{ secrets.CODECOV_TOKEN }}
43 changes: 39 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@
// SPDX-License-Identifier: MIT
//

import class Foundation.ProcessInfo
import PackageDescription


#if swift(<6)
let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency")
#else
let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency")
#endif


let package = Package(
name: "SpeziHealthKit",
defaultLocalization: "en",
Expand All @@ -21,21 +29,48 @@ let package = Package(
.library(name: "SpeziHealthKit", targets: ["SpeziHealthKit"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.0")
],
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.0")
] + swiftLintPackage(),
targets: [
.target(
name: "SpeziHealthKit",
dependencies: [
.product(name: "Spezi", package: "Spezi")
]
],
swiftSettings: [
swiftConcurrency,
.enableUpcomingFeature("InferSendableFromCaptures")
],
plugins: [] + swiftLintPlugin()
),
.testTarget(
name: "SpeziHealthKitTests",
dependencies: [
.product(name: "XCTSpezi", package: "Spezi"),
.target(name: "SpeziHealthKit")
]
],
swiftSettings: [
swiftConcurrency
],
plugins: [] + swiftLintPlugin()
)
]
)


func swiftLintPlugin() -> [Target.PluginUsage] {
// Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app`
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")]
} else {
[]
}
}

func swiftLintPackage() -> [PackageDescription.Package.Dependency] {
if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil {
[.package(url: "https://github.com/realm/SwiftLint.git", from: "0.55.1")]
} else {
[]
}
}
6 changes: 4 additions & 2 deletions Sources/SpeziHealthKit/CollectSample/CollectSamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// SPDX-License-Identifier: MIT
//

import Foundation
import HealthKit
import Spezi

Expand All @@ -32,8 +33,7 @@ public struct CollectSamples: HealthKitDataSourceDescription {
self.predicate = predicate
self.deliverySetting = deliverySetting
}



public func dataSources(
healthStore: HKHealthStore,
standard: any HealthKitConstraint
Expand All @@ -49,3 +49,5 @@ public struct CollectSamples: HealthKitDataSourceDescription {
}
}
}

extension CollectSamples: Hashable {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// SPDX-License-Identifier: MIT
//

import HealthKit
@preconcurrency import HealthKit
import OSLog
import Spezi
import SwiftUI
Expand All @@ -19,19 +19,16 @@
let sampleType: HKSampleType
let predicate: NSPredicate?
let deliverySetting: HealthKitDeliverySetting
var active = false
private lazy var anchorUserDefaultsKey = UserDefaults.Keys.healthKitAnchorPrefix.appending(sampleType.identifier)
private lazy var anchor: HKQueryAnchor? = loadAnchor() {
@MainActor var active = false

@MainActor private lazy var anchorUserDefaultsKey = UserDefaults.Keys.healthKitAnchorPrefix.appending(sampleType.identifier)
@MainActor private lazy var anchor: HKQueryAnchor? = loadAnchor() {
didSet {
saveAnchor()
}
}

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

required init( // swiftlint:disable:this function_default_parameter_at_end
healthStore: HKHealthStore,
standard: any HealthKitConstraint,
sampleType: HKSampleType,
Expand All @@ -42,18 +39,17 @@
self.standard = standard
self.sampleType = sampleType
self.deliverySetting = deliverySetting

if predicate == nil {

if let predicate {
self.predicate = predicate

Check warning on line 44 in Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift#L44

Added line #L44 was not covered by tests
} else {
self.predicate = HKQuery.predicateForSamples(
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 All @@ -73,20 +69,20 @@
return date
}


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,
let .background(startSetting, _) where startSetting == .automatic:
Expand All @@ -95,7 +91,7 @@
break
}
}

func triggerManualDataSourceCollection() async {
guard !active else {
return
Expand All @@ -111,35 +107,34 @@
case .background:
active = true
try await healthStore.startBackgroundDelivery(for: [sampleType]) { result in
Task {
Supereg marked this conversation as resolved.
Show resolved Hide resolved
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
guard case let .success((sampleTypes, completionHandler)) = result else {
return

Check warning on line 111 in Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift#L111

Added line #L111 was not covered by tests
}

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

Check warning on line 115 in Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift#L115

Added line #L115 was not covered by tests
completionHandler()
return

Check warning on line 117 in Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift#L117

Added line #L117 was not covered by tests
}

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)")

Check warning on line 124 in Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/CollectSample/HealthKitSampleDataSource.swift#L124

Added line #L124 was not covered by tests
}

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




@MainActor
private func anchoredSingleObjectQuery() async throws {
let resultsAnchor = try await healthStore.anchoredSingleObjectQuery(
for: self.sampleType,
Expand All @@ -149,20 +144,17 @@
)
self.anchor = resultsAnchor
}


@MainActor
private func anchoredContinuousObjectQuery() 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)

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

for deletedObject in results.deletedObjects {
await standard.remove(sample: deletedObject)
}
Expand All @@ -174,7 +166,8 @@
}
}
}


@MainActor
private func saveAnchor() {
if deliverySetting.saveAnchor {
guard let anchor,
Expand All @@ -185,7 +178,8 @@
UserDefaults.standard.set(data, forKey: anchorUserDefaultsKey)
}
}


@MainActor
private func loadAnchor() -> HKQueryAnchor? {
guard deliverySetting.saveAnchor,
let userDefaultsData = UserDefaults.standard.data(forKey: anchorUserDefaultsKey),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
// SPDX-License-Identifier: MIT
//

import HealthKit
@preconcurrency import HealthKit
import Spezi


extension HKSample: Identifiable {
#if compiler(<6)
extension HKSample: Swift.Identifiable {}
#else
extension HKSample: @retroactive Identifiable {}
#endif


extension HKSample {
/// The `uuid` identifier.
public var id: UUID {
uuid
}
Expand All @@ -20,6 +28,7 @@ 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
@MainActor
func anchoredSingleObjectQuery(
for sampleType: HKSampleType,
using anchor: HKQueryAnchor? = nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@


extension HKHealthStore {
private static var activeObservations: [HKObjectType: Int] = [:]
private static let activeObservationsLock = NSLock()
private static nonisolated(unsafe) var activeObservations: [HKObjectType: Int] = [:]



@MainActor
func startBackgroundDelivery(
for sampleTypes: Set<HKSampleType>,
withPredicate predicate: NSPredicate? = nil,
observerQuery: @escaping (Result<(sampleTypes: Set<HKSampleType>, completionHandler: HKObserverQueryCompletionHandler), Error>) -> Void
observerQuery: @escaping @Sendable @MainActor (
Result<(sampleTypes: Set<HKSampleType>, completionHandler: HKObserverQueryCompletionHandler), Error>
) async -> Void
) async throws {
var queryDescriptors: [HKQueryDescriptor] = []
for sampleType in sampleTypes {
Expand All @@ -29,15 +32,24 @@
}

let observerQuery = HKObserverQuery(queryDescriptors: queryDescriptors) { query, samples, completionHandler, error in
// From https://developer.apple.com/documentation/healthkit/hkobserverquery/executing_observer_queries:
// "Whenever a matching sample is added to or deleted from the HealthKit store,
// the system calls the query’s update handler on the same background queue (but not necessarily the same thread)."
// So, the observerQuery has to be @Sendable!

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()
Task { @MainActor in
await observerQuery(.failure(error ?? NSError(domain: "Spezi HealthKit", code: -1)))
completionHandler()
}

Check warning on line 46 in Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift#L43-L46

Added lines #L43 - L46 were not covered by tests
return
}

observerQuery(.success((samples, completionHandler)))
Task { @MainActor in
await observerQuery(.success((samples, completionHandler)))
}
}

self.execute(observerQuery)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,4 @@ 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,
standard: any HealthKitConstraint
) {
_Concurrency.Task {
for sample in try await sampleQuery(for: sampleType, withPredicate: predicate) {
await standard.add(sample: sample)
}
}
}
// swiftlint:enable function_default_parameter_at_end
}
Loading
Loading