La Live Activity restait affichée après la fin de séance

La montre n'annonçait pas la fin : le flux de samples s'arrêtait, et
l'iPhone ne pouvait pas distinguer « séance terminée » de « montre hors de
portée ». La Live Activity restait donc figée sur la dernière FC reçue —
un cœur à 74 bpm en permanence dans la Dynamic Island.

Trois causes cumulées, toutes corrigées :

1. Aucun message de fin. WorkoutManager envoie maintenant `liveEnded` à
   l'arrêt de la session HealthKit, en cas d'échec, et en mode test.
2. Le seul chemin de terminaison côté iPhone était le watchdog de
   LiveStore, un Timer du main runloop — qui ne tourne pas quand l'app est
   suspendue en arrière-plan, c'est-à-dire pendant toute la séance.
   `endLive()` termine désormais l'activité dès réception du message.
3. `dismissalPolicy: .default` laissait la bannière ~4 h APRÈS la fin.
   Passé en `.immediate`.

Filet de sécurité : `adoptExisting()` au lancement reprend la main sur une
activité orpheline (app tuée pendant une séance, `current` perdu) et
termine celles de plus de 6 h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 12:50:32 +00:00
parent 051c8e8ff4
commit 5e9b4a3df6
5 changed files with 99 additions and 7 deletions

View File

@@ -31,6 +31,9 @@ public class CoachLiveBridgePlugin: CAPPlugin, CAPBridgedPlugin {
override public func load() { override public func load() {
super.load() super.load()
if #available(iOS 16.2, *) {
DispatchQueue.main.async { LiveActivityManager.shared.adoptExisting() }
}
guard WCSession.isSupported() else { guard WCSession.isSupported() else {
CAPLog.print("⚡️ CoachLiveBridge: WCSession non supporté sur cet appareil") CAPLog.print("⚡️ CoachLiveBridge: WCSession non supporté sur cet appareil")
return return
@@ -126,10 +129,24 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
/// traiter comme des samples (ce qui polluerait LiveStore). /// traiter comme des samples (ce qui polluerait LiveStore).
/// Retourne `true` si le message a été routé ailleurs. /// Retourne `true` si le message a été routé ailleurs.
private func routeIfNotLiveSample(_ message: [String: Any]) -> Bool { private func routeIfNotLiveSample(_ message: [String: Any]) -> Bool {
guard message["routineDone"] != nil else { return false } if message["routineDone"] != nil {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .coachRoutineFromWatch, object: nil, userInfo: message name: .coachRoutineFromWatch, object: nil, userInfo: message
) )
return true return true
} }
// Fin de séance annoncée par la montre. Sans ce signal, l'arrêt du flux
// est indistinguable d'une perte de portée : la Live Activity restait
// figée sur la dernière FC.
if message["liveEnded"] as? Bool == true {
let reason = (message["reason"] as? String) ?? "unknown"
liveLog.info("fin de séance reçue de la Watch (reason=\(reason, privacy: .public))")
DispatchQueue.main.async { [weak self] in
self?.notifyListeners("liveEnded", data: message)
}
LiveStore.shared.endLive()
return true
}
return false
}
} }

View File

@@ -20,6 +20,24 @@ final class LiveActivityManager {
private init() {} private init() {}
/// Reprend la main sur une activité laissée par une exécution précédente.
///
/// Si l'app iPhone est tuée pendant une séance, `current` repart à nil au
/// lancement suivant : `end()` devenait un no-op et l'activité restait
/// affichée indéfiniment. À appeler une fois au démarrage.
/// Au-delà de 6 h, aucune séance n'est plausible : on termine.
func adoptExisting() {
for activity in Activity<CoachLiveActivityAttributes>.activities {
if Date().timeIntervalSince(activity.attributes.startedAt) > 6 * 3600 {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
Self.log.info("Live Activity orpheline (>6h) terminée")
} else if current == nil {
current = activity
Self.log.info("Live Activity existante adoptée")
}
}
}
private var isAuthorized: Bool { private var isAuthorized: Bool {
ActivityAuthorizationInfo().areActivitiesEnabled ActivityAuthorizationInfo().areActivitiesEnabled
} }
@@ -67,11 +85,15 @@ final class LiveActivityManager {
Task { await activity.update(content) } Task { await activity.update(content) }
} }
/// Termine l'activité. dismissalPolicy .default = bannière reste ~4h. /// Termine l'activité et la retire de l'écran immédiatement.
/// `.default` laisse la bannière ~4 h après la fin : la séance est
/// terminée mais le cœur reste affiché avec la dernière FC reçue, ce qui se
/// lit comme une séance encore en cours. `.immediate` est le seul
/// comportement correct ici.
func end() { func end() {
guard let activity = current else { return } guard let activity = current else { return }
current = nil current = nil
Task { await activity.end(nil, dismissalPolicy: .default) } Task { await activity.end(nil, dismissalPolicy: .immediate) }
Self.log.info("Live Activity ended") Self.log.info("Live Activity ended")
} }
} }

