1 contributor
import Foundation
import HealthKit
import SwiftData
import os.log
private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "ObserverService")
// Without background observation, a HealthKit deletion followed by reinsertion between two
// manual snapshots is completely invisible. HKObserverQuery with background delivery closes this gap.
// Note: HKObserverQuery signals that something changed but does not identify what changed.
// Actual detection still comes from the next full snapshot + delta comparison.
final class ObserverService {
static let shared = ObserverService()
// Minimum interval between observer-triggered snapshots — manual snapshots bypass this entirely.
private static let debounceIntervalSeconds: TimeInterval = 600 // 10 minutes
private var observerQueries: [HKObserverQuery] = []
private var debounceTask: Task<Void, Never>?
private var lastCallbackTimestamp: Date?
private var accumulatedTypeIDs: Set<String> = []
private let lock = NSLock()
private weak var modelContainer: ModelContainer?
private var selectedTypeIDs: Set<String> = []
func startObserving(types: [HKObjectType], store: HKHealthStore, container: ModelContainer, selectedTypeIDs: Set<String>) {
self.modelContainer = container
self.selectedTypeIDs = selectedTypeIDs
for objectType in types {
let query = HKObserverQuery(sampleType: objectType as! HKSampleType, predicate: nil) { [weak self] _, completionHandler, error in
// Always call first — HealthKit re-fires indefinitely if not called
defer { completionHandler() }
// Schedule snapshot task separately; failure is logged, not fatal
if let error {
logger.error("ObserverQuery error for \(objectType.identifier): \(error)")
return
}
self?.handleObserverCallback(typeID: objectType.identifier)
}
store.execute(query)
// Frequency: .immediate for critical types, .daily for others
let frequency: HKUpdateFrequency = isCriticalType(objectType.identifier) ? .immediate : .daily
store.enableBackgroundDelivery(for: objectType, frequency: frequency) { success, error in
if !success {
logger.error("Failed to enable background delivery for \(objectType.identifier): \(String(describing: error))")
}
}
observerQueries.append(query)
}
}
// MARK: - Callback handling
private func handleObserverCallback(typeID: String) {
let alreadyScheduled = lock.withLock {
let now = Date()
lastCallbackTimestamp = now
accumulatedTypeIDs.insert(typeID)
return debounceTask != nil
}
guard !alreadyScheduled else { return }
debounceTask = Task { [weak self] in
guard let self else { return }
// Wait out the debounce window
try? await Task.sleep(nanoseconds: UInt64(Self.debounceIntervalSeconds * 1_000_000_000))
await self.tryCreateObserverSnapshot()
}
}
@MainActor
private func tryCreateObserverSnapshot() async {
lock.withLock {
debounceTask = nil
}
guard let container = modelContainer else {
logger.error("ObserverService: no modelContainer — cannot create snapshot")
return
}
// Manual overlap suppression: if a manual snapshot was created during the debounce window,
// cancel the observer snapshot to avoid a redundant .unchanged delta.
let context = ModelContext(container)
if let lastCallback = lastCallbackTimestamp {
let descriptor = FetchDescriptor<HealthSnapshot>(
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
let recent = try? context.fetch(descriptor)
if let latestManual = recent?.first(where: { $0.triggerReason == "manual" }),
latestManual.timestamp > lastCallback {
logger.info("ObserverService: suppressed — manual snapshot captured during debounce window")
return
}
}
// Create one consolidated snapshot covering all monitored types
do {
let snapshot = try await HealthKitService.shared.createSnapshot(
in: context,
selectedTypeIDs: selectedTypeIDs,
adaptiveTimeoutsEnabled: true,
triggerReason: "observerCallback"
)
logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
} catch {
logger.error("ObserverService: failed to create snapshot — \(error)")
}
lock.withLock {
accumulatedTypeIDs.removeAll()
lastCallbackTimestamp = nil
}
}
// MARK: - Type classification
private func isCriticalType(_ typeID: String) -> Bool {
let critical: Set<String> = Set([
HKQuantityType.quantityType(forIdentifier: .heartRate)?.identifier,
HKQuantityType.quantityType(forIdentifier: .stepCount)?.identifier,
].compactMap { $0 })
return critical.contains(typeID)
}
}