Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public struct SecurityRelatedModifier<V: ViewModifier> {

/// Create a new security related modifier declaration.
/// - Parameter wrappedValue: An auto-closure creating a new security related modifier each time it is used.
public init(wrappedValue: @autoclosure @escaping @Sendable () -> V) {
public init(wrappedValue: @autoclosure @escaping @MainActor @Sendable () -> V) {
self.modifierClosure = { @MainActor in
wrappedValue()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,12 @@ final class BluetoothManagerStorage: ValueObservable, Sendable {
extension BluetoothManagerStorage {
var stateSubscription: AsyncStream<BluetoothState> {
AsyncStream(BluetoothState.self) { continuation in
Task { @GroveBluetooth [self] in
let id = subscribe(continuation)
Task { @GroveBluetooth [weak self] in
guard let self else {
continuation.finish()
return
}
let id = self.subscribe(continuation)
continuation.onTermination = { @Sendable [weak self] _ in
guard let self = self else {
return
Expand Down
23 changes: 20 additions & 3 deletions Sources/GroveFoundation/LocalPreferences/LocalPreference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,27 @@ import Synchronization
@available(iOS 18, macOS 15, watchOS 11, *)
@propertyWrapper
public struct LocalPreference<T: SendableMetatype>: DynamicProperty, Sendable {
private enum UsageContext {
case swiftUI, external
}

private let key: LocalPreferenceKey<T>
private let store: LocalPreferencesStore
@State private var observer = UserDefaultsKeyObserver<T>()
private var usageContext: UsageContext = .external

private let externalObserver = UserDefaultsKeyObserver<T>()
@State private var swiftUIObserver = UserDefaultsKeyObserver<T>()

private var observer: UserDefaultsKeyObserver<T> {
switch usageContext {
case .swiftUI:
swiftUIObserver
case .external:
externalObserver
}
}

/// The current value of the local preference..
/// The current value of the local preference.
public var wrappedValue: T {
get {
_ = observer.viewUpdate
Expand Down Expand Up @@ -103,7 +119,8 @@ public struct LocalPreference<T: SendableMetatype>: DynamicProperty, Sendable {
}

@_documentation(visibility: internal)
nonisolated public func update() {
nonisolated public mutating func update() {
usageContext = .swiftUI
observer.configure(for: key, in: store)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ final class HealthKitSampleCollector<Sample: _HKSampleWithSampleType>: HealthDat
do {
if deliverySetting.continueInBackground {
// set up a background query
let queryInvalidator = try await healthStore.startBackgroundDelivery(for: [sampleType.hkSampleType]) { [weak self] result in
let queryInvalidator = try await healthStore.startBackgroundDelivery(for: sampleType.hkSampleType) { [weak self] result in
guard let self, self.isActive else {
// if the sample collector has been turned off, we don't want to process these.
return
Expand Down Expand Up @@ -135,7 +135,11 @@ final class HealthKitSampleCollector<Sample: _HKSampleWithSampleType>: HealthDat
task.cancel()
case .backgroundDelivery(let invalidator):
invalidator.invalidate()
healthStore.disableBackgroundDelivery(for: [sampleType.hkSampleType])
do {
try await healthStore.disableBackgroundDelivery(for: sampleType.hkSampleType)
} catch {
healthKit.logger.error("Failed to stop data collection for sample collector for \(self.sampleType.id): \(error)")
}
}
}

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

#if canImport(HealthKit)

import Grove
import class GroveFoundation.AsyncSemaphore
import HealthKit
import OSLog

Expand All @@ -31,19 +31,17 @@ extension HKHealthStore {
}
}

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

@MainActor @discardableResult
@MainActor
@discardableResult
func startBackgroundDelivery(
for sampleTypes: Set<HKSampleType>,
for sampleType: HKSampleType,
withPredicate predicate: NSPredicate? = nil,
updateHandler: @escaping @MainActor @Sendable (
Result<(sampleTypes: Set<HKSampleType>, completionHandler: HKObserverQueryCompletionHandler), any Error>
) async -> Void
) async throws -> BackgroundObserverQueryInvalidator {
let queryDescriptors: [HKQueryDescriptor] = sampleTypes
.flatMap { $0.effectiveObjectTypesForAuthorization }
let queryDescriptors: [HKQueryDescriptor] = sampleType
.effectiveObjectTypesForAuthorization
.compactMap { $0 as? HKSampleType }
.map { HKQueryDescriptor(sampleType: $0, predicate: predicate) }
let observerQuery = HKObserverQuery(queryDescriptors: queryDescriptors) { query, sampleTypes, completionHandler, error in
Expand Down Expand Up @@ -75,48 +73,61 @@ extension HKHealthStore {
await updateHandler(.success((sampleTypes, completionHandler)))
}
}
do {
var enabled = Set<HKSampleType>()
do {
for descriptor in queryDescriptors {
try await enableBackgroundDelivery(for: descriptor.sampleType)
enabled.insert(descriptor.sampleType)
}
} catch {
// If one sample type fails to enable, roll back the whole operation as best we can.
for sampleType in enabled {
try? await disableBackgroundDelivery(for: sampleType)
}
throw error
}
}
self.execute(observerQuery)
try await enableBackgroundDelivery(for: queryDescriptors.mapIntoSet(\.sampleType))
return .init(healthStore: self, query: observerQuery)
}
}


@available(iOS 18, macOS 15, watchOS 11, *)
extension HKHealthStore {
private static let backgroundDeliveryOperationsGate = AsyncSemaphore()
nonisolated(unsafe) private static var backgroundObserverCounts: [HKObjectType: Int] = [:]


func enableBackgroundDelivery(for objectTypes: Set<HKObjectType>) async throws {
var enabledObjectTypes: Set<HKObjectType> = []
do {
for objectType in objectTypes {
try await self.enableBackgroundDelivery(for: objectType, frequency: .immediate)
enabledObjectTypes.insert(objectType)
Self.activeObservationsLock.withLock {
Self.activeObservations[objectType, default: 0] += 1
}
}
} catch {
HealthKit.logger.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 enableBackgroundDelivery(for objectType: HKObjectType) async throws {
await Self.backgroundDeliveryOperationsGate.wait()
defer {
Self.backgroundDeliveryOperationsGate.signal()
}
if Self.backgroundObserverCounts[objectType, default: 0] > 0 {
// Already registered, so we only need to increment the count.
Self.backgroundObserverCounts[objectType, default: 0] += 1
return
}
try await self.HealthKit::enableBackgroundDelivery(for: objectType, frequency: .immediate)
Self.backgroundObserverCounts[objectType] = 1
}


func disableBackgroundDelivery(
for objectTypes: Set<HKObjectType>
) {
Self.activeObservationsLock.withLock {
for objectType in objectTypes {
if let activeObservation = HKHealthStore.activeObservations[objectType] {
let newActiveObservation = activeObservation - 1
if newActiveObservation <= 0 {
Self.activeObservations[objectType] = nil
Task { @MainActor in
try? await self.disableBackgroundDelivery(for: objectType)
}
} else {
Self.activeObservations[objectType] = newActiveObservation
}
}
}
func disableBackgroundDelivery(for objectType: HKObjectType) async throws {
await Self.backgroundDeliveryOperationsGate.wait()
defer {
Self.backgroundDeliveryOperationsGate.signal()
}
let numActiveObservers = HKHealthStore.backgroundObserverCounts[objectType, default: 0]
guard numActiveObservers > 0 else {
return
}
if numActiveObservers == 1 {
try await self.HealthKit::disableBackgroundDelivery(for: objectType)
}
HKHealthStore.backgroundObserverCounts[objectType] = numActiveObservers - 1
}
}

Expand Down
74 changes: 38 additions & 36 deletions Sources/GroveStudy/Study Manager/StudyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable {

/// Runs the identifier rewrite before anything reads the scheduler store.
///
/// Synchronous and outside `configure()`'s `Task` block on purpose: that block's `catch` returns
/// without logging, and the rewrite has to finish before tasks are registered against it.
/// Synchronous and outside `configure()`'s `Task` block on purpose: the rewrite has to finish
/// before tasks are registered against it.
private func migrateLegacyTaskIdentifiersIfNeeded() {
do {
if try migrateLegacyTaskIdentifiers() {
Expand All @@ -159,47 +159,50 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable {


@_documentation(visibility: internal)
public func configure() { // swiftlint:disable:this function_body_length
public func configure() {
migrateLegacyTaskIdentifiersIfNeeded()
typealias Task = Swift::Task
Task { @MainActor [self] in
let enrollments: [StudyEnrollment]
Swift::Task { @MainActor in
do {
enrollments = try modelContext.fetch(FetchDescriptor<StudyEnrollment>())
try registerStudyTasksWithScheduler(for: enrollments)
try await setupStudyBackgroundComponents(for: enrollments)
try removeOrphanedTasks()
try removeOrphanedStudyBundles()
try await _configure()
} catch {
return
logger.error("configure failed: \(error)")
}
#if targetEnvironment(simulator)
if autosaveTask == nil {
autosaveTask = Task.detached {
while true {
await MainActor.run {
try? self.modelContext.save()
}
try? await Task.sleep(for: .seconds(0.25))
}
}


@MainActor
private func _configure() async throws { // swiftlint:disable:this function_body_length
let enrollments = try modelContext.fetch(FetchDescriptor<StudyEnrollment>())
try registerStudyTasksWithScheduler(for: enrollments)
try await setupStudyBackgroundComponents(for: enrollments)
try removeOrphanedTasks()
try removeOrphanedStudyBundles()
#if targetEnvironment(simulator)
if autosaveTask == nil {
autosaveTask = Swift::Task {
while true {
await MainActor.run {
try? self.modelContext.save()
}
try? await Swift::Task.sleep(for: .seconds(0.25))
}
}
#endif
outcomesObserverToken = scheduler.observeNewOutcomes { [weak self] outcome in
guard let self,
let studyContext = outcome.task.studyContext,
let studyBundle = self.studyEnrollments.first(where: { $0.studyId == studyContext.studyId })?.studyBundle else {
return
}
self.handleStudyLifecycleEvent(
.completedTask(componentId: studyContext.componentId),
for: studyBundle,
at: .now
)
}
#endif
outcomesObserverToken = scheduler.observeNewOutcomes { [weak self = self] outcome in
guard let self,
let studyContext = outcome.task.studyContext,
let studyBundle = self.studyEnrollments.first(where: { $0.studyId == studyContext.studyId })?.studyBundle else {
return
}
self.handleStudyLifecycleEvent(
.completedTask(componentId: studyContext.componentId),
for: studyBundle,
at: .now
)
}

Task { [weak self] in
Swift::Task { [weak self] in
let localeUpdates = NotificationCenter.default.notifications(named: NSLocale.currentLocaleDidChangeNotification)
for await _ in localeUpdates {
guard let self else {
Expand All @@ -211,7 +214,7 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable {
}
}
#if canImport(UIKit) && !os(watchOS)
Task { [weak self] in
Swift::Task { [weak self] in
let timeUpdates = NotificationCenter.default.notifications(named: UIApplication.significantTimeChangeNotification)
for await _ in timeUpdates {
guard let self else {
Expand All @@ -221,7 +224,6 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable {
}
}
#endif

do {
try fixTaskContextAndDuplicateVersions()
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import SwiftUI
@available(iOS 18, macOS 15, watchOS 11, *)
struct MarkdownViewImageProvider: ImageProvider {
private struct ImageLoadingView: View {
let url: URL?
private let url: URL?
@State private var image: Image?

var body: some View {
Expand Down
Loading