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>
This commit is contained in:
Sylvain Bettinelli
2026-08-21 09:22:50 +00:00
parent 2f2d7696fa
commit 3e657f5917
2 changed files with 82 additions and 11 deletions

View File

@@ -165,21 +165,30 @@ final class LocationTracker: NSObject, ObservableObject {
/// pas l'appeler du tout fait perdre **toute** la trace le builder est /// pas l'appeler du tout fait perdre **toute** la trace le builder est
/// invalidé à sa libération. /// invalidé à sa libération.
/// ///
/// `workout` peut être `nil` : `finishWorkout()` réussit mais ne rend /// **Une trace ne peut PAS être sauvegardée sans workout.** Une version
/// pas l'objet **quand la montre est verrouillée** (confirmé par un /// antérieure de ce commentaire l'affirmait ; c'est faux, vérifié à la
/// ingénieur Apple). On sauvegarde alors la trace sans association plutôt /// source le 21/08 : la signature est
/// que de la jeter une route orpheline vaut mieux qu'une sortie sans /// `finishRoute(with workout: HKWorkout, metadata:)` non optionnelle
/// parcours. Une route ne peut être associée qu'une fois, et jamais après /// et Apple précise « You must have already saved this workout to the
/// coup. /// 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. /// séance en salle, à ne pas remonter comme une anomalie.
func finishRoute(with workout: HKWorkout?) async { func finishRoute(with workout: HKWorkout) async {
await flushPendingRoute() await flushPendingRoute()
guard let builder = routeBuilder else { return } guard let builder = routeBuilder else { return }
self.routeBuilder = nil self.routeBuilder = nil
guard filter.acceptedCount > 0 else { 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") locationLog.info("aucune position retenue : pas de trace a clore")
return 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 // MARK: Écriture HealthKit
/// Écrit les points en attente. Par lots : une écriture par position /// Écrit les points en attente. Par lots : une écriture par position

View File

@@ -309,10 +309,19 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
LocationTracker.shared.stopTracking() LocationTracker.shared.stopTracking()
try? await builder.endCollection(at: date) try? await builder.endCollection(at: date)
// `finishWorkout()` rend `nil` SANS erreur quand la montre est // `finishWorkout()` rend `nil` SANS erreur quand la montre est
// verrouillée : ce n'est pas un échec. On poursuit avec `nil`, la // verrouillée : ce n'est pas un échec, la séance EST écrite dans
// trace sera sauvegardée sans association plutôt que perdue. // 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() 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 Task { @MainActor in
self.isRunning = false 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<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, nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
didFailWithError error: Error) { didFailWithError error: Error) {
let message = error.localizedDescription let message = error.localizedDescription