Files
coach-ios/ios/App/App/CoachWorkoutObserver.swift
Sylvain Bettinelli f315c5471e feat(workout-observer): bypass @capgo via requestAllHealthPermissions
Le plugin tiers @capgo/capacitor-health v8.4.9 a un bug iOS 26 :
Health.requestAuthorization pend indéfiniment (Promise jamais
résolue, confirmé empiriquement 2026-05-18 — timeout 8s même avec
1 seul type 'steps' + désinstall/réinstall complet inefficace).

Workaround : nouvelle méthode requestAllHealthPermissions exposée
sur notre plugin custom CoachWorkoutObserver (qui fonctionne
empiriquement — fireCount=10 confirme que HealthKit native marche).

Appelle directement HKHealthStore.requestAuthorization avec un
Set<HKObjectType> construit en Swift (14 quantity + 1 category
+ workout). Pas de string→enum mapping intermédiaire, pas de
parsing TypeScript→Swift, donc pas de chaîne où ça pourrait
silently fail comme dans @capgo.

Bump build requis côté Mac avant rebuild TestFlight build 22.
2026-05-18 16:35:42 +00:00

400 lines
17 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
@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)
}
}
private func scheduleNotification(for workout: HKWorkout) {
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"
}
}
}