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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user