Files
coach-ios/ios/App/App/CoachWorkoutObserver.swift
Sylvain Bettinelli 9903424235 Le plugin affirmait l'envoi sans jamais relire ce que la montre avait reçu
Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence :
rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au
poignet ». Détail du dossier et chronologie dans coach_sportif,
docs/GUIDE-MONTRE.md §5quater et §5quinquies.

CoachWorkoutKit :
- requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog
  et l'envoi continuait : une réinstallation de l'app remet l'autorisation à
  notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre
  du silence — et l'écran affichait « Envoyé » alors que rien ne partait.
- authorizationState est lu et rendu au JS, par isAvailable() comme par la
  nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici
  qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse.
- schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et
  sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste
  vide n'est PAS traitée comme un échec : le scheduler publie de façon
  asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque
  envoi coûterait plus cher que le silence qu'on cherche.

CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle
vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur
le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté
serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt
qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ».

⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de
Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation.
À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la
doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts,
ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout,
CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 06:24:08 +00:00

500 lines
22 KiB
Swift

// CoachWorkoutObserver.swift
// Observer HealthKit en background : détecte les nouveaux HKWorkout
// ajoutés au store (= fin de séance Apple Watch ou autre source) et
// push une notif locale enrichie sur l'iPhone.
//
// Pipeline :
// 1. requestAuthorization (lecture HKWorkout)
// 2. enableBackgroundDelivery(.immediate) sur HKWorkoutType
// 3. HKObserverQuery callback à chaque nouveau workout (réveille
// l'app en background même si fermée)
// 4. fetchLatestWorkout query le dernier workout sur la dernière
// heure pour récupérer durée/kcal/distance
// 5. scheduleNotification UNNotificationRequest immédiat
//
// Usage JS :
// await window.Capacitor.Plugins.CoachWorkoutObserver.startObserving()
// { observing: true }
// await window.Capacitor.Plugins.CoachWorkoutObserver.stopObserving()
// { observing: false }
//
// Capability Xcode requise : Signing & Capabilities HealthKit
// cocher "Background Delivery". Sans ça, iOS ne réveille pas l'app.
import Foundation
import Capacitor
import HealthKit
import UserNotifications
// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition
// du plan dont une séance est issue ou nil si elle a été lancée à la main.
import WorkoutKit
@objc(CoachWorkoutObserverPlugin)
public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachWorkoutObserverPlugin"
public let jsName = "CoachWorkoutObserver"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "startObserving", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopObserving", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "testNotification", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resetAnchor", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestAllHealthPermissions", returnType: CAPPluginReturnPromise),
]
// Clés UserDefaults pour debug visible côté JS via getStatus()
private let kFireCount = "CoachWorkoutObserver.fireCount"
private let kLastFireAt = "CoachWorkoutObserver.lastFireAt"
private let kLastNotifAt = "CoachWorkoutObserver.lastNotifAt"
private let kLastError = "CoachWorkoutObserver.lastError"
private let kBgDeliveryEnabled = "CoachWorkoutObserver.bgDeliveryEnabled"
private let kObserverActive = "CoachWorkoutObserver.observerActive"
private let healthStore = HKHealthStore()
private var observerQuery: HKObserverQuery?
// Clé UserDefaults pour persister l'UUID du dernier workout notifié.
// Évite les doublons si iOS appelle l'observer plusieurs fois pour
// le même workout (ce qui arrive).
private let lastNotifiedKey = "CoachWorkoutObserver.lastNotifiedUUID"
// Clé UserDefaults pour persister le HKQueryAnchor entre runs.
// HKAnchoredObjectQuery est l'API Apple-recommandée pour ne récupérer
// QUE les nouveaux samples ajoutés depuis le dernier check, plutôt
// qu'un predicate temporel qui peut louper des workouts.
private let kAnchor = "CoachWorkoutObserver.anchorData"
@objc func startObserving(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.reject("HealthKit not available on this device")
return
}
Task {
do {
try await requestPermissionsIfNeeded()
try await enableBackgroundDelivery()
startObserverQuery()
call.resolve(["observing": true])
} catch {
call.reject("Observer setup failed: \(error.localizedDescription)")
}
}
}
@objc func stopObserving(_ call: CAPPluginCall) {
if let q = observerQuery {
healthStore.stop(q)
observerQuery = nil
}
UserDefaults.standard.set(false, forKey: kObserverActive)
let workoutType = HKObjectType.workoutType()
healthStore.disableBackgroundDelivery(for: workoutType) { _, _ in }
call.resolve(["observing": false])
}
// Debug expose les compteurs persistés pour visualiser ce que fait
// l'observer sans Console.app. Appelé depuis page /settings ou /debug.
@objc func getStatus(_ call: CAPPluginCall) {
let d = UserDefaults.standard
call.resolve([
"observerActive": d.bool(forKey: kObserverActive),
"bgDeliveryEnabled": d.bool(forKey: kBgDeliveryEnabled),
"fireCount": d.integer(forKey: kFireCount),
"lastFireAt": d.string(forKey: kLastFireAt) ?? "",
"lastNotifAt": d.string(forKey: kLastNotifAt) ?? "",
"lastWorkoutUUID": d.string(forKey: lastNotifiedKey) ?? "",
"lastError": d.string(forKey: kLastError) ?? "",
"healthDataAvailable": HKHealthStore.isHealthDataAvailable(),
])
}
// Bypass complet de @capgo/capacitor-health pour requestAuthorization.
// Le plugin tiers semble pendre indéfiniment sur iOS 26 (Promise jamais
// résolue, confirmé empiriquement 2026-05-18 avec timeout 8s même
// avec 1 seul type 'steps'). On appelle directement HKHealthStore
// depuis notre plugin custom qui fonctionne (CoachWorkoutObserver
// a fireCount=10 = preuve que HealthKit native marche pour cette app).
@objc func requestAllHealthPermissions(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.reject("HealthKit not available on this device")
return
}
// Types lus par coach.hypnotruck.ch alignés sur READ_SCOPES JS
var readTypes = Set<HKObjectType>()
let quantityIds: [HKQuantityTypeIdentifier] = [
.stepCount,
.heartRate,
.restingHeartRate,
.heartRateVariabilitySDNN,
.bodyMass,
.height,
.bodyFatPercentage,
.oxygenSaturation,
.distanceWalkingRunning,
.distanceCycling,
.activeEnergyBurned,
.basalEnergyBurned,
.appleExerciseTime,
.flightsClimbed,
]
for id in quantityIds {
if let t = HKObjectType.quantityType(forIdentifier: id) {
readTypes.insert(t)
}
}
let categoryIds: [HKCategoryTypeIdentifier] = [
.sleepAnalysis,
]
for id in categoryIds {
if let t = HKObjectType.categoryType(forIdentifier: id) {
readTypes.insert(t)
}
}
readTypes.insert(HKObjectType.workoutType())
let totalTypes = readTypes.count
NSLog("[CoachWorkoutObserver] requestAllHealthPermissions: requesting %d types", totalTypes)
healthStore.requestAuthorization(toShare: nil, read: readTypes) { success, error in
DispatchQueue.main.async {
if let error = error {
NSLog("[CoachWorkoutObserver] requestAuthorization error: %@", error.localizedDescription)
call.reject("requestAuthorization failed: \(error.localizedDescription)")
} else {
NSLog("[CoachWorkoutObserver] requestAuthorization OK success=%@", success ? "true" : "false")
call.resolve([
"success": success,
"typesRequested": totalTypes,
])
}
}
}
}
// Debug reset l'anchor stocké. Au prochain observer fire, on
// récupèrera TOUS les workouts (initial sync). Utile pour tester
// le pipeline avec un workout pré-existant.
@objc func resetAnchor(_ call: CAPPluginCall) {
UserDefaults.standard.removeObject(forKey: kAnchor)
UserDefaults.standard.removeObject(forKey: lastNotifiedKey)
call.resolve(["reset": true])
}
// Debug force une notif locale immédiate pour valider que le pipeline
// notif Apple fonctionne, indépendamment de l'observer HealthKit.
@objc func testNotification(_ call: CAPPluginCall) {
let content = UNMutableNotificationContent()
content.title = "🧪 Test CoachWorkoutObserver"
content.body = "Si tu vois ça, le pipeline notif natif fonctionne."
content.sound = .default
if #available(iOS 15.0, *) {
content.interruptionLevel = .timeSensitive
}
let req = UNNotificationRequest(
identifier: "coach_wo_test_\(Int(Date().timeIntervalSince1970))",
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(req) { error in
DispatchQueue.main.async {
if let error = error {
call.reject("Test notif failed: \(error.localizedDescription)")
} else {
call.resolve(["scheduled": true])
}
}
}
}
// MARK: - HealthKit setup
private func requestPermissionsIfNeeded() async throws {
let workoutType = HKObjectType.workoutType()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
healthStore.requestAuthorization(toShare: nil, read: [workoutType]) { _, error in
if let error = error {
cont.resume(throwing: error)
} else {
cont.resume()
}
}
}
}
private func enableBackgroundDelivery() async throws {
let workoutType = HKObjectType.workoutType()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { [weak self] ok, error in
if let error = error {
UserDefaults.standard.set("bg delivery: \(error.localizedDescription)", forKey: self?.kLastError ?? "")
cont.resume(throwing: error)
} else {
UserDefaults.standard.set(ok, forKey: self?.kBgDeliveryEnabled ?? "")
NSLog("[CoachWorkoutObserver] background delivery enabled=%@", ok ? "true" : "false")
cont.resume()
}
}
}
}
private func startObserverQuery() {
// Stop l'ancienne au cas où startObserving est rappelé (idempotent)
if let q = observerQuery {
healthStore.stop(q)
}
let workoutType = HKObjectType.workoutType()
let query = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] _, completion, error in
guard let self = self else { completion(); return }
if let error = error {
UserDefaults.standard.set("observer: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] query error: %@", error.localizedDescription)
completion()
return
}
// Persiste compteur + timestamp pour debug visible via getStatus
let d = UserDefaults.standard
d.set(d.integer(forKey: self.kFireCount) + 1, forKey: self.kFireCount)
d.set(ISO8601DateFormatter().string(from: Date()), forKey: self.kLastFireAt)
NSLog("[CoachWorkoutObserver] observer fired")
self.fetchLatestWorkout {
completion() // signaler iOS qu'on a fini de traiter (mandatory)
}
}
healthStore.execute(query)
observerQuery = query
UserDefaults.standard.set(true, forKey: kObserverActive)
NSLog("[CoachWorkoutObserver] observer query started")
}
// MARK: - Fetch + notif
private func fetchLatestWorkout(completion: @escaping () -> Void) {
let workoutType = HKObjectType.workoutType()
let savedAnchor = loadAnchor()
let query = HKAnchoredObjectQuery(
type: workoutType,
predicate: nil,
anchor: savedAnchor,
limit: HKObjectQueryNoLimit
) { [weak self] _, samples, _, newAnchor, error in
defer { completion() }
guard let self = self else { return }
if let error = error {
UserDefaults.standard.set("anchored: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] anchored query error: %@", error.localizedDescription)
return
}
// Persiste le nouvel anchor pour le prochain call (clé du
// mécanisme : seuls les samples ajoutés APRÈS cet anchor
// seront retournés au prochain query).
if let newAnchor = newAnchor {
self.saveAnchor(newAnchor)
}
let workouts = (samples as? [HKWorkout]) ?? []
// Au 1er fire après installation (anchor était nil), on a pu
// récupérer un GROS historique de workouts iCloud. On
// enregistre juste l'anchor sans notifier pour éviter de
// spammer l'user avec des séances de l'année dernière.
if savedAnchor == nil {
NSLog("[CoachWorkoutObserver] initial sync — anchor saved, skipped %d historical workouts", workouts.count)
return
}
guard !workouts.isEmpty else {
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
return
}
// Notifier le plus récent (par endDate) généralement il y
// en a 1 mais Apple peut grouper plusieurs samples.
let latest = workouts.max(by: { $0.endDate < $1.endDate })!
let uuid = latest.uuid.uuidString
UserDefaults.standard.set(uuid, forKey: self.lastNotifiedKey)
NSLog("[CoachWorkoutObserver] %d new workout(s), notifying latest %@", workouts.count, uuid)
self.scheduleNotification(for: latest)
}
healthStore.execute(query)
}
// MARK: - Anchor persistence (NSKeyedArchiver pour HKQueryAnchor)
private func loadAnchor() -> HKQueryAnchor? {
guard let data = UserDefaults.standard.data(forKey: kAnchor) else { return nil }
do {
return try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
} catch {
NSLog("[CoachWorkoutObserver] anchor unarchive failed: %@", error.localizedDescription)
return nil
}
}
private func saveAnchor(_ anchor: HKQueryAnchor) {
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
UserDefaults.standard.set(data, forKey: kAnchor)
} catch {
NSLog("[CoachWorkoutObserver] anchor archive failed: %@", error.localizedDescription)
}
}
/// Remonte au serveur la fin d'une séance de renfo, qui décide seul si elle
/// correspond à une routine poussée dans l'app Exercice.
///
/// Volontairement bête : on n'envoie que des faits bruts (type, durée). Les
/// règles de correspondance fenêtre de 12 h depuis l'envoi explicite,
/// tolérance de durée vivent dans `/api/routine/auto-complete`, donc
/// ajustables sans rebuild iOS.
private func reportToRoutineIfStrength(_ workout: HKWorkout) {
let activity = workout.workoutActivityType
guard activity == .functionalStrengthTraining
|| activity == .traditionalStrengthTraining else { return }
guard let url = URL(string: "https://coach.hypnotruck.ch/api/routine/auto-complete") else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"activity": "functionalStrengthTraining",
"duration_min": workout.duration / 60,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] auto-complete routine: %@", error.localizedDescription)
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
if (json["matched"] as? Bool) == true {
let n = (json["done"] as? [String])?.count ?? 0
NSLog("[CoachWorkoutObserver] routine '%@' cochée automatiquement (%d exercices)",
(json["moment"] as? String) ?? "?", n)
} else {
NSLog("[CoachWorkoutObserver] pas une routine (%@)", (json["reason"] as? String) ?? "?")
}
}.resume()
}
/// Dit au serveur si la séance vient d'une **séance programmée** ou d'un
/// démarrage à la main dans l'app Exercice.
///
/// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle
/// devait parler » de « la séance n'a jamais été celle qu'on avait
/// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à
/// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été
/// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »).
/// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les
/// deux cas.
///
/// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des
/// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS.
private func reportPlanOrigin(_ workout: HKWorkout) {
guard #available(iOS 17.0, *) else { return }
Task {
var fromPlan = false
var planName = ""
do {
if let plan = try await workout.workoutPlan {
fromPlan = true
if case let .custom(custom) = plan.workout {
planName = custom.displayName ?? ""
}
}
} catch {
// Une lecture qui échoue n'est PAS un « lancé à la main » :
// sans réponse, on n'envoie rien plutôt qu'un faux négatif.
NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription)
return
}
guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: [
"uuid": workout.uuid.uuidString,
"start": ISO8601DateFormatter().string(from: workout.startDate),
"duration_min": workout.duration / 60,
"activity": Int(workout.workoutActivityType.rawValue),
"from_plan": fromPlan,
"plan_name": planName,
])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { _, _, error in
if let error = error {
NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription)
} else {
NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false")
}
}.resume()
}
}
private func scheduleNotification(for workout: HKWorkout) {
reportToRoutineIfStrength(workout)
reportPlanOrigin(workout)
let durationMin = Int(workout.duration / 60)
var bodyParts: [String] = ["\(durationMin) min"]
if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {
bodyParts.append("\(Int(kcal)) kcal")
}
if let dist = workout.totalDistance?.doubleValue(for: .meter()), dist > 100 {
let km = dist / 1000
bodyParts.append(String(format: "%.1f km", km))
}
let typeName = workoutActivityName(workout.workoutActivityType)
let body = bodyParts.joined(separator: " · ")
let content = UNMutableNotificationContent()
content.title = "🎉 Séance \(typeName) terminée"
content.body = "Bravo ! \(body)"
content.sound = .default
// .timeSensitive : force iOS à afficher la notif même en Focus modéré
// et en foreground app. Sans ça la notif peut être suppress
// silencieusement.
if #available(iOS 15.0, *) {
content.interruptionLevel = .timeSensitive
}
let request = UNNotificationRequest(
identifier: "coach_workout_done_\(workout.uuid.uuidString)",
content: content,
trigger: nil // déclenchement immédiat
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
guard let self = self else { return }
if let error = error {
UserDefaults.standard.set("notif: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] notif scheduling failed: %@", error.localizedDescription)
} else {
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: self.kLastNotifAt)
NSLog("[CoachWorkoutObserver] notif scheduled for workout %@", workout.uuid.uuidString)
}
}
}
private func workoutActivityName(_ activity: HKWorkoutActivityType) -> String {
switch activity {
case .running: return "course"
case .cycling: return "vélo"
case .walking: return "marche"
case .hiking: return "rando"
case .swimming: return "natation"
case .functionalStrengthTraining, .traditionalStrengthTraining: return "renfo"
case .coreTraining: return "gainage"
case .yoga: return "yoga"
case .pilates: return "pilates"
case .flexibility: return "mobilité"
case .highIntensityIntervalTraining: return "HIIT"
case .crossTraining: return "cross-training"
case .rowing: return "aviron"
case .skatingSports: return "patinage"
case .paddleSports: return "paddle"
default: return "sport"
}
}
}