Skip to content

Commit 86eba04

Browse files
authored
HealthKit integration uses own contexts for CoreData (#470)
We want to make sure HealthKit integration with CoreData is thread safe and also doesn't risk using contexts which are no longer valid. Do this by making HealthKit related code use its own contexts, and minimize how much code it holds onto. Testing: Ran on device Note: Current build is known to be quite crashy
1 parent 6d27cca commit 86eba04

16 files changed

+244
-227
lines changed

BeeKit/HeathKit/GoalHealthKitConnection.swift

Lines changed: 0 additions & 105 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Foundation
2+
import SwiftyJSON
3+
import HealthKit
4+
import OSLog
5+
6+
/// Monitor a specific HealthKit metric and report when it changes
7+
class HealthKitMetricMonitor {
8+
static let minimumIntervalBetweenObserverUpdates : TimeInterval = 5 // Seconds
9+
10+
private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "HealthKitMetricMonitor")
11+
12+
private let healthStore: HKHealthStore
13+
public let metric : HealthKitMetric
14+
private let onUpdate: (HealthKitMetric) async -> Void
15+
16+
private var observerQuery : HKObserverQuery? = nil
17+
private var lastObserverUpdate : Date? = nil
18+
19+
init(healthStore: HKHealthStore, metric: HealthKitMetric, onUpdate: @escaping (HealthKitMetric) async -> Void) {
20+
self.healthStore = healthStore
21+
self.metric = metric
22+
self.onUpdate = onUpdate
23+
}
24+
25+
/// Perform an initial sync and register for changes to the relevant metric so the goal can be kept up to date
26+
public func setupHealthKit() async throws {
27+
try await healthStore.enableBackgroundDelivery(for: metric.sampleType(), frequency: HKUpdateFrequency.immediate)
28+
registerObserverQuery()
29+
}
30+
31+
/// Register for changes to the relevant metric. Assumes permission and background delivery already enabled
32+
public func registerObserverQuery() {
33+
guard observerQuery == nil else {
34+
return
35+
}
36+
logger.notice("Registering observer query for \(self.metric.databaseString, privacy: .public)")
37+
38+
let query = HKObserverQuery(sampleType: metric.sampleType(), predicate: nil, updateHandler: { (query, completionHandler, error) in
39+
self.logger.notice("ObserverQuery response for \(self.metric.databaseString, privacy: .public)")
40+
41+
guard error == nil else {
42+
self.logger.error("ObserverQuery for \(self.metric.databaseString, privacy: .public) was error: \(error, privacy: .public)")
43+
return
44+
}
45+
46+
if let lastUpdate = self.lastObserverUpdate {
47+
if Date().timeIntervalSince(lastUpdate) < HealthKitMetricMonitor.minimumIntervalBetweenObserverUpdates {
48+
self.logger.notice("Ignoring update to \(self.metric.databaseString, privacy: .public) due to recent previous update")
49+
completionHandler()
50+
return
51+
}
52+
}
53+
self.lastObserverUpdate = Date()
54+
55+
Task {
56+
await self.onUpdate(self.metric)
57+
completionHandler()
58+
}
59+
})
60+
healthStore.execute(query)
61+
62+
// Once we have successfully executed the query then keep track of it to stop later
63+
self.observerQuery = query
64+
}
65+
66+
/// Remove any registered queries to prevent further updates
67+
public func unregisterObserverQuery() {
68+
guard let query = self.observerQuery else {
69+
logger.warning("unregisterObserverQuery(\(self.metric.databaseString, privacy: .public)): Attempted to unregister query when not registered")
70+
return
71+
}
72+
logger.notice("Unregistering observer query for \(self.metric.databaseString, privacy: .public)")
73+
74+
healthStore.stop(query)
75+
}
76+
}
77+

BeeKit/Managers/GoalManager.swift

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,14 @@ public actor GoalManager {
6464
await performPostGoalUpdateBookkeeping()
6565
}
6666

67-
public func refreshGoal(_ goal: Goal) async throws {
67+
public func refreshGoal(_ goalID: NSManagedObjectID) async throws {
68+
let context = container.newBackgroundContext()
69+
let goal = try context.existingObject(with: goalID) as! Goal
70+
6871
let responseObject = try await requestManager.get(url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)?datapoints_count=5", parameters: nil)
6972
let goalJSON = JSON(responseObject!)
70-
let goalId = goalJSON["id"].stringValue
7173

72-
let context = container.newBackgroundContext()
73-
let request = NSFetchRequest<Goal>(entityName: "Goal")
74-
request.predicate = NSPredicate(format: "id == %@", goalId)
75-
if let existingGoal = try context.fetch(request).first {
76-
existingGoal.updateToMatch(json: goalJSON)
77-
} else {
78-
logger.warning("Found no existing goal in CoreData store when refreshing \(goal.slug) with id \(goal.id)")
79-
}
74+
goal.updateToMatch(json: goalJSON)
8075
try context.save()
8176

8277
await performPostGoalUpdateBookkeeping()
@@ -150,7 +145,8 @@ public actor GoalManager {
150145
do {
151146
while true {
152147
// If there are no queued goals then we are complete and can stop checking
153-
guard let user = currentUserManager.user() else { break }
148+
let context = container.newBackgroundContext()
149+
guard let user = currentUserManager.user(context: context) else { break }
154150
let queuedGoals = user.goals.filter { $0.queued }
155151
if queuedGoals.isEmpty {
156152
break
@@ -160,7 +156,8 @@ public actor GoalManager {
160156
try await withThrowingTaskGroup(of: Void.self) { group in
161157
for goal in queuedGoals {
162158
group.addTask {
163-
try await self.refreshGoal(goal)
159+
// TODO: We don't really need to reload the goal in a new context here
160+
try await self.refreshGoal(goal.objectID)
164161
}
165162
}
166163
try await group.waitForAll()

0 commit comments

Comments
 (0)