La cible watchOS ne contenait pas une ligne de localisation : ni CLLocationManager, ni HKWorkoutRouteBuilder, et aucune clé NSLocation dans son Info.plist. Tant que les séances partaient de l'app Exercice d'Apple, c'est elle qui écrivait la trace et CoachHealthRoute la relisait après coup. Dès qu'une séance démarre depuis CoachWatch, plus personne ne l'écrit : la sortie n'aurait ni carte, ni parcours dans Santé. Le filtrage vit dans `RouteFilter`, en Foundation pur, donc testable ici : rejet des points imprécis (> 50 m, seuil de l'exemple Apple), des sauts impossibles, des doublons et des points arrivés dans le désordre, puis cumul de la distance (haversine) et du dénivelé. Le dénivelé a demandé deux passes. L'hystérésis seule laissait passer 297 m de D+ sur un parcours PLAT : une oscillation d'amplitude égale au seuil est comptée à chaque alternance, ce qui est inhérent à tout seuil. D'où un lissage préalable, qui annule le bruit alternant. Contrepartie assumée et testée : une pointe franchie en quelques points est écrêtée, donc sous-comptée — sans conséquence, le D+ qui fait foi restant celui, barométrique, qu'Apple écrit dans les métadonnées de la séance. Quatre pièges documentés, tous traités dans le code : - `allowsBackgroundLocationUpdates` sans `UIBackgroundModes` = `location` TERMINE l'app. Un garde-fou vérifie le plist avant d'armer le drapeau. - Ne jamais demander « Always » sur watchOS : Apple décrit ce prompt comme « mostly a placeholder » et le parcours comme un comportement indéfini. - Interdit de relancer la localisation depuis l'arrière-plan : la pause garde le flux ouvert et ignore les points, au lieu de couper le manager. - Le CPU tue le GPS — d'où l'insertion des points par lots et la trace d'affichage bornée à 1500 points. Corrigé après lecture de la doc : `finishRoute` s'appelle APRÈS `finishWorkout` et reçoit le workout pour s'y associer. J'avais écrit l'inverse, avec nil, ce qui aurait perdu l'association à chaque sortie. Et `finishWorkout()` rend nil sans erreur quand la montre est verrouillée : on sauvegarde alors la trace sans association plutôt que de la jeter. 15 tests, dont 4 vérifiés rouges en désactivant le lissage. Les trois fichiers sont déclarés dans la cible CoachWatch (pbxproj sauvegardé en .bak-outdoor) : aucun « Add Files » à faire sur le Mac. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
319 lines
14 KiB
Swift
319 lines
14 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 {
|
||
// Le point qui suivra la reprise serait à des centaines de mètres :
|
||
// on repart d'une origine neuve pour ne pas tracer la pause.
|
||
pendingRoute.removeAll()
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// ⚠️ `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.
|
||
///
|
||
/// Échoue aussi 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 {
|
||
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)")
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|