Skip to content
Draft
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
36 changes: 5 additions & 31 deletions BeeKit/Managers/HealthStoreManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,22 @@ import OSLog
/// This does mean users who have very little buffer, and are not regularly unlocking their phone, may erroneously derail. There is nothing we
/// can do about this.
static let daysToUpdateOnChangeNotification = 7

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea where all these whitespace changes came from? I would have guessed swift format, but I've confirmed this doesn't happen for me when I trigger reformatting of this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I am not sure and I noticed too and have not looked into it further, yet.

private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "HealthStoreManager")

private let goalManager: GoalManager

// TODO: Public for now to use from config
public let healthStore = HKHealthStore()

/// The Connection objects responsible for updating goals based on their healthkit metrics
/// Dictionary key is the goal id, as this is stable across goal renames
private nonisolated(unsafe) var monitors: [String: HealthKitMetricMonitor] = [:]

/// Protect concurrent modifications to the connections dictionary
private nonisolated let monitorsSemaphore = DispatchSemaphore(value: 1)

init(goalManager: GoalManager, container: NSPersistentContainer) {
self.goalManager = goalManager
self.modelContainer = container
let context = container.newBackgroundContext()
context.name = "HealthStoreManager"
self.modelExecutor = .init(context: context)
}

/// Request acess to HealthKit data for the supplied metric
///
/// This function will throw an exception on a major failure. However, it will return silently if the user chooses
Expand All @@ -51,15 +44,13 @@ import OSLog
logger.notice("requestAuthorization for \(metric.databaseString, privacy: .public)")
try await self.healthStore.requestAuthorization(toShare: Set(), read: [metric.sampleType()])
}

/// Start listening for background updates to the supplied goal if we are not already doing so
public func ensureUpdatesRegularly(goalID: NSManagedObjectID) async throws {
let goal = try modelContext.existingObject(with: goalID) as! Goal
modelContext.refresh(goal, mergeChanges: false)
guard let metricName = goal.healthKitMetric else { return }
try await self.ensureUpdatesRegularly(metricNames: [metricName], removeMissing: false)
}

/// Ensure we have background update listeners for all known goals such that they
/// will be updated any time the health data changes.
public func ensureGoalsUpdateRegularly() async throws {
Expand All @@ -68,21 +59,17 @@ import OSLog
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
return try await ensureUpdatesRegularly(metricNames: metrics, removeMissing: true)
}

/// Install observers for any goals we currently have permission to read
///
/// This function will never show a permissions dialog - instead it will not update for
/// metrics where we do not have permission.
public nonisolated func silentlyInstallObservers(context: NSManagedObjectContext) {
logger.notice("Silently installing observer queries")

guard let goals = goalManager.staleGoals(context: context) else { return }
let metrics = goals.compactMap { $0.healthKitMetric }.filter { $0 != "" }
let monitors = updateKnownMonitors(metricNames: metrics, removeMissing: true)

for monitor in monitors { monitor.registerObserverQuery() }
}

/// Immediately update the supplied goal based on HealthKit's data record
///
/// Any existing beeminder records for the date range provided will be updated or deleted.
Expand All @@ -95,16 +82,13 @@ import OSLog
try await updateWithRecentData(goal: goal, days: days)
try await goalManager.refreshGoal(goalID)
}

/// Immediately update all known goals based on HealthKit's data record
public func updateAllGoalsWithRecentData(days: Int) async throws {
logger.notice("Updating all goals with recent day for last \(days, privacy: .public) days")

// We must create this context in a backgrounfd thread as it will be used in background threads
modelContext.refreshAllObjects()
guard let goals = goalManager.staleGoals(context: modelContext) else { return }
let goalsWithHealthData = goals.filter { $0.healthKitMetric != nil && $0.healthKitMetric != "" }

try await withThrowingTaskGroup(of: Void.self) { group in
for goal in goalsWithHealthData {
let goalID = goal.objectID
Expand All @@ -118,28 +102,20 @@ import OSLog
}
try await goalManager.refreshGoals()
}

private func ensureUpdatesRegularly(metricNames: any Sequence<String>, removeMissing: Bool) async throws {
let monitors = updateKnownMonitors(metricNames: metricNames, removeMissing: removeMissing)

var permissions = Set<HKObjectType>()
for monitor in monitors { permissions.insert(monitor.metric.permissionType()) }
if permissions.count > 0 {
try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions)

}

if permissions.count > 0 { try await self.healthStore.requestAuthorization(toShare: Set(), read: permissions) }
try await withThrowingTaskGroup(of: Void.self) { group in
for monitor in monitors { group.addTask { try await monitor.setupHealthKit() } }
try await group.waitForAll()
}
}

