Files
coach-ios/ios/App/CoachWatch/LocationTracker.swift
Sylvain Bettinelli 2c9af6c684 Chaque pause effaçait jusqu'à 24 points de trace déjà parcourus
setPaused vidait `pendingRoute` pour « ne pas tracer la pause ». Mais ce tampon
ne contient que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
`distanceMeters` : ils attendent seulement leur écriture, qui ne part qu'au
seuil de routeFlushThreshold (25). À 1 Hz, c'est une douzaine de points en
moyenne, jusqu'à 24.

L'écart était donc visible : la distance affichée les incluait, la
HKWorkoutRoute non. Six arrêts sur une sortie VTTAE — feux, photos, ravito — et
jusqu'à 144 points manquaient à la trace, avec un D+ recalculé depuis elle qui
divergeait d'autant de celui affiché pendant la séance. Aucun log ne
mentionnait l'effacement.

Ils sont désormais écrits, via le même `Task { await flushPendingRoute() }` que
le flush au seuil. Le raisonnement du commentaire d'origine visait le point qui
SUIT la reprise, à des centaines de mètres de là — mais celui-là n'est pas
encore dans le tampon quand la pause commence, et c'est `implausibleSpeed` qui
l'écarte. Nettoyer la pause est l'affaire du filtre, pas du tampon d'écriture.

Pas de course possible : `isPaused` est posé avant, et `ingest` sort dès qu'il
est vrai — aucun point neuf ne peut s'ajouter pendant que le flush s'exécute.

Relevé § 2.5 de docs/AUDIT-2026-09-22.md, seul constat de la section vérifié
ligne à ligne avant correction. 88 tests Linux verts (RouteFilter compris), mais
ils ne couvrent pas ce fichier : CoreLocation n'existe pas sur Linux.

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

