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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user