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. /// /// ⚠️ **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) } }