353 lines
16 KiB
Swift
Raw 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 CoreLocation
import HealthKit
import os
private let locationLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "location")
/* GPS de la séance : flux de positions, trace HealthKit, distance et D+.
*
* Le filtrage vit dans `RouteFilter` (Foundation pur, testé sur Linux). Ce
* fichier ne fait que le brancher sur CoreLocation et HealthKit — c'est la
* seule partie qui exige un poignet pour être validée.
*
* **Pourquoi enregistrer la trace nous-mêmes.** Jusqu'ici les traces venaient
* de l'app *Exercice* d'Apple, relues après coup côté iPhone par
* `CoachHealthRoute`. Dès qu'une séance est lancée depuis CoachWatch, plus
* personne n'écrit de `HKWorkoutRoute` : sans ce fichier, la sortie n'aurait
* aucune trace, ni sur la carte ni dans Santé.
*
* ⚠️ **Quatre pièges, tous documentés et tous coûteux.**
*
* 1. `allowsBackgroundLocationUpdates = true` **sans** `UIBackgroundModes` =
* `location` dans l'Info.plist **termine l'app** — Apple : « is a fatal
* error that terminates the app ». D'où le garde-fou `backgroundModeDeclared`
* ci-dessous, qui vérifie le plist avant d'armer le drapeau.
* 2. **Ne jamais demander « Always » sur watchOS.** Un ingénieur DTS d'Apple
* décrit ce prompt comme « mostly a placeholder » et le parcours comme
* « undefined behavior » : le statut boucle jusqu'à revenir à
* `.notDetermined`. `requestWhenInUseAuthorization()` suffit pour continuer
* à recevoir des positions en arrière-plan.
* 3. **Démarrer au premier plan, et ne plus jamais arrêter.** watchOS interdit
* de relancer les mises à jour depuis l'arrière-plan : une pause qui
* couperait le GPS ne pourrait pas le rallumer avant le retour à l'écran.
* On garde donc le flux ouvert et on ignore les points pendant la pause.
* 4. **Le CPU tue le GPS.** watchOS suspend une app qui consomme trop en
* arrière-plan, et les positions s'arrêtent sans la moindre erreur — un
* développeur a perdu les siennes à cause d'un rafraîchissement d'écran à
* 1/100 s. D'où l'insertion des points par lots plutôt qu'un par un.
*/
@MainActor
final class LocationTracker: NSObject, ObservableObject {
static let shared = LocationTracker()
/// Trace décimée pour l'affichage carte. Bornée : une sortie de 3 h à 1 Hz
/// produirait plus de 10 000 points, que la montre ne peut pas redessiner
/// à chaque rafraîchissement sans se faire suspendre (piège 4).
@Published private(set) var trackForMap: [GeoFix] = []
@Published private(set) var lastFix: GeoFix?
@Published private(set) var distanceMeters: Double = 0
@Published private(set) var ascentMeters: Double = 0
@Published private(set) var descentMeters: Double = 0
/// Rayon d'incertitude du dernier point reçu, accepté ou non — c'est la
/// jauge « qualité GPS » de l'écran.
@Published private(set) var horizontalAccuracy: Double?
@Published private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
@Published private(set) var isTracking = false
private let manager = CLLocationManager()
private var filter = RouteFilter()
private var routeBuilder: HKWorkoutRouteBuilder?
/// Points en attente d'écriture dans HealthKit.
private var pendingRoute: [CLLocation] = []
/// Positions ignorées tant que la séance est en pause : le flux reste
/// ouvert (piège 3) mais la trace ne doit pas traverser l'arrêt.
private var isPaused = false
private let maxMapPoints = 1_500
private let routeFlushThreshold = 25
private override init() {
super.init()
manager.delegate = self
authorizationStatus = manager.authorizationStatus
}
// MARK: Autorisation
/// À appeler **au premier plan**, avant de démarrer la séance.
func requestAuthorization() {
// Volontairement pas `requestAlwaysAuthorization()` : voir piège 2.
guard manager.authorizationStatus == .notDetermined else { return }
manager.requestWhenInUseAuthorization()
}
var isAuthorized: Bool {
switch authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways: return true
default: return false
}
}
// MARK: Cycle de vie
/// Démarre le suivi. **Doit être appelé pendant que l'app est au premier
/// plan** — watchOS refuse de démarrer la localisation depuis l'arrière-plan.
///
/// - Parameter routeBuilder: fourni par `HKLiveWorkoutBuilder`, via
/// `seriesBuilder(for: HKSeriesType.workoutRoute())`. `nil` pour un suivi
/// d'affichage seul, sans écriture dans Santé.
func start(activity: HKWorkoutActivityType, routeBuilder: HKWorkoutRouteBuilder?) {
guard !isTracking else { return }
// On n'exige pas que l'autorisation soit DÉJÀ accordée : quand elle est
// encore en attente, le manager reste armé et CoreLocation délivre les
// positions dès l'acceptation. Refuser ici priverait de trace toute
// première séance, celle-là même où la demande apparaît.
if authorizationStatus == .denied || authorizationStatus == .restricted {
locationLog.error("localisation refusée par l'utilisateur : séance sans trace")
return
}
self.routeBuilder = routeBuilder
filter = RouteFilter(maxPlausibleSpeed: Self.maxSpeed(for: activity))
trackForMap.removeAll()
pendingRoute.removeAll()
distanceMeters = 0
ascentMeters = 0
descentMeters = 0
isPaused = false
// Le défaut de watchOS est `kCLLocationAccuracyHundredMeters` — inutile
// pour une trace. `BestForNavigation` est réservé par Apple aux
// appareils branchés : trop gourmand pour une séance au poignet.
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = kCLDistanceFilterNone
manager.activityType = Self.activityType(for: activity)
if Self.backgroundModeDeclared {
manager.allowsBackgroundLocationUpdates = true
} else {
// Armer le drapeau sans la clé Info.plist tuerait l'app (piège 1).
// Mieux vaut une trace qui s'arrête écran éteint qu'un crash.
locationLog.error("UIBackgroundModes/location absent du plist : suivi limité au premier plan")
}
manager.startUpdatingLocation()
isTracking = true
locationLog.info("suivi GPS démarré")
}
/// Suspend l'enregistrement **sans couper le flux** (piège 3).
func setPaused(_ paused: Bool) {
guard isTracking else { return }
isPaused = paused
if paused {
// ⚠️ Ici se jouait une perte silencieuse. `pendingRoute` ne contient
// que des points DÉJÀ acceptés par le filtre et DÉJÀ comptés dans
// `distanceMeters` — ils attendent seulement leur écriture, qui ne
// part qu'au seuil de `routeFlushThreshold` (25). À 1 Hz, c'est une
// douzaine de points en moyenne, jusqu'à 24. Les jeter creusait un
// écart visible entre la distance affichée et la trace enregistrée :
// six arrêts sur une sortie, et jusqu'à 144 points manquaient à la
// HKWorkoutRoute, avec un D+ recalculé qui divergeait d'autant.
//
// On les écrit donc au lieu de les perdre. Le raisonnement du
// commentaire d'origine visait le point qui SUIT la reprise, à des
// centaines de mètres de là — mais celui-là n'est pas encore dans
// le tampon, et c'est `implausibleSpeed` qui l'écarte. Nettoyer la
// pause est l'affaire du filtre, pas du tampon d'écriture.
Task { await flushPendingRoute() }
}
}
/// Coupe le flux GPS. À appeler dès la fin de séance ; la trace, elle, ne
/// se clôt qu'après la sauvegarde du workout — voir `finishRoute(with:)`.
func stopTracking() {
guard isTracking else { return }
manager.stopUpdatingLocation()
isTracking = false
locationLog.info("suivi GPS arrete")
}
/// Clôt la trace et l'associe à la séance sauvegardée.
///
/// ⚠️ **L'ordre n'est pas négociable.** Apple : « After saving the workout,
/// add any remaining locations to the route builder and call finishRoute. »
/// Appeler `finishRoute` avant `finishWorkout` empêche l'association, et ne
/// pas l'appeler du tout fait perdre **toute** la trace — le builder est
/// invalidé à sa libération.
///
/// ⚠️ **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.
///
/// 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 {
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
}
do {
_ = try await builder.finishRoute(with: workout, metadata: nil)
locationLog.info("trace close : \(self.filter.acceptedCount) points, \(Int(self.filter.distanceM)) m")
} catch {
locationLog.error("finishRoute a echoue : \(error.localizedDescription)")
}
}
/// 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
/// gaspillerait le CPU que watchOS surveille (piège 4).
private func flushPendingRoute() async {
guard let builder = routeBuilder, !pendingRoute.isEmpty else { return }
let batch = pendingRoute
pendingRoute.removeAll()
do {
try await builder.insertRouteData(batch)
} catch {
// Les points sont perdus, pas la séance : on ne remet pas le lot
// dans la file, sinon un échec persistant la ferait enfler
// indéfiniment en mémoire.
locationLog.error("insertRouteData a echoue (\(batch.count) points) : \(error.localizedDescription)")
}
}
// MARK: Réglages par activité
/// Plafond de vitesse plausible, en m/s. Le VTT électrique descend
/// largement au-dessus d'un coureur : un seuil unique écarterait des points
/// parfaitement valides.
private static func maxSpeed(for activity: HKWorkoutActivityType) -> Double {
switch activity {
case .cycling: return 30 // 108 km/h, descente comprise
case .running: return 12 // 43 km/h
default: return 8 // marche, randonnée
}
}
private static func activityType(for activity: HKWorkoutActivityType) -> CLActivityType {
switch activity {
// `.fitness` fait désactiver le positionnement intérieur et peut
// provoquer des pauses automatiques ; Apple recommande
// `.otherNavigation` pour le vélo et le hors-route.
case .cycling: return .otherNavigation
default: return .fitness
}
}
/// Le plist déclare-t-il le mode de fond `location` ?
///
/// watchOS porte deux clés distinctes : `WKBackgroundModes` pour
/// `workout-processing`, et `UIBackgroundModes` pour `location`. On accepte
/// les deux, la documentation d'Apple n'étant pas univoque sur ce point.
private static var backgroundModeDeclared: Bool {
let keys = ["UIBackgroundModes", "WKBackgroundModes"]
for key in keys {
if let modes = Bundle.main.object(forInfoDictionaryKey: key) as? [String],
modes.contains("location") {
return true
}
}
return false
}
}
extension LocationTracker: CLLocationManagerDelegate {
nonisolated func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
let fixes = locations.map { GeoFix(location: $0) }
let raw = locations
Task { @MainActor in
self.ingest(fixes: fixes, raw: raw)
}
}
nonisolated func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
locationLog.error("CoreLocation a echoue : \(error.localizedDescription)")
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
self.authorizationStatus = status
}
}
private func ingest(fixes: [GeoFix], raw: [CLLocation]) {
horizontalAccuracy = fixes.last?.horizontalAccuracy
guard !isPaused else { return }
for (fix, location) in zip(fixes, raw) {
guard case .accepted = filter.add(fix) else { continue }
lastFix = fix
pendingRoute.append(location)
appendToMap(fix)
}
distanceMeters = filter.distanceM
ascentMeters = filter.ascentM
descentMeters = filter.descentM
if pendingRoute.count >= routeFlushThreshold {
Task { await flushPendingRoute() }
}
}
/// Ajoute à la trace d'affichage, en décimant une fois le plafond atteint :
/// on garde un point sur deux, ce qui divise la densité sans déformer le
/// tracé ni faire enfler la mémoire sur une longue sortie.
private func appendToMap(_ fix: GeoFix) {
trackForMap.append(fix)
if trackForMap.count > maxMapPoints {
trackForMap = trackForMap.enumerated()
.compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil }
}
}
}
extension GeoFix {
/// Conversion depuis CoreLocation. Isolée ici pour que `RouteFilter` reste
/// testable hors d'un Mac.
init(location: CLLocation) {
self.init(lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
altitude: location.altitude,
horizontalAccuracy: location.horizontalAccuracy,
verticalAccuracy: location.verticalAccuracy,
speed: location.speed,
timestamp: location.timestamp)
}
}