From e62e4803e4d90e661455e209256b00a6ae35a7fc Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 18 May 2026 12:29:22 +0000 Subject: [PATCH] feat(workout-observer): debug status + test notif + timeSensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumentation pour debug visible côté JS sans Console.app. Nouvelles méthodes exposées : - getStatus() → { observerActive, bgDeliveryEnabled, fireCount, lastFireAt, lastNotifAt, lastWorkoutUUID, lastError, healthDataAvailable } - testNotification() → force une notif locale immédiate via UNUserNotificationCenter pour valider le pipeline notif natif, indépendant de l'observer HealthKit. Persistance UserDefaults : - fireCount incrémenté à chaque observer.update callback - lastFireAt / lastNotifAt / lastError timestamps ISO8601 - observerActive / bgDeliveryEnabled bools pour état courant Fix notif : - interruptionLevel = .timeSensitive (iOS 15+) sur les notifs "Séance terminée" pour qu'iOS les affiche même en foreground app ou Focus modéré. Sans ça les bannières peuvent être suppress silencieusement par iOS. Bump version requis côté Mac avant rebuild TestFlight. --- ios/App/App/CoachWorkoutObserver.swift | 77 +++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/ios/App/App/CoachWorkoutObserver.swift b/ios/App/App/CoachWorkoutObserver.swift index 54b13de..eaa297a 100644 --- a/ios/App/App/CoachWorkoutObserver.swift +++ b/ios/App/App/CoachWorkoutObserver.swift @@ -33,8 +33,18 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "startObserving", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopObserving", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "testNotification", 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? @@ -65,11 +75,54 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { 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(), + ]) + } + + // 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 { @@ -88,10 +141,12 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { private func enableBackgroundDelivery() async throws { let workoutType = HKObjectType.workoutType() try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { ok, 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() } @@ -106,18 +161,25 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { } 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 { + 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") } @@ -174,16 +236,25 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { 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) { error in + 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) } }