diff --git a/ios/App/CoachWatch/LocationTracker.swift b/ios/App/CoachWatch/LocationTracker.swift index 831368b..a5d255e 100644 --- a/ios/App/CoachWatch/LocationTracker.swift +++ b/ios/App/CoachWatch/LocationTracker.swift @@ -165,21 +165,30 @@ final class LocationTracker: NSObject, ObservableObject { /// pas l'appeler du tout fait perdre **toute** la trace — le builder est /// invalidé à sa libération. /// - /// ⚠️ `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend - /// pas l'objet **quand la montre est verrouillée** (confirmé par un - /// ingénieur Apple). On sauvegarde alors la trace sans association plutôt - /// que de la jeter — une route orpheline vaut mieux qu'une sortie sans - /// parcours. Une route ne peut être associée qu'une fois, et jamais après - /// coup. + /// ⚠️ **Une trace ne peut PAS être sauvegardée sans workout.** Une version + /// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la + /// source le 21/08 : 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 cas « montre verrouillée », où `finishWorkout()` rend + /// `nil` sans erreur, se traite donc **en amont** : `WorkoutManager` va + /// rechercher dans HealthKit le workout qui vient d'y être écrit. Ici, si + /// aucun workout n'arrive, il ne reste qu'à jeter la trace explicitement + /// (`discardRoute()`) — un builder abandonné sans `discard()` laisse ses + /// données en suspens. /// - /// Échoue aussi si aucune position n'a été insérée — cas normal d'une + /// Ne fait rien si aucune position n'a été insérée — cas normal d'une /// séance en salle, à ne pas remonter comme une anomalie. - func finishRoute(with workout: HKWorkout?) async { + func finishRoute(with workout: HKWorkout) async { await flushPendingRoute() guard let builder = routeBuilder else { return } self.routeBuilder = nil guard filter.acceptedCount > 0 else { + // Séance en salle : rien à clore. Volontairement SANS `discard()` — + // c'est le chemin le plus fréquent, et il fonctionnait tel quel ; + // on n'y introduit pas un appel qu'aucun build n'a validé. locationLog.info("aucune position retenue : pas de trace a clore") return } @@ -191,6 +200,19 @@ final class LocationTracker: NSObject, ObservableObject { } } + /// Abandonne la trace en cours. + /// + /// Seul recours quand aucun workout n'a pu être associé : sans `discard()`, + /// le builder garde ses données côté HealthKit et « any further calls to + /// the builder raise an exception ». La sortie existera alors sans + /// parcours — perte réelle, à tracer comme telle plutôt qu'à masquer. + func discardRoute() { + guard let builder = routeBuilder else { return } + self.routeBuilder = nil + builder.discard() + locationLog.error("trace abandonnee : aucun workout a associer (\(self.filter.acceptedCount) points perdus)") + } + // MARK: Écriture HealthKit /// Écrit les points en attente. Par lots : une écriture par position diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index 3669d7a..da93ca2 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -309,10 +309,19 @@ extension WorkoutManager: HKWorkoutSessionDelegate { 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. On poursuit avec `nil`, la - // trace sera sauvegardée sans association plutôt que perdue. + // 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() - await LocationTracker.shared.finishRoute(with: workout) + 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 @@ -325,6 +334,46 @@ extension WorkoutManager: HKWorkoutSessionDelegate { } } + /// 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, HKWorkout, Set, + // Set) 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