View File

@@ -95,6 +95,23 @@ final class LiveStore: ObservableObject {
} }
} }
/// Fin de séance annoncée par la montre (`liveEnded`). Termine la Live
/// Activity tout de suite, sans attendre le watchdog celui-ci repose sur
/// un Timer du main runloop, qui ne tourne pas quand l'app iPhone est
/// suspendue en arrière-plan (le cas normal pendant une séance).
/// nonisolated : appelé depuis un callback WCSession, hors main.
nonisolated func endLive() {
Task { @MainActor [weak self] in
guard let self else { return }
self.status = .waiting
self.lostSince = nil
self.lastSampleAt = nil
if #available(iOS 16.2, *) {
LiveActivityManager.shared.end()
}
}
}
@available(iOS 16.2, *) @available(iOS 16.2, *)
private func currentState(isLive: Bool) -> CoachLiveActivityAttributes.ContentState { private func currentState(isLive: Bool) -> CoachLiveActivityAttributes.ContentState {
CoachLiveActivityAttributes.ContentState( CoachLiveActivityAttributes.ContentState(

View File

@@ -48,6 +48,35 @@ final class ConnectivityManager: NSObject, ObservableObject {
} }
} }
/// Signale la FIN de séance à l'iPhone.
///
/// Sans ce message, l'iPhone ne peut pas distinguer « séance terminée » de
/// « montre hors de portée » : le flux de samples s'arrête dans les deux
/// cas. Sa Live Activity restait alors figée sur la dernière FC reçue.
/// Les samples encore en attente partent AVANT la fin (flushLocked) pour
/// que l'ordre d'arrivée reste cohérent.
func sendEnded(reason: String = "user") {
queue.async {
let session = WCSession.default
guard session.activationState == .activated else { return }
self.flushLocked()
let payload: [String: Any] = [
"liveEnded": true,
"reason": reason,
"ts": Date().timeIntervalSince1970,
]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
// Échec temps réel : livraison différée garantie.
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("fin de séance signalée à l'iPhone (reason=\(reason, privacy: .public))")
}
}
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe /// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche /// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
/// alors un bouton « Renvoyer »). /// alors un bouton « Renvoyer »).

View File

@@ -97,6 +97,9 @@ final class WorkoutManager: NSObject, ObservableObject {
self.simTask = nil self.simTask = nil
isRunning = false isRunning = false
statusMessage = nil statusMessage = nil
// Le mode test ne passe pas par HKWorkoutSession : c'est ici le
// seul endroit où l'iPhone peut apprendre que le flux s'arrête.
ConnectivityManager.shared.sendEnded(reason: "sim")
} else { } else {
session?.end() session?.end()
} }
@@ -210,6 +213,9 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
self.isRunning = false self.isRunning = false
self.session = nil self.session = nil
self.builder = nil self.builder = nil
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
// affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit.
ConnectivityManager.shared.sendEnded(reason: "session-ended")
} }
} }
@@ -223,6 +229,7 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
self.isRunning = false self.isRunning = false
self.session = nil self.session = nil
self.builder = nil self.builder = nil
ConnectivityManager.shared.sendEnded(reason: "session-failed")
} }
} }
} }