HealthProbe / HealthProbe / Services / ObserverService.swift
1 contributor
129 lines | 5.373kb
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)
    }
}