private nonisolated func updateKnownMonitors(metricNames: any Sequence<String>, removeMissing: Bool)
-> [HealthKitMetricMonitor]
{
monitorsSemaphore.wait()

for metricName in metricNames {
if monitors[metricName] == nil {
guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == metricName }) else {
Expand All @@ -155,7 +131,6 @@ import OSLog
)
}
}

if removeMissing {
for (metricName, monitor) in monitors {
if !metricNames.contains(metricName) {
Expand All @@ -164,13 +139,10 @@ import OSLog
}
}
}

let requestedMonitors = metricNames.compactMap { monitors[$0] }

monitorsSemaphore.signal()
return requestedMonitors
}

private func updateGoalsForMetricChange(metricName: String, metric: HealthKitMetric) async {
do {
modelContext.refreshAllObjects()
Expand All @@ -180,13 +152,11 @@ import OSLog
logger.notice("Received an update for metric \(metricName, privacy: .public) but no goals using it")
return
}

for goal in goalsForMetric {
try await self.updateWithRecentData(goal: goal, days: HealthStoreManager.daysToUpdateOnChangeNotification)
}
} catch { logger.error("Error updating goals for metric change: \(error, privacy: .public)") }
}

private func updateWithRecentData(goal: Goal, days: Int) async throws {
guard let metric = HealthKitConfig.metrics.first(where: { $0.databaseString == goal.healthKitMetric }) else {
throw HealthKitError("No metric found for goal \(goal.slug) with metric \(goal.healthKitMetric ?? "nil")")
Expand All @@ -206,5 +176,9 @@ import OSLog
goalID: goal.objectID,
healthKitDataPoints: nonZeroDataPoints
)

let goal = try self.modelContext.existingObject(with: goal.objectID) as! Goal
goal.lastSyncedWithHealthKitLocal = Date()
try self.modelContext.save()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>BeeminderModel3.xcdatamodel</string>
<string>BeeminderModel4.xcdatamodel</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25C56" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="DataPoint" representedClassName="DataPoint" syncable="YES">
<attribute name="comment" optional="YES" attributeType="String"/>
<attribute name="daystampRaw" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="isDummy" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isInitial" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
<userInfo>
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
</userInfo>
</attribute>
<attribute name="requestid" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="value" attributeType="Decimal" defaultValueString="0.0"/>
<relationship name="goal" maxCount="1" deletionRule="Nullify" destinationEntity="Goal" inverseName="data" inverseEntity="Goal"/>
</entity>
<entity name="Goal" representedClassName="Goal" syncable="YES" coreSpotlightDisplayNameExpression="slug">
<attribute name="alertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="autodata" optional="YES" attributeType="String"/>
<attribute name="autodataConfig" optional="YES" attributeType="Transformable" customClassName="NSDictionary"/>
<attribute name="colorkey" attributeType="String" defaultValueString="gray"/>
<attribute name="deadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="dueBy" optional="YES" attributeType="Transformable" valueTransformerName="DueByTableValueTransformer" customClassName="NSDictionary"/>
<attribute name="graphUrl" attributeType="String"/>
<attribute name="healthKitMetric" optional="YES" attributeType="String"/>
<attribute name="hhmmFormat" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="initDay" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastSyncedWithHealthKitLocal" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastTouch" attributeType="String"/>
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
<userInfo>
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
</userInfo>
</attribute>
<attribute name="leadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="limSum" attributeType="String"/>
<attribute name="pledge" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="queued" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="safeBuf" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="safeSum" attributeType="String"/>
<attribute name="slug" attributeType="String" spotlightIndexingEnabled="YES"/>
<attribute name="thumbUrl" attributeType="String"/>
<attribute name="title" attributeType="String" spotlightIndexingEnabled="YES"/>
<attribute name="todayta" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="urgencyKey" attributeType="String"/>
<attribute name="useDefaults" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="won" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="yAxis" attributeType="String"/>
<relationship name="data" toMany="YES" deletionRule="Cascade" destinationEntity="DataPoint" inverseName="goal" inverseEntity="DataPoint"/>
<relationship name="owner" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="User" inverseName="goals" inverseEntity="User"/>
<relationship name="recentData" toMany="YES" deletionRule="Nullify" destinationEntity="DataPoint"/>
</entity>
<entity name="User" representedClassName=".User" syncable="YES">
<attribute name="deadbeat" attributeType="Boolean" usesScalarValueType="NO"/>
<attribute name="defaultAlertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="defaultDeadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="defaultLeadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastFetchedModelVersionLocal" optional="YES" attributeType="String"/>
<attribute name="lastUpdatedLocal" optional="YES" attributeType="Date" defaultDateTimeInterval="-978307200" usesScalarValueType="NO" elementID="lastUpdatedLocal">
<userInfo>
<entry key="renamingIdentifier" value="lastModifiedLocal"/>
</userInfo>
</attribute>
<attribute name="timezone" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" defaultDateTimeInterval="-978278400" usesScalarValueType="YES"/>
<attribute name="username" attributeType="String"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="owner" inverseEntity="Goal"/>
</entity>
</model>
3 changes: 3 additions & 0 deletions BeeKit/Model/Goal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import SwiftyJSON

/// The last time this record in the CoreData store was updated
@NSManaged public var lastUpdatedLocal: Date
@NSManaged public var lastSyncedWithHealthKitLocal: Date?

public init(
context: NSManagedObjectContext,
Expand Down Expand Up @@ -119,6 +120,7 @@ import SwiftyJSON
self.yAxis = yAxis

lastUpdatedLocal = Date()
lastSyncedWithHealthKitLocal = nil
}

public init(context: NSManagedObjectContext, owner: User, json: JSON) {
Expand All @@ -127,6 +129,7 @@ import SwiftyJSON
self.owner = owner
self.id = json["id"].string!

lastSyncedWithHealthKitLocal = nil
self.updateToMatch(json: json)
}

Expand Down
22 changes: 15 additions & 7 deletions BeeSwift/GoalView/GoalViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable

fileprivate var scrollView = UIScrollView()
fileprivate var submitButton = BSButton()
private let pullToRefreshView = PullToRefreshView()
fileprivate let headerWidth = Double(1.0 / 3.0)

// date corresponding to the datapoint to be created
Expand Down Expand Up @@ -308,14 +309,8 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable
}

