HealthProbe / HealthProbe / Services / ObserverService.swift
Newer Older
129 lines | 5.373kb
Bogdan Timofte authored a month ago
1
import Foundation
2
import HealthKit
3
import SwiftData
4
import os.log
5

            
6
private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "ObserverService")
7

            
8
// Without background observation, a HealthKit deletion followed by reinsertion between two
9
// manual snapshots is completely invisible. HKObserverQuery with background delivery closes this gap.
10
// Note: HKObserverQuery signals that something changed but does not identify what changed.
11
// Actual detection still comes from the next full snapshot + delta comparison.
12
final class ObserverService {
13
    static let shared = ObserverService()
14

            
15
    // Minimum interval between observer-triggered snapshots — manual snapshots bypass this entirely.
16
    private static let debounceIntervalSeconds: TimeInterval = 600  // 10 minutes
17

            
18
    private var observerQueries: [HKObserverQuery] = []
19
    private var debounceTask: Task<Void, Never>?
20
    private var lastCallbackTimestamp: Date?
21
    private var accumulatedTypeIDs: Set<String> = []
22
    private let lock = NSLock()
23

            
24
    private weak var modelContainer: ModelContainer?
25
    private var selectedTypeIDs: Set<String> = []
26

            
27
    func startObserving(types: [HKObjectType], store: HKHealthStore, container: ModelContainer, selectedTypeIDs: Set<String>) {
28
        self.modelContainer = container
29
        self.selectedTypeIDs = selectedTypeIDs
30

            
31
        for objectType in types {
32
            let query = HKObserverQuery(sampleType: objectType as! HKSampleType, predicate: nil) { [weak self] _, completionHandler, error in
33
                // Always call first — HealthKit re-fires indefinitely if not called
34
                defer { completionHandler() }
35
                // Schedule snapshot task separately; failure is logged, not fatal
36
                if let error {
37
                    logger.error("ObserverQuery error for \(objectType.identifier): \(error)")
38
                    return
39
                }
40
                self?.handleObserverCallback(typeID: objectType.identifier)
41
            }
42
            store.execute(query)
43

            
44
            // Frequency: .immediate for critical types, .daily for others
45
            let frequency: HKUpdateFrequency = isCriticalType(objectType.identifier) ? .immediate : .daily
46
            store.enableBackgroundDelivery(for: objectType, frequency: frequency) { success, error in
47
                if !success {
48
                    logger.error("Failed to enable background delivery for \(objectType.identifier): \(String(describing: error))")
49
                }
50
            }
51
            observerQueries.append(query)
52
        }
53
    }
54

            
55
    // MARK: - Callback handling
56

            
57
    private func handleObserverCallback(typeID: String) {
Bogdan Timofte authored 3 weeks ago
58
        let alreadyScheduled = lock.withLock {
59
            let now = Date()
60
            lastCallbackTimestamp = now
61
            accumulatedTypeIDs.insert(typeID)
62
            return debounceTask != nil
63
        }
Bogdan Timofte authored a month ago
64

            
65
        guard !alreadyScheduled else { return }
66

            
67
        debounceTask = Task { [weak self] in
68
            guard let self else { return }
69
            // Wait out the debounce window
70
            try? await Task.sleep(nanoseconds: UInt64(Self.debounceIntervalSeconds * 1_000_000_000))
71
            await self.tryCreateObserverSnapshot()
72
        }
73
    }
74

            
75
    @MainActor
76
    private func tryCreateObserverSnapshot() async {
Bogdan Timofte authored 3 weeks ago
77
        lock.withLock {
78
            debounceTask = nil
79
        }
Bogdan Timofte authored a month ago
80

            
81
        guard let container = modelContainer else {
82
            logger.error("ObserverService: no modelContainer — cannot create snapshot")
83
            return
84
        }
85

            
86
        // Manual overlap suppression: if a manual snapshot was created during the debounce window,
87
        // cancel the observer snapshot to avoid a redundant .unchanged delta.
88
        let context = ModelContext(container)
89
        if let lastCallback = lastCallbackTimestamp {
90
            let descriptor = FetchDescriptor<HealthSnapshot>(
91
                sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
92
            )
93
            let recent = try? context.fetch(descriptor)
94
            if let latestManual = recent?.first(where: { $0.triggerReason == "manual" }),
95
               latestManual.timestamp > lastCallback {
96
                logger.info("ObserverService: suppressed — manual snapshot captured during debounce window")
97
                return
98
            }
99
        }
100

            
101
        // Create one consolidated snapshot covering all monitored types
102
        do {
103
            let snapshot = try await HealthKitService.shared.createSnapshot(
104
                in: context,
Bogdan Timofte authored a month ago
105
                selectedTypeIDs: selectedTypeIDs,
106
                adaptiveTimeoutsEnabled: true,
107
                triggerReason: "observerCallback"
Bogdan Timofte authored a month ago
108
            )
109
            logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
110
        } catch {
111
            logger.error("ObserverService: failed to create snapshot — \(error)")
112
        }
113

            
Bogdan Timofte authored 3 weeks ago
114
        lock.withLock {
115
            accumulatedTypeIDs.removeAll()
116
            lastCallbackTimestamp = nil
117
        }
Bogdan Timofte authored a month ago
118
    }
119

            
120
    // MARK: - Type classification
121

            
122
    private func isCriticalType(_ typeID: String) -> Bool {
123
        let critical: Set<String> = Set([
124
            HKQuantityType.quantityType(forIdentifier: .heartRate)?.identifier,
125
            HKQuantityType.quantityType(forIdentifier: .stepCount)?.identifier,
126
        ].compactMap { $0 })
127
        return critical.contains(typeID)
128
    }
129
}