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