feat(workout-observer): debug status + test notif + timeSensitive

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.
This commit is contained in:
Sylvain Bettinelli
2026-05-18 12:29:22 +00:00
parent 33ed74ca63
commit e62e4803e4

View File

@@ -33,8 +33,18 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "startObserving", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startObserving", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stopObserving", 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 let healthStore = HKHealthStore()
private var observerQuery: HKObserverQuery? private var observerQuery: HKObserverQuery?
@@ -65,11 +75,54 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
healthStore.stop(q) healthStore.stop(q)
observerQuery = nil observerQuery = nil
} }
UserDefaults.standard.set(false, forKey: kObserverActive)
let workoutType = HKObjectType.workoutType() let workoutType = HKObjectType.workoutType()
healthStore.disableBackgroundDelivery(for: workoutType) { _, _ in } healthStore.disableBackgroundDelivery(for: workoutType) { _, _ in }
call.resolve(["observing": false]) 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 // MARK: - HealthKit setup
private func requestPermissionsIfNeeded() async throws { private func requestPermissionsIfNeeded() async throws {
@@ -88,10 +141,12 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
private func enableBackgroundDelivery() async throws { private func enableBackgroundDelivery() async throws {
let workoutType = HKObjectType.workoutType() let workoutType = HKObjectType.workoutType()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) 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 { if let error = error {
UserDefaults.standard.set("bg delivery: \(error.localizedDescription)", forKey: self?.kLastError ?? "")
cont.resume(throwing: error) cont.resume(throwing: error)
} else { } else {
UserDefaults.standard.set(ok, forKey: self?.kBgDeliveryEnabled ?? "")
NSLog("[CoachWorkoutObserver] background delivery enabled=%@", ok ? "true" : "false") NSLog("[CoachWorkoutObserver] background delivery enabled=%@", ok ? "true" : "false")
cont.resume() cont.resume()
} }
@@ -106,18 +161,25 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
} }
let workoutType = HKObjectType.workoutType() let workoutType = HKObjectType.workoutType()
let query = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] _, completion, error in let query = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] _, completion, error in
guard let self = self else { completion(); return }
if let error = error { if let error = error {
UserDefaults.standard.set("observer: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] query error: %@", error.localizedDescription) NSLog("[CoachWorkoutObserver] query error: %@", error.localizedDescription)
completion() completion()
return 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") NSLog("[CoachWorkoutObserver] observer fired")
self?.fetchLatestWorkout { self.fetchLatestWorkout {
completion() // signaler iOS qu'on a fini de traiter (mandatory) completion() // signaler iOS qu'on a fini de traiter (mandatory)
} }
} }
healthStore.execute(query) healthStore.execute(query)
observerQuery = query observerQuery = query
UserDefaults.standard.set(true, forKey: kObserverActive)
NSLog("[CoachWorkoutObserver] observer query started") NSLog("[CoachWorkoutObserver] observer query started")
} }
@@ -174,16 +236,25 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
content.title = "🎉 Séance \(typeName) terminée" content.title = "🎉 Séance \(typeName) terminée"
content.body = "Bravo ! \(body)" content.body = "Bravo ! \(body)"
content.sound = .default 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( let request = UNNotificationRequest(
identifier: "coach_workout_done_\(workout.uuid.uuidString)", identifier: "coach_workout_done_\(workout.uuid.uuidString)",
content: content, content: content,
trigger: nil // déclenchement immédiat 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 { if let error = error {
UserDefaults.standard.set("notif: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] notif scheduling failed: %@", error.localizedDescription) NSLog("[CoachWorkoutObserver] notif scheduling failed: %@", error.localizedDescription)
} else { } else {
UserDefaults.standard.set(ISO8601DateFormatter().string(from: Date()), forKey: self.kLastNotifAt)
NSLog("[CoachWorkoutObserver] notif scheduled for workout %@", workout.uuid.uuidString) NSLog("[CoachWorkoutObserver] notif scheduled for workout %@", workout.uuid.uuidString)
} }
} }