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