From a21b067eaca52777a406c2107ea952ef33f95ad7 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 18 May 2026 12:54:22 +0000 Subject: [PATCH] fix(workout-observer): HKAnchoredObjectQuery au lieu de predicate 1h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) ✓ --- ios/App/App/CoachWorkoutObserver.swift | 84 ++++++++++++++++++++------ 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/ios/App/App/CoachWorkoutObserver.swift b/ios/App/App/CoachWorkoutObserver.swift index eaa297a..a166af7 100644 --- a/ios/App/App/CoachWorkoutObserver.swift +++ b/ios/App/App/CoachWorkoutObserver.swift @@ -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"]