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
This commit is contained in:
2026-07-08 07:56:19 -04:00
parent 06fa52345b
commit a4ed4df756
9 changed files with 102 additions and 261 deletions
@@ -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)
}
}
@@ -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<Void, Never>] = [:]
/// 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<Workout>())) ?? []
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<Void, Error>) 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)
}
}
}
+2 -3
View File
@@ -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 {