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: "stopObserving", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "testNotification", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "testNotification", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "resetAnchor", returnType: CAPPluginReturnPromise),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Clés UserDefaults pour debug visible côté JS via getStatus()
|
// 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
|
// Évite les doublons si iOS appelle l'observer plusieurs fois pour
|
||||||
// le même workout (ce qui arrive).
|
// le même workout (ce qui arrive).
|
||||||
private let lastNotifiedKey = "CoachWorkoutObserver.lastNotifiedUUID"
|
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) {
|
@objc func startObserving(_ call: CAPPluginCall) {
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
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
|
// Debug — force une notif locale immédiate pour valider que le pipeline
|
||||||
// notif Apple fonctionne, indépendamment de l'observer HealthKit.
|
// notif Apple fonctionne, indépendamment de l'observer HealthKit.
|
||||||
@objc func testNotification(_ call: CAPPluginCall) {
|
@objc func testNotification(_ call: CAPPluginCall) {
|
||||||
@@ -187,38 +202,71 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
private func fetchLatestWorkout(completion: @escaping () -> Void) {
|
private func fetchLatestWorkout(completion: @escaping () -> Void) {
|
||||||
let workoutType = HKObjectType.workoutType()
|
let workoutType = HKObjectType.workoutType()
|
||||||
// Filtre 1h pour limiter le scope (un workout très ancien remonté
|
let savedAnchor = loadAnchor()
|
||||||
// par un sync HealthKit tardif ne devrait pas spammer une notif).
|
let query = HKAnchoredObjectQuery(
|
||||||
let pred = HKQuery.predicateForSamples(withStart: Date(timeIntervalSinceNow: -3600), end: nil)
|
type: workoutType,
|
||||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
predicate: nil,
|
||||||
let query = HKSampleQuery(
|
anchor: savedAnchor,
|
||||||
sampleType: workoutType,
|
limit: HKObjectQueryNoLimit
|
||||||
predicate: pred,
|
) { [weak self] _, samples, _, newAnchor, error in
|
||||||
limit: 1,
|
|
||||||
sortDescriptors: [sort]
|
|
||||||
) { [weak self] _, samples, error in
|
|
||||||
defer { completion() }
|
defer { completion() }
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let error = error {
|
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
|
return
|
||||||
}
|
}
|
||||||
guard let workout = samples?.first as? HKWorkout else {
|
// Persiste le nouvel anchor pour le prochain call (clé du
|
||||||
NSLog("[CoachWorkoutObserver] no workout in last hour")
|
// 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
|
return
|
||||||
}
|
}
|
||||||
let uuid = workout.uuid.uuidString
|
guard !workouts.isEmpty else {
|
||||||
let lastUuid = UserDefaults.standard.string(forKey: self.lastNotifiedKey) ?? ""
|
NSLog("[CoachWorkoutObserver] anchored fired but 0 new workouts")
|
||||||
if uuid == lastUuid {
|
|
||||||
NSLog("[CoachWorkoutObserver] workout %@ already notified, skip", uuid)
|
|
||||||
return
|
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)
|
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)
|
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) {
|
private func scheduleNotification(for workout: HKWorkout) {
|
||||||
let durationMin = Int(workout.duration / 60)
|
let durationMin = Int(workout.duration / 60)
|
||||||
var bodyParts: [String] = ["\(durationMin) min"]
|
var bodyParts: [String] = ["\(durationMin) min"]
|
||||||
|
|||||||
Reference in New Issue
Block a user