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>
341 lines
15 KiB
Swift
341 lines
15 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.
|
||
///
|
||
/// ⚠️ **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)
|
||
}
|
||
}
|