Files
coach-ios/ios/App/CoachWatch/WorkoutManager.swift
Sylvain Bettinelli 3e657f5917 Une trace GPS ne peut pas être sauvegardée sans séance
Erreur de compilation remontée du Mac :
`Value of optional type 'HKWorkout?' must be unwrapped`.

Le fichier promettait quelque chose d'impossible. Vérifié à la source ce jour
(DocC HealthKit) : la signature est `finishRoute(with workout: HKWorkout,
metadata:)` — non optionnelle — et Apple précise « You must have already saved
this workout to the HealthKit store ». Il n'existe aucune API pour clore une
route orpheline. Le commentaire qui annonçait « la trace sera sauvegardée sans
association plutôt que perdue » décrivait un comportement inatteignable.

Le vrai recours tient à ce que dit le bug d'Apple : quand la montre est
verrouillée, `finishWorkout()` rend `nil` mais la séance EST écrite dans
HealthKit — seul l'objet manque. `WorkoutManager` va donc la rechercher :
dernier workout écrit par CETTE app (HKSource.default(), sinon une séance de
l'app Exercice pourrait récupérer notre trace), croisant les 5 dernières
minutes.

⚠️ La fenêtre porte sur le chevauchement, pas sur `startDate` : filtrer sur le
début raterait toute séance de plus de quelques minutes — le piège qui avait
rendu muettes les notifications de fin de séance côté iPhone.

Si rien n'est récupérable, `discardRoute()` jette la trace explicitement et le
journalise comme une perte : un builder abandonné sans `discard()` laisse ses
données en suspens, et « any further calls to the builder raise an exception ».

Le chemin « séance en salle » (aucune position retenue) reste inchangé,
volontairement sans `discard()` : c'est le cas le plus fréquent, il
fonctionnait, et aucun build ne l'a validé avec cet appel.

`HKSource` est écrit en toutes lettres : `predicateForObjects(from:)` a cinq
surcharges et un `.default()` abrégé s'y résout mal.

swiftc -parse OK, 57 tests Swift verts (inchangés : ces fichiers importent
HealthKit, hors de portée de Linux).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 09:22:50 +00:00

391 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import HealthKit
import os
private let workoutLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "workout")
/// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone.
/// `@MainActor` (donc Sendable) : l'état observable vit sur le main ; les callbacks
/// HealthKit, livrés hors-main, sont `nonisolated` et hoppent vers le main.
@MainActor
final class WorkoutManager: NSObject, ObservableObject {
static let shared = WorkoutManager()
@Published private(set) var heartRate = 0.0
@Published private(set) var activeEnergyKcal = 0.0
@Published private(set) var distanceMeters = 0.0
@Published private(set) var speedKmh = 0.0
@Published private(set) var elapsedSec = 0.0
@Published private(set) var isRunning = false
@Published private(set) var isPaused = false
@Published private(set) var statusMessage: String?
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var lastSentAt = Date.distantPast
// Throttle léger anti-saturation BLE pour les metrics à haute fréquence
// (calories/distance). La FC, elle, est poussée immédiatement à chaque
// nouvel échantillon comme l'app Exercice native d'Apple.
private let minSendInterval: TimeInterval = 0.5
private var simTask: Task<Void, Never>?
private override init() { super.init() }
func startWorkout(activityType: HKWorkoutActivityType,
locationType: HKWorkoutSessionLocationType) async {
guard !isRunning, session == nil else { return }
guard HKHealthStore.isHealthDataAvailable() else {
statusMessage = "HealthKit indisponible sur cet appareil"
return
}
let distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned)
// Vitesse instantanée mesurée par la montre. HKLiveWorkoutDataSource la
// collecte d'office (course extérieure sur Series 6+, vélo sur
// watchOS 11+), mais la lecture doit être autorisée. Sur un appareil qui
// ne la produit pas, les stats restent vides : `ingest` retombe alors
// sur la vitesse moyenne dérivée de la distance.
let speedType = Self.speedType(for: activityType)
// La trace exige d'être demandée en lecture ET en écriture Apple :
// « you must request permission to read and share both HKWorkout and
// HKWorkoutRoute samples ». Sans elle, `finishRoute` échoue et la
// sortie n'a pas de parcours.
let route = HKSeriesType.workoutRoute()
let outdoor = (locationType == .outdoor)
var read: Set<HKObjectType> = [hr, energy, distanceType, speedType]
var share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
if outdoor {
read.insert(route)
share.insert(route)
}
do {
try await healthStore.requestAuthorization(toShare: share, read: read)
let woStatus = healthStore.authorizationStatus(for: HKQuantityType.workoutType())
workoutLog.info("requestAuthorization OK; workoutType share status=\(woStatus.rawValue)")
if woStatus == .sharingDenied {
statusMessage = "Accès Santé refusé. Active Coach dans Réglages → Santé → Accès aux données, puis relance."
return
}
} catch {
workoutLog.error("requestAuthorization threw: \(error.localizedDescription)")
statusMessage = "Autorisation Santé refusée : \(error.localizedDescription)"
return
}
// Demandée ici, donc au premier plan : watchOS n'affiche pas la feuille
// d'autorisation depuis l'arrière-plan. Volontairement « quand
// l'app est active » et jamais « toujours » sur watchOS ce dernier
// mène à un parcours d'autorisation qui boucle.
if outdoor { LocationTracker.shared.requestAuthorization() }
let config = HKWorkoutConfiguration()
config.activityType = activityType
config.locationType = locationType
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: config)
session.delegate = self
builder.delegate = self
self.session = session
self.builder = builder
let start = Date()
session.startActivity(with: start)
workoutLog.info("startActivity called, beginning collection…")
try await builder.beginCollection(at: start)
isRunning = true
statusMessage = nil
workoutLog.info("beginCollection OK, isRunning=true")
// GPS uniquement en extérieur : en salle il ne ferait que vider la
// batterie pour une trace immobile.
if outdoor {
// Apple déconseille d'instancier un HKWorkoutRouteBuilder
// directement : celui du builder de séance associe la trace.
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute())
as? HKWorkoutRouteBuilder
if routeBuilder == nil {
workoutLog.error("seriesBuilder n'a pas rendu un HKWorkoutRouteBuilder")
}
LocationTracker.shared.start(activity: activityType, routeBuilder: routeBuilder)
}
} catch {
self.session = nil
self.builder = nil
let ns = error as NSError
workoutLog.error("start failed domain=\(ns.domain) code=\(ns.code): \(error.localizedDescription)")
statusMessage = "Démarrage séance impossible : \(error.localizedDescription)"
}
}
/// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la
/// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`).
/// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le
/// changement d'état HealthKit, seule source fiable (la montre peut aussi
/// mettre en pause d'elle-même).
func pause() {
guard simTask == nil else { setSimPaused(true); return }
session?.pause()
}
func resume() {
guard simTask == nil else { setSimPaused(false); return }
session?.resume()
}
/// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun
/// changement d'état, l'iPhone doit donc être prévenu ici.
private func setSimPaused(_ paused: Bool) {
isPaused = paused
ConnectivityManager.shared.sendPaused(paused)
}
func end() {
if let simTask {
simTask.cancel()
self.simTask = nil
isRunning = false
statusMessage = nil
// Le mode test ne passe pas par HKWorkoutSession : c'est ici le
// seul endroit où l'iPhone peut apprendre que le flux s'arrête.
ConnectivityManager.shared.sendEnded(reason: "sim")
} else {
session?.end()
}
}
/// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live.
func startSimulation() {
guard !isRunning else { return }
isRunning = true
statusMessage = "Mode test — FC simulée"
let startedAt = Date()
simTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let elapsed = Date().timeIntervalSince(startedAt)
let hr = 110 + 40 * abs(sin(elapsed / 30))
let kcal = elapsed / 6
let dist = elapsed * 2.6
self.heartRate = hr
self.activeEnergyKcal = kcal
self.distanceMeters = dist
self.elapsedSec = elapsed
self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s
ConnectivityManager.shared.sendSample([
"heartRate": hr,
"activeEnergyKcal": kcal,
"distanceMeters": dist,
"speedKmh": self.speedKmh,
"elapsedSec": elapsed,
"ts": Date().timeIntervalSince1970,
"sim": true
])
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}
/// Appelé sur le main depuis le callback de collecte (hors-main).
private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?,
elapsed: TimeInterval) {
if let hr { heartRate = hr }
if let kcal { activeEnergyKcal = kcal }
if let dist { distanceMeters = dist }
elapsedSec = elapsed
// Vitesse instantanée de la montre si elle la mesure, sinon moyenne
// depuis le départ (distance / temps). Les deux en km/h.
if let speedMs {
speedKmh = speedMs * 3.6
} else if elapsed > 5, distanceMeters > 0 {
speedKmh = (distanceMeters / elapsed) * 3.6
}
let now = Date()
// Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à
// jour calories/distance seule), on applique le throttle anti-saturation.
let heartRateUpdated = hr != nil
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return }
lastSentAt = now
ConnectivityManager.shared.sendSample([
"heartRate": heartRate,
"activeEnergyKcal": activeEnergyKcal,
"distanceMeters": distanceMeters,
"speedKmh": speedKmh,
"elapsedSec": elapsed,
"ts": now.timeIntervalSince1970
])
}
private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.distanceCycling)
: HKQuantityType(.distanceWalkingRunning)
}
/// La marche n'a pas de type de vitesse dédié : on lui donne celui de la
/// course, qui restera vide la vitesse moyenne dérivée prend le relais.
private static func speedType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.cyclingSpeed)
: HKQuantityType(.runningSpeed)
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>) {
let hrType = HKQuantityType(.heartRate)
let energyType = HKQuantityType(.activeEnergyBurned)
let walkRun = HKQuantityType(.distanceWalkingRunning)
let cycling = HKQuantityType(.distanceCycling)
let runSpeed = HKQuantityType(.runningSpeed)
let cycleSpeed = HKQuantityType(.cyclingSpeed)
var hr: Double?
var kcal: Double?
var dist: Double?
var speedMs: Double?
for type in collectedTypes {
guard let qType = type as? HKQuantityType,
let stats = workoutBuilder.statistics(for: qType) else { continue }
if qType == hrType {
let bpm = HKUnit.count().unitDivided(by: .minute())
hr = stats.mostRecentQuantity()?.doubleValue(for: bpm)
} else if qType == energyType {
kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie())
} else if qType == walkRun || qType == cycling {
dist = stats.sumQuantity()?.doubleValue(for: .meter())
} else if qType == runSpeed || qType == cycleSpeed {
// mostRecent : c'est bien la vitesse à l'instant t qu'on veut
// afficher, pas la moyenne de la séance.
let ms = HKUnit.meter().unitDivided(by: .second())
speedMs = stats.mostRecentQuantity()?.doubleValue(for: ms)
}
}
let elapsed = workoutBuilder.elapsedTime
Task { @MainActor in
self.ingest(hr: hr, kcal: kcal, dist: dist, speedMs: speedMs, elapsed: elapsed)
}
}
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}
extension WorkoutManager: HKWorkoutSessionDelegate {
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date) {
workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)")
// Pause / reprise : c'est la session HealthKit qui fait foi, pas
// l'appel à pause() la montre peut mettre en pause d'elle-même
// (auto-pause) sans passer par nous.
if toState == .paused || toState == .running {
let paused = (toState == .paused)
Task { @MainActor in
self.isPaused = paused
// Le flux GPS reste ouvert pendant la pause : watchOS interdit
// de redémarrer la localisation depuis l'arrière-plan, donc on
// ignore les points au lieu de couper le manager.
LocationTracker.shared.setPaused(paused)
ConnectivityManager.shared.sendPaused(paused)
}
}
guard toState == .ended else { return }
let builder = workoutSession.associatedWorkoutBuilder()
Task { @MainActor in
// Ordre imposé par Apple : couper le GPS, clore la collecte,
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
// l'associant au workout obtenu.
LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date)
// `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
// HealthKit seul l'objet manque. Comme `finishRoute(with:)`
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
// on va rechercher celle qui vient d'être sauvegardée.
let workout = try? await builder.finishWorkout()
if let workout {
await LocationTracker.shared.finishRoute(with: workout)
} else if let recovered = await self.recentlySavedWorkout() {
workoutLog.info("workout recupere apres un finishWorkout() nil")
await LocationTracker.shared.finishRoute(with: recovered)
} else {
LocationTracker.shared.discardRoute()
}
}
Task { @MainActor in
self.isRunning = false
self.isPaused = false
self.session = nil
self.builder = nil
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
// affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit.
ConnectivityManager.shared.sendEnded(reason: "session-ended")
}
}
/// La séance que HealthKit vient d'enregistrer, quand `finishWorkout()`
/// n'a rien rendu.
///
/// La fenêtre porte sur le **chevauchement**, pas sur la date de début :
/// sans `.strictStartDate`, un workout est retenu dès qu'il croise
/// l'intervalle. Filtrer sur son `startDate` raterait toute séance de plus
/// de quelques minutes c'est le piège qui avait rendu muettes les
/// notifications de fin de séance côté iPhone.
///
/// Restreinte à ce que **cette app** a écrit (`HKSource.default()`) : sans
/// ça, une séance enregistrée en parallèle par l'app Exercice d'Apple
/// pourrait récupérer notre trace.
private func recentlySavedWorkout() async -> HKWorkout? {
let recent = HKQuery.predicateForSamples(
withStart: Date(timeIntervalSinceNow: -Self.recoveryWindow), end: nil)
// Type écrit en toutes lettres : `predicateForObjects(from:)` a cinq
// surcharges (HKSource, Set<HKSource>, HKWorkout, Set<HKSourceRevision>,
// Set<HKDevice>) et un `.default()` abrégé s'y résout mal.
let mine = HKQuery.predicateForObjects(from: HKSource.default())
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [recent, mine])
let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return await withCheckedContinuation { continuation in
let query = HKSampleQuery(sampleType: .workoutType(),
predicate: predicate,
limit: 1,
sortDescriptors: [newestFirst]) { _, samples, error in
if let error {
workoutLog.error("recuperation du workout impossible : \(error.localizedDescription)")
}
continuation.resume(returning: samples?.first as? HKWorkout)
}
healthStore.execute(query)
}
}
/// Fenêtre de recherche du workout de repli. Large assez pour couvrir une
/// sauvegarde lente, courte assez pour ne pas ramasser la séance d'avant.
private static let recoveryWindow: TimeInterval = 5 * 60
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didFailWithError error: Error) {
let message = error.localizedDescription
let ns = error as NSError
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
Task { @MainActor in
self.statusMessage = "Erreur séance : \(message)"
self.isRunning = false
self.session = nil
self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
}
}