From a4ed4df7560f38b805e00f4200485c051307c876 Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 8 Jul 2026 07:56:19 -0400 Subject: [PATCH] Make Apple Health workout recording watch-only The phone no longer writes estimated Health workouts: the watch, which runs the live session, is the sole recorder. Replaces WorkoutHealthWriter with a WorkoutHealthDeleter that only removes a legacy phone-estimate workout when its record is deleted here, drops the MET calorie table and the phone's write/read Health scopes, and keeps phoneEstimate decodable for existing documents. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3 --- Shared/HealthKit/HealthKitMapping.swift | 21 +- Shared/Model/Enums.swift | 25 +-- Workouts/AppServices.swift | 13 +- Workouts/HealthKit/WorkoutHealthDeleter.swift | 78 +++++++ Workouts/HealthKit/WorkoutHealthWriter.swift | 203 ------------------ Workouts/HealthKit/WorkoutLauncher.swift | 5 +- Workouts/Sync/SyncEngine.swift | 12 -- .../Views/WorkoutLogs/WorkoutLogsView.swift | 2 +- .../WorkoutLogs/WorkoutSummaryView.swift | 4 +- 9 files changed, 102 insertions(+), 261 deletions(-) create mode 100644 Workouts/HealthKit/WorkoutHealthDeleter.swift delete mode 100644 Workouts/HealthKit/WorkoutHealthWriter.swift diff --git a/Shared/HealthKit/HealthKitMapping.swift b/Shared/HealthKit/HealthKitMapping.swift index 8de7885..716651d 100644 --- a/Shared/HealthKit/HealthKitMapping.swift +++ b/Shared/HealthKit/HealthKitMapping.swift @@ -9,12 +9,13 @@ import Foundation import HealthKit // Bridges the app's HealthKit-free domain types to HealthKit, and centralizes the -// authorization type sets so the watch (which records the session) and the phone -// (which writes an estimated workout when no watch ran) request consistent scopes. +// authorization type sets. The watch records the live session (shares the workout + +// energy, reads HR back); the phone only ever deletes a legacy phone-estimate workout +// it previously authored, so it needs nothing but workout-share. extension WorkoutActivityType { - /// The HealthKit activity type used to tag a saved workout, so it lands in the - /// right Apple Health / Fitness category and credits the rings appropriately. + /// The HealthKit activity type used to tag the watch's saved workout, so it lands + /// in the right Apple Health / Fitness category and credits the rings appropriately. var hkActivityType: HKWorkoutActivityType { switch self { case .traditionalStrength: .traditionalStrengthTraining @@ -52,16 +53,10 @@ enum WorkoutHealthAuthorization { HKCharacteristicType(.dateOfBirth), // → max HR for zone tracking ] - /// Phone: writes an estimated workout when no watch session ran; reads body - /// measurements to size the calorie estimate (and HR to display watch data). + /// Phone: shares only the workout type, the minimum HealthKit requires to delete a + /// legacy phone-estimate `HKWorkout` this app authored. The phone reads nothing and + /// no longer writes any workouts of its own (recording is watch-only). static let phoneShare: Set = [ .workoutType(), - HKQuantityType(.activeEnergyBurned), - ] - static let phoneRead: Set = [ - HKQuantityType(.bodyMass), - HKQuantityType(.heartRate), - HKCharacteristicType(.dateOfBirth), - HKCharacteristicType(.biologicalSex), ] } diff --git a/Shared/Model/Enums.swift b/Shared/Model/Enums.swift index 92e12ff..f2ef841 100644 --- a/Shared/Model/Enums.swift +++ b/Shared/Model/Enums.swift @@ -35,9 +35,8 @@ enum LoadType: Int, CaseIterable, Codable, Sendable { } } -/// The kind of training a split represents — used to tag the Apple Health workout -/// so it lands in the right category (and credits the rings correctly), and to pick -/// a MET value when the phone has to estimate calories without watch sensor data. +/// The kind of training a split represents — used to tag the watch's Apple Health +/// workout so it lands in the right category (and credits the rings correctly). /// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType` /// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum /// HealthKit-free. @@ -70,23 +69,13 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable { case .cycling: "figure.outdoor.cycle" } } - - /// Metabolic-equivalent value used only for the phone-side calorie estimate - /// (`kcal ≈ MET × bodyweightKg × hours`) when no watch metrics are available. - var met: Double { - switch self { - case .traditionalStrength: 5.0 - case .functionalStrength: 4.0 - case .hiit: 8.0 - case .coreTraining: 4.0 - case .cardio: 7.0 - case .cycling: 7.5 - } - } } -/// Where a workout's health metrics came from: real watch sensors, or a phone-side -/// estimate written when no watch session ran. Persisted in `WorkoutMetrics`. +/// Where a workout's health metrics came from: real watch sensors, or — for records +/// written by app versions up to 2.x — a phone-side MET estimate made when no watch +/// session ran. The phone no longer writes estimates (recording is watch-only), but +/// `phoneEstimate` is retained so legacy documents on disk still decode. Persisted in +/// `WorkoutMetrics`. enum MetricSource: String, Codable, Sendable { case watch case phoneEstimate diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 8d5b605..d41512c 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -12,7 +12,7 @@ final class AppServices { let syncEngine: SyncEngine let watchBridge: PhoneConnectivityBridge let workoutLauncher = WorkoutLauncher() - let workoutHealthWriter: WorkoutHealthWriter + let workoutHealthDeleter = WorkoutHealthDeleter() /// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted. let liveRunState: LiveRunState @@ -26,10 +26,6 @@ final class AppServices { let liveRunState = LiveRunState() self.liveRunState = liveRunState self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState) - let writer = WorkoutHealthWriter(container: container, syncEngine: syncEngine) - self.workoutHealthWriter = writer - syncEngine.onWorkoutCompleted = { [weak writer] doc in writer?.workoutCompleted(doc) } - syncEngine.onWatchMetricsArrived = { [weak writer] id in writer?.watchMetricsArrived(workoutID: id) } #if DEBUG if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) } #endif @@ -43,10 +39,9 @@ final class AppServices { guard let self else { return } await self.syncEngine.connect() self.watchBridge.activate() - // Past the iCloud gate and cache is reconciled: request Health access and - // back-fill any recently-completed workout that never reached Health. - self.workoutHealthWriter.authorizeIfNeeded() - self.workoutHealthWriter.sweepRecentlyCompleted() + // Past the iCloud gate: request the workout-share scope so the user can still + // delete legacy phone-estimate workouts from Health when deleting them here. + self.workoutHealthDeleter.authorizeIfNeeded() } bootstrapTask = task await task.value diff --git a/Workouts/HealthKit/WorkoutHealthDeleter.swift b/Workouts/HealthKit/WorkoutHealthDeleter.swift new file mode 100644 index 0000000..1cd050b --- /dev/null +++ b/Workouts/HealthKit/WorkoutHealthDeleter.swift @@ -0,0 +1,78 @@ +// +// WorkoutHealthDeleter.swift +// Workouts +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation +import HealthKit +import os + +/// Removes a workout's Apple Health record when the user deletes that workout in the app. +/// +/// Apple Health recording is **watch-only**: the watch's `HKWorkoutSession` +/// (`WorkoutSessionManager`) is the sole recorder, and the phone writes nothing to +/// Health. This helper exists purely so that *legacy* phone-estimate workouts — the +/// MET-based `HKWorkout`s that app versions up to 2.x wrote from the phone — can still +/// be pruned from Health when the user deletes them here. +/// +/// HealthKit only permits deleting samples this app authored, so those phone estimates +/// delete cleanly while watch-recorded workouts (authored by the watch app's separate +/// HealthKit source) are refused — the delete flow directs the user to the Health app. +@MainActor +final class WorkoutHealthDeleter { + private let healthStore = HKHealthStore() + private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "health") + + private var isScreenshotRun: Bool { + #if DEBUG + return ScreenshotSeed.isActive + #else + return false + #endif + } + + /// Request the single scope a delete needs: share (write) access to the workout + /// type. HealthKit requires write authorization for a sample's type in order to + /// delete a sample this app authored. No read scopes — the phone reads nothing + /// from Health (heart rate and calories come from the watch over WatchConnectivity). + func authorizeIfNeeded() { + guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } + healthStore.requestAuthorization( + toShare: WorkoutHealthAuthorization.phoneShare, + read: [] + ) { _, _ in } + } + + /// Best-effort removal of a workout's Health record, on the user's request when + /// deleting the workout in the app. Only samples this app authored (legacy phone + /// estimates) can be deleted; watch-recorded workouts are refused by HealthKit and + /// remain in Health until removed there. + func deleteFromHealth(uuidString: String) { + guard HKHealthStore.isHealthDataAvailable(), let uuid = UUID(uuidString: uuidString) else { return } + guard healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { + log.notice("Skipping Health delete: workout sharing not authorized") + return + } + let healthStore = self.healthStore + let log = self.log + let query = HKSampleQuery( + sampleType: .workoutType(), + predicate: HKQuery.predicateForObject(with: uuid), + limit: 1, sortDescriptors: nil + ) { _, samples, error in + if let error { + log.error("Health workout lookup failed: \(error.localizedDescription, privacy: .public)") + return + } + guard let workout = samples?.first else { return } + healthStore.delete([workout]) { _, error in + if let error { + log.error("Health workout delete failed: \(error.localizedDescription, privacy: .public)") + } + } + } + healthStore.execute(query) + } +} diff --git a/Workouts/HealthKit/WorkoutHealthWriter.swift b/Workouts/HealthKit/WorkoutHealthWriter.swift deleted file mode 100644 index 3f94aad..0000000 --- a/Workouts/HealthKit/WorkoutHealthWriter.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// WorkoutHealthWriter.swift -// Workouts -// -// Copyright 2025 Rouslan Zenetl. All Rights Reserved. -// - -import Foundation -import HealthKit -import SwiftData - -/// Writes an *estimated* `HKWorkout` from the phone when a workout completes without -/// a watch session having recorded it — so phone-only workouts still feed the Move -/// ring. Mirrors what Apple's own "Add Workout" does: energy ≈ MET × bodyweight × -/// duration, with bodyweight read from Health. -/// -/// Dedupe is the whole game: a workout must be written to Health by the watch OR the -/// phone, never both. The signals are `WorkoutMetrics.source`/`healthKitWorkoutUUID` -/// (already present → skip) plus a debounce that a late watch update cancels. -@MainActor -final class WorkoutHealthWriter { - private let container: ModelContainer - private let healthStore = HKHealthStore() - private weak var syncEngine: SyncEngine? - - /// Scheduled estimate tasks keyed by workout id — for debounce + cancellation. - private var pending: [String: Task] = [:] - - /// Grace window before writing, so a watch session that finishes a beat later can - /// cancel us (its metrics arrive over WatchConnectivity and call `watchMetricsArrived`). - private let debounce: Duration = .seconds(10) - - /// Default bodyweight when Health has none, in kg (~154 lb). - private let fallbackBodyMassKg = 70.0 - - init(container: ModelContainer, syncEngine: SyncEngine) { - self.container = container - self.syncEngine = syncEngine - } - - private var context: ModelContext { container.mainContext } - - private var isScreenshotRun: Bool { - #if DEBUG - return ScreenshotSeed.isActive - #else - return false - #endif - } - - /// Request the scopes the estimate needs: share workouts + active energy, read - /// body measurements (to size the estimate) and heart rate (to show watch data). - func authorizeIfNeeded() { - guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } - healthStore.requestAuthorization( - toShare: WorkoutHealthAuthorization.phoneShare, - read: WorkoutHealthAuthorization.phoneRead - ) { _, _ in } - } - - /// A workout was persisted as completed. If nothing has written it to Health yet, - /// schedule an estimated write after the debounce. - func workoutCompleted(_ doc: WorkoutDocument) { - guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } - guard doc.status == WorkoutStatus.completed.rawValue, doc.end != nil else { return } - // Already recorded (watch session, or a prior estimate) → nothing to do. - guard doc.metrics?.healthKitWorkoutUUID == nil, doc.metrics?.source != .watch else { return } - guard pending[doc.id] == nil else { return } - - let id = doc.id - pending[id] = Task { [weak self] in - try? await Task.sleep(for: self?.debounce ?? .seconds(10)) - guard let self, !Task.isCancelled else { return } - self.pending[id] = nil - await self.writeEstimate(workoutID: id) - } - } - - /// The watch recorded this workout — cancel any pending phone estimate so we don't - /// double-write. - func watchMetricsArrived(workoutID id: String) { - pending[id]?.cancel() - pending[id] = nil - } - - /// Best-effort removal of a workout's Health record, on the user's request when - /// deleting the workout in the app. HealthKit only allows deleting samples this - /// app authored, so phone estimates delete cleanly while watch-recorded workouts - /// (authored by the watch app's separate HealthKit source) may be refused — that - /// refusal is swallowed; the record then stays in Health until removed there. - func deleteFromHealth(uuidString: String) { - guard HKHealthStore.isHealthDataAvailable(), let uuid = UUID(uuidString: uuidString) else { return } - let healthStore = self.healthStore - let query = HKSampleQuery( - sampleType: .workoutType(), - predicate: HKQuery.predicateForObject(with: uuid), - limit: 1, sortDescriptors: nil - ) { _, samples, _ in - guard let workout = samples?.first else { return } - healthStore.delete([workout]) { _, _ in } - } - healthStore.execute(query) - } - - /// Launch safety sweep: catch workouts that completed but never got a Health record - /// (e.g., the app was killed during the debounce). Bounded to the last day so we - /// don't retroactively flood Health with historical workouts; idempotent via the - /// per-workout UUID guard. - func sweepRecentlyCompleted() { - guard HKHealthStore.isHealthDataAvailable(), !isScreenshotRun else { return } - let cutoff = Date().addingTimeInterval(-24 * 60 * 60) - let completedRaw = WorkoutStatus.completed.rawValue - let all = (try? context.fetch(FetchDescriptor())) ?? [] - for w in all where w.statusRaw == completedRaw && w.metricHealthKitWorkoutUUID == nil { - guard let end = w.end, end >= cutoff else { continue } - workoutCompleted(WorkoutDocument(from: w)) - } - } - - // MARK: - Estimate write - - private func writeEstimate(workoutID id: String) async { - guard healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return } - // Re-read the freshest doc — watch metrics may have landed during the debounce. - guard let workout = CacheMapper.fetchWorkout(id: id, in: context), - workout.status == .completed, let end = workout.end else { return } - if let m = workout.metrics, m.healthKitWorkoutUUID != nil || m.source == .watch { return } - - let doc = WorkoutDocument(from: workout) - let activity = activityType(for: doc) - let bodyKg = await bodyMassKg() - let hours = max(0, end.timeIntervalSince(doc.start)) / 3600 - let kcal = activity.met * bodyKg * hours - - let config = HKWorkoutConfiguration() - config.activityType = activity.hkActivityType - config.locationType = .indoor - let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: .local()) - - do { - try await builder.beginCollection(at: doc.start) - if kcal > 0 { - let sample = HKQuantitySample( - type: HKQuantityType(.activeEnergyBurned), - quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal), - start: doc.start, end: end - ) - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - builder.add([sample]) { _, error in - if let error { cont.resume(throwing: error) } else { cont.resume() } - } - } - } - try await builder.endCollection(at: end) - let saved = try await builder.finishWorkout() - - // Persist the estimate's metrics so it's never written twice. - var updated = doc - updated.metrics = WorkoutMetrics( - activeEnergyKcal: kcal > 0 ? kcal : nil, - avgHeartRate: nil, maxHeartRate: nil, minHeartRate: nil, - totalVolume: WorkoutVolume.total(doc.logs), - hrZoneSeconds: nil, - healthKitWorkoutUUID: saved?.uuid.uuidString, - source: .phoneEstimate, - recordedAt: end - ) - updated.updatedAt = Date() - await syncEngine?.save(workout: updated) - } catch { - // Leave it unmarked; the next launch sweep retries within the window. - } - } - - /// The split's configured activity type, following the seed clone-on-edit - /// redirect (the estimate can run seconds to hours after the workout, so the - /// split may have forked in between). Generic strength when the split is gone. - private func activityType(for doc: WorkoutDocument) -> WorkoutActivityType { - if let splitID = doc.splitID { - let resolved = syncEngine?.currentSplitID(for: splitID) ?? splitID - if let split = CacheMapper.fetchSplit(id: resolved, in: context) { - return split.activityTypeEnum - } - } - return .traditionalStrength - } - - /// Most recent body mass from Health, in kg, falling back to a default when none. - private func bodyMassKg() async -> Double { - let fallback = fallbackBodyMassKg - return await withCheckedContinuation { continuation in - let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - let query = HKSampleQuery( - sampleType: HKQuantityType(.bodyMass), predicate: nil, limit: 1, sortDescriptors: [sort] - ) { _, samples, _ in - let kg = (samples?.first as? HKQuantitySample)? - .quantity.doubleValue(for: .gramUnit(with: .kilo)) - continuation.resume(returning: kg ?? fallback) - } - healthStore.execute(query) - } - } -} diff --git a/Workouts/HealthKit/WorkoutLauncher.swift b/Workouts/HealthKit/WorkoutLauncher.swift index 1ea1bac..ec29221 100644 --- a/Workouts/HealthKit/WorkoutLauncher.swift +++ b/Workouts/HealthKit/WorkoutLauncher.swift @@ -30,9 +30,8 @@ final class WorkoutLauncher { /// Ask watchOS to launch the Watch app into the workout. Best-effort: no-ops where /// HealthKit is unavailable (e.g. iPad without it) and silently tolerates a missing - /// or unreachable paired watch. Authorization is requested up front by - /// `WorkoutHealthWriter` at launch, so we only need the workout-share scope that - /// `startWatchApp(toHandle:)` itself requires. + /// or unreachable paired watch. Requests the workout-share scope that + /// `startWatchApp(toHandle:)` itself requires (also enough for a legacy Health delete). func launchWatchWorkout(activityType: HKWorkoutActivityType) { guard HKHealthStore.isHealthDataAvailable() else { return } Task { diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 56a079f..7ade136 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -51,14 +51,6 @@ final class SyncEngine { /// this to push fresh state to the watch. var onCacheChanged: (() -> Void)? - /// Called when a completed workout is persisted. The HealthKit writer uses this to - /// schedule an estimated Health workout if nothing recorded it. - var onWorkoutCompleted: ((WorkoutDocument) -> Void)? - - /// Called when a workout carrying watch-recorded metrics is persisted, so the - /// writer can cancel any pending phone estimate for it. - var onWatchMetricsArrived: ((String) -> Void)? - private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync") private let modelContainer: ModelContainer private var store: DocumentFileStore? @@ -351,10 +343,6 @@ final class SyncEngine { } catch { report("Failed to save workout", error) } - // Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate; - // a completed doc (re)considers an estimate (the writer dedupes on metrics). - if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) } - if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) } } func delete(split: Split) async { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index f3010f5..48025e4 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -94,7 +94,7 @@ struct WorkoutLogsView: View { // the UUID before the delete prunes the cache entity. if let healthUUID = workout.metricHealthKitWorkoutUUID { Button("Delete + Remove from Apple Health", role: .destructive) { - services.workoutHealthWriter.deleteFromHealth(uuidString: healthUUID) + services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID) Task { await sync.delete(workout: workout) } itemToDelete = nil } diff --git a/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift b/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift index 6de0a92..7f1ad41 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift @@ -9,8 +9,8 @@ import SwiftUI import SwiftData /// Post-workout summary shown right after a workout finishes. Queried by id so the -/// health metrics (calories, heart rate) fill in live as they arrive — from the -/// watch over the bridge, or from the phone's estimate a few seconds after ending. +/// health metrics (calories, heart rate) fill in live as they arrive from the watch +/// over the WatchConnectivity bridge. struct WorkoutSummaryView: View { @Environment(\.dismiss) private var dismiss @Query private var workouts: [Workout]