Skip to content

Commit

Permalink
Support Strict Concurrency (#23)
Browse files Browse the repository at this point in the history
# Support Strict Concurrency

## ♻️ Current situation & Problem
This PR updates SpeziHealthKit to enable strict concurrency checking and
provides compatibility with Swift 6.

NSPredicate is not Sendable (see a discussion
[here](https://forums.swift.org/t/nspredicate-and-concurrency/70821)).
Therefore, `CollectSample` and `CollectSamples` cannot be `Sendable`.
These types are usually constructed in the configuration section which
runs on the MainActor. Therefore, instances of `CollectSample`, ... can
never leave the `@MainActor`. This is why this PR constraints a lot of
the implementation to the MainActor.

## ⚙️ Release Notes 
* Swift 6 Compatibility.


## 📚 Documentation
--


## ✅ Testing
--

## 📝 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
Supereg authored Aug 13, 2024
1 parent 1e9cb5a commit fbdec78
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 116 deletions.
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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
self.standard = standard
self.sampleType = sampleType
self.deliverySetting = deliverySetting

if predicate == nil {

if let predicate {
self.predicate = predicate
} 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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
break
}
}

func triggerManualDataSourceCollection() async {
guard !active else {
return
Expand All @@ -111,35 +107,34 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
case .background:
active = true
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
guard case let .success((sampleTypes, completionHandler)) = result else {
return
}

guard sampleTypes.contains(self.sampleType) else {
Logger.healthKit.warning("Received 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("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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
)
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 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
}
}
}


@MainActor
private func saveAnchor() {
if deliverySetting.saveAnchor {
guard let anchor,
Expand All @@ -185,7 +178,8 @@ final class HealthKitSampleDataSource: HealthKitDataSource {
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 @@ import Spezi


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 @@ extension HKHealthStore {
}

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()
}
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

0 comments on commit fbdec78

Please sign in to comment.