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>
353 lines
16 KiB
Swift
353 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|