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)
}
}