fix(workout-observer): HKAnchoredObjectQuery au lieu de predicate 1h

Diagnostic via getStatus build 18 : fireCount=10 mais lastNotifAt=""
et lastWorkoutUUID="" → l'observer fire mais fetchLatestWorkout ne
trouve aucun workout. Le predicate
HKQuery.predicateForSamples(withStart: now-1h, end: nil) filtre par
startDate, ce qui rate les workouts dont startDate est antérieur à
now-1h (ex: workout long en cours, ou workouts sync depuis iCloud
historique).

Refactor : HKAnchoredObjectQuery (API Apple-recommandée pour ce cas).
- Au boot, charge l'anchor stocké via NSKeyedArchiver/Unarchiver
- Au 1er fire (anchor nil), enregistre le nouvel anchor SANS notifier
  (skip back-historize : évite spam si HealthKit sync un gros
  historique iCloud)
- Aux fires suivants, retourne uniquement les samples ajoutés depuis
  l'anchor précédent → notif sur le plus récent par endDate
- Persistance anchor via UserDefaults (NSKeyedArchiver requiringSecureCoding)

Nouvelle méthode resetAnchor() exposée au JS pour permettre le reset
côté debug — utile pour tester avec un workout pré-existant.

Signatures vérifiées via JSON DocC Apple :
- HKAnchoredObjectQuery(type:predicate:anchor:limit:resultsHandler:) ✓
- HKObjectQueryNoLimit (var global Int) ✓
This commit is contained in:
Sylvain Bettinelli
2026-05-18 12:54:22 +00:00
parent e62e4803e4
commit a21b067eac

View File

@@ -35,6 +35,7 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "stopObserving", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "testNotification", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resetAnchor", returnType: CAPPluginReturnPromise),
]
// Clés UserDefaults pour debug visible côté JS via getStatus()
@@ -52,6 +53,11 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
// É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 {
@@ -97,6 +103,15 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
])
}
// 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) {
@@ -187,38 +202,71 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
private func fetchLatestWorkout(completion: @escaping () -> Void) {
let workoutType = HKObjectType.workoutType()
// Filtre 1h pour limiter le scope (un workout très ancien remonté
// par un sync HealthKit tardif ne devrait pas spammer une notif).
let pred = HKQuery.predicateForSamples(withStart: Date(timeIntervalSinceNow: -3600), end: nil)
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let query = HKSampleQuery(
sampleType: workoutType,
predicate: pred,
limit: 1,
sortDescriptors: [sort]
) { [weak self] _, samples, error in
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 {
NSLog("[CoachWorkoutObserver] sample query error: %@", error.localizedDescription)
UserDefaults.standard.set("anchored: \(error.localizedDescription)", forKey: self.kLastError)
NSLog("[CoachWorkoutObserver] anchored query error: %@", error.localizedDescription)
return
}
guard let workout = samples?.first as? HKWorkout else {
NSLog("[CoachWorkoutObserver] no workout in last hour")
// 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
}
let uuid = workout.uuid.uuidString
let lastUuid = UserDefaults.standard.string(forKey: self.lastNotifiedKey) ?? ""
if uuid == lastUuid {
NSLog("[CoachWorkoutObserver] workout %@ already notified, skip", uuid)
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)
self.scheduleNotification(for: workout)
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"]