if self.goal.isDataProvidedAutomatically {
let pullToRefreshView = PullToRefreshView()
scrollView.addSubview(pullToRefreshView)

if self.goal.isLinkedToHealthKit {
pullToRefreshView.message = "Pull down to synchronize with Apple Health"
} else {
pullToRefreshView.message = "Pull down to update"
}
refreshPullDown()

pullToRefreshView.snp.makeConstraints { (make) in
make.top.equalTo(self.datapointTableController.view.snp.bottom).offset(elementSpacing)
Expand Down Expand Up @@ -396,6 +391,19 @@ class GoalViewController: UIViewController, UIScrollViewDelegate, DatapointTable
self.countdownLabel.textColor = self.goal.countdownColor
self.countdownLabel.text = self.goal.capitalSafesum()
}
private func refreshPullDown() {
let lastSynced: String = {
guard let lastSyncedWithHealthKit = goal.lastSyncedWithHealthKitLocal else { return "not yet" }
let isSameDay = Calendar.autoupdatingCurrent.isDate(lastSyncedWithHealthKit, inSameDayAs: .now)
let dateStyle: Date.FormatStyle.DateStyle = isSameDay ? .omitted : .numeric
return lastSyncedWithHealthKit.formatted(date: dateStyle, time: .shortened)
}()
if self.goal.isLinkedToHealthKit {
pullToRefreshView.message = "Pull down to synchronize with Apple Health" + "\n" + "Last synced: \(lastSynced)"
} else {
pullToRefreshView.message = "Pull down to update"
}
}

@objc func goalImageTapped() {
self.goalImageScrollView.setZoomScale(self.goalImageScrollView.zoomScale == 1.0 ? 2.0 : 1.0, animated: true)
Expand Down
Loading