HealthProbe / HealthProbe / Services / HealthKitService.swift
1 contributor
504 lines | 21.355kb
import Foundation
import HealthKit
import SwiftData
import UIKit
import os.log

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

enum TypeCategory: String, CaseIterable {
    case activity    = "Activity"
    case heart       = "Heart"
    case respiratory = "Respiratory"
    case sleep       = "Sleep"
    case hearing     = "Hearing"
    case body        = "Body"
}

struct MonitoredType: Identifiable {
    let id: String
    let displayName: String
    let category: TypeCategory
    let isEnabledByDefault: Bool
    let objectType: HKObjectType?   // nil = unsupported on this OS/device
}

final class HealthKitService {
    static let shared = HealthKitService()
    let store = HKHealthStore()

    static let allTypes: [MonitoredType] = buildAllTypes()

    // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each.
    private static let perTypeTimeoutSeconds: TimeInterval = 15
    // Prevents 3N simultaneous HK queries from exhausting resources at N=20 types.
    private static let maxConcurrentTypeFetches = 6

    var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }

    // MARK: - Authorization

    func requestAuthorization() async throws {
        guard isAvailable else { return }
        let readTypes = Set(Self.allTypes.compactMap { $0.objectType })
        try await store.requestAuthorization(toShare: [], read: readTypes)
    }

    // MARK: - Snapshot creation

    @MainActor
    func createSnapshot(in context: ModelContext, selectedTypeIDs: Set<String>) async throws -> HealthSnapshot {
        let active = Self.allTypes.filter { selectedTypeIDs.contains($0.id) }
        let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context))

        let snapshot = HealthSnapshot(
            timestamp: Date(),
            osVersion: ProcessInfo.processInfo.operatingSystemVersionString,
            deviceName: UIDevice.current.name,
            deviceID: deviceResolution.id
        )
        snapshot.recoveredDeviceID = deviceResolution.isRecovered
        snapshot.triggerReason = "manual"
        snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
        context.insert(snapshot)

        let typeCounts = await fetchAllTypeCounts(for: active, snapshot: snapshot)

        // Invariant assertions before save — debug asserts + release silent correction
        for tc in typeCounts {
            let isComplete = tc.quality == SnapshotQuality.complete
            assert(
                !isComplete || tc.count >= 0,
                "TypeCount with quality .complete must have count >= 0"
            )
            assert(
                isComplete || tc.count == -1,
                "TypeCount with quality != .complete must have count == -1"
            )
            if !isComplete && tc.count != -1 {
                logger.critical("TypeCount invariant violation: quality=\(tc.quality.rawValue) count=\(tc.count) type=\(tc.typeIdentifier)")
                tc.count = -1
            }
        }

        snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)

        // Chain metadata — set BEFORE context.save()
        // localSequenceNumber is used here solely to find the latest local candidate during
        // snapshot creation. Once previousSnapshotID is set, all chain reconstruction must use
        // previousSnapshotID exclusively — never reconstruct a chain by walking localSequenceNumber.
        let previous = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context)
        if let previous {
            snapshot.previousSnapshotID = previous.id
            snapshot.localSequenceNumber = previous.localSequenceNumber + 1
            snapshot.isChainStart = false

            let intentedTypeIDs = active.map { $0.id }
            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intentedTypeIDs)
            if snapshot.monitoredTypeSetHash != previous.monitoredTypeSetHash {
                snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion + 1
            } else {
                snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion
            }

            // Auto-detect post-restore: previous was fully unauthorized, current is complete
            if previous.snapshotQuality == SnapshotQuality.unauthorized && snapshot.snapshotQuality == SnapshotQuality.complete {
                snapshot.isPostRestore = true
                snapshot.isPostRestoreInferred = true
            }
        } else {
            snapshot.previousSnapshotID = nil
            snapshot.localSequenceNumber = 0
            snapshot.isChainStart = true
            snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: active.map { $0.id })
            snapshot.monitoredRegistryVersion = 0

            // Auto-detect post-restore on chain start with significant data
            let completeTypeCounts = typeCounts.filter { $0.quality == SnapshotQuality.complete }
            let completeCount = completeTypeCounts.reduce(0) { $0 + max($1.count, 0) }
            if completeCount > 1000 {
                snapshot.isPostRestore = true
                snapshot.isPostRestoreInferred = true
            }
        }

        // Device metadata — informational only, never used for chain linkage
        snapshot.hardwareModel = hardwareModel()
        snapshot.appBuildVersion = appBuildVersion()

        try context.save()

        // Post-save pipeline: delta computation + anomaly detection
        try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context)

        return snapshot
    }

    // MARK: - Post-save pipeline

    private func runPostSavePipeline(
        snapshot: HealthSnapshot,
        typeCounts: [TypeCount],
        context: ModelContext
    ) async throws {
        guard let prevID = snapshot.previousSnapshotID else { return }

        let prevDescriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
        )
        guard let previous = try context.fetch(prevDescriptor).first else { return }

        guard let delta = try DeltaService.computeAndSave(current: snapshot, context: context) else { return }

        // Build type count maps for AnomalyDetector (never access relationship properties directly)
        let currentTypeCounts = Dictionary(uniqueKeysWithValues: typeCounts.map { ($0.typeIdentifier, $0) })
        let previousTypeCounts = Dictionary(
            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
        )

        let detection = AnomalyDetector.detect(
            delta: delta,
            current: snapshot,
            previous: previous,
            currentTypeCounts: currentTypeCounts,
            previousTypeCounts: previousTypeCounts
        )

        for record in detection.records {
            context.insert(record)
        }
        if let consumedDeltaID = detection.consumedPostRestoreSuppressionDeltaID {
            previous.isPostRestoreSuppressedDeltaID = consumedDeltaID
        }

        if !detection.records.isEmpty || detection.consumedPostRestoreSuppressionDeltaID != nil {
            try context.save()
        }
    }

    // MARK: - Per-type fetch pipeline

    private func fetchAllTypeCounts(for active: [MonitoredType], snapshot: HealthSnapshot) async -> [TypeCount] {
        var results: [TypeCount] = []

        // Fetch in batches to cap concurrent HK queries
        let batches = stride(from: 0, to: active.count, by: Self.maxConcurrentTypeFetches).map {
            Array(active[$0..<min($0 + Self.maxConcurrentTypeFetches, active.count)])
        }

        for batch in batches {
            await withTaskGroup(of: TypeCount.self) { group in
                for monitoredType in batch {
                    group.addTask { [weak self] in
                        guard let self else {
                            return self?.makeFailedTypeCount(monitoredType) ?? TypeCount(
                                typeIdentifier: monitoredType.id,
                                displayName: monitoredType.displayName,
                                count: -1,
                                quality: SnapshotQuality.failed
                            )
                        }
                        return await self.fetchTypeCount(for: monitoredType)
                    }
                }
                for await tc in group {
                    tc.snapshot = snapshot
                    snapshot.typeCounts?.append(tc)
                    results.append(tc)
                }
            }
        }
        return results
    }

    private func fetchTypeCount(for monitoredType: MonitoredType) async -> TypeCount {
        // Unsupported type: HKObjectType factory returned nil for this identifier
        guard let objectType = monitoredType.objectType,
              let sampleType = objectType as? HKSampleType else {
            let tc = TypeCount(
                typeIdentifier: monitoredType.id,
                displayName: monitoredType.displayName,
                count: -1,
                quality: SnapshotQuality.failed
            )
            tc.isUnsupported = true
            return tc
        }

        // Check authorization status before querying — if denied, fail immediately
        // (HealthKit queries for denied types might succeed with 0 data, appearing complete)
        if store.authorizationStatus(for: sampleType) == .sharingDenied {
            return TypeCount(
                typeIdentifier: monitoredType.id,
                displayName: monitoredType.displayName,
                count: -1,
                quality: SnapshotQuality.unauthorized
            )
        }

        // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each.
        do {
            return try await withTimeout(seconds: Self.perTypeTimeoutSeconds) {
                await self.fetchTypeCountFromHK(monitoredType: monitoredType, sampleType: sampleType)
            }
        } catch {
            let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied
            return TypeCount(
                typeIdentifier: monitoredType.id,
                displayName: monitoredType.displayName,
                count: -1,
                quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed
            )
        }
    }

    private func fetchTypeCountFromHK(monitoredType: MonitoredType, sampleType: HKSampleType) async -> TypeCount {
        do {
            let distribution = try await fetchDistribution(for: sampleType)

            // Both date queries share the same 15s budget via withTimeout in the caller.
            // If either date query fails, both are set to nil (no partial date results).
            async let earliestTask = fetchEarliestDate(for: sampleType)
            async let latestTask   = fetchLatestDate(for: sampleType)
            let (earliest, latest) = try await (earliestTask, latestTask)

            let tc = TypeCount(
                typeIdentifier: monitoredType.id,
                displayName: monitoredType.displayName,
                count: distribution.totalCount,
                quality: SnapshotQuality.complete
            )
            tc.earliestDate = earliest
            tc.latestDate = latest
            tc.hash = HashService.typeHash(
                typeIdentifier: monitoredType.id,
                totalCount: distribution.totalCount,
                earliestDate: earliest,
                latestDate: latest
            )

            // YearlyCount — group distribution bins by year
            // YearlyCount uses Calendar.current — year attribution is local-time based.
            let isApprox = DistributionCaptureConfiguration.bucketComponent != .day
            var yearMap: [Int: Int] = [:]
            for bin in distribution.bins {
                let year = Calendar.current.component(.year, from: bin.start)
                yearMap[year, default: 0] += bin.count
            }
            for (year, yearCount) in yearMap {
                let yc = YearlyCount(
                    year: year,
                    count: yearCount,
                    typeIdentifier: monitoredType.id,
                    isApproximate: isApprox
                )
                yc.typeCount = tc
                tc.yearlyCounts?.append(yc)
            }

            return tc
        } catch {
            let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied
            return TypeCount(
                typeIdentifier: monitoredType.id,
                displayName: monitoredType.displayName,
                count: -1,
                quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed
            )
        }
    }

    private func makeFailedTypeCount(_ monitoredType: MonitoredType) -> TypeCount {
        TypeCount(
            typeIdentifier: monitoredType.id,
            displayName: monitoredType.displayName,
            count: -1,
            quality: SnapshotQuality.failed
        )
    }

    // MARK: - HealthKit queries

    private func fetchDistribution(for sampleType: HKSampleType) async throws -> SampleDistribution {
        try await withCheckedThrowingContinuation { continuation in
            let query = HKSampleQuery(
                sampleType: sampleType,
                predicate: nil,
                limit: HKObjectQueryNoLimit,
                sortDescriptors: nil
            ) { _, samples, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                let samples = samples ?? []
                let calendar = Calendar.current
                var countsByDay: [Date: Int] = [:]
                for sample in samples {
                    guard let day = calendar.dateInterval(
                        of: DistributionCaptureConfiguration.bucketComponent,
                        for: sample.startDate
                    ) else { continue }
                    countsByDay[day.start, default: 0] += 1
                }
                let bins = countsByDay.map { dayStart, count in
                    SampleDistribution.Bin(
                        start: dayStart,
                        end: calendar.date(
                            byAdding: DistributionCaptureConfiguration.bucketComponent,
                            value: DistributionCaptureConfiguration.bucketStep,
                            to: dayStart
                        ) ?? dayStart,
                        count: count
                    )
                }.sorted { $0.start < $1.start }
                continuation.resume(returning: SampleDistribution(totalCount: samples.count, bins: bins))
            }
            store.execute(query)
        }
    }

    private func fetchEarliestDate(for sampleType: HKSampleType) async throws -> Date? {
        try await withCheckedThrowingContinuation { continuation in
            let query = HKSampleQuery(
                sampleType: sampleType,
                predicate: nil,
                limit: 1,
                sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
            ) { _, samples, error in
                if let error { continuation.resume(throwing: error); return }
                continuation.resume(returning: samples?.first?.startDate)
            }
            store.execute(query)
        }
    }

    private func fetchLatestDate(for sampleType: HKSampleType) async throws -> Date? {
        try await withCheckedThrowingContinuation { continuation in
            let query = HKSampleQuery(
                sampleType: sampleType,
                predicate: nil,
                limit: 1,
                sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)]
            ) { _, samples, error in
                if let error { continuation.resume(throwing: error); return }
                continuation.resume(returning: samples?.first?.startDate)
            }
            store.execute(query)
        }
    }

    // MARK: - Quality aggregation

    func deriveSnapshotQuality(from typeCounts: [TypeCount]) -> SnapshotQuality {
        guard !typeCounts.isEmpty else { return .failed }
        if typeCounts.contains(where: { $0.quality == .loading }) { return .loading }
        let allUnauthorized = typeCounts.allSatisfy { $0.quality == .unauthorized }
        if allUnauthorized { return .unauthorized }
        let anyImpaired = typeCounts.contains { $0.quality == .failed || $0.quality == .unauthorized }
        if anyImpaired { return .partial }
        return .complete
    }

    // MARK: - Chain helpers

    private func findPreviousSnapshot(deviceID: String, excluding id: UUID, context: ModelContext) -> HealthSnapshot? {
        let descriptor = FetchDescriptor<HealthSnapshot>(
            predicate: #Predicate<HealthSnapshot> { $0.deviceID == deviceID && $0.id != id },
            sortBy: [SortDescriptor(\.localSequenceNumber, order: .reverse)]
        )
        return try? context.fetch(descriptor).first
    }

    private func isStoreEmpty(context: ModelContext) -> Bool {
        let descriptor = FetchDescriptor<HealthSnapshot>()
        return (try? context.fetch(descriptor).isEmpty) ?? true
    }

    // MARK: - Device metadata

    private func hardwareModel() -> String {
        var size = 0
        sysctlbyname("hw.machine", nil, &size, nil, 0)
        var machine = [CChar](repeating: 0, count: size)
        sysctlbyname("hw.machine", &machine, &size, nil, 0)
        return String(cString: machine)
    }

    private func appBuildVersion() -> String {
        let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
        let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
        return "\(version) (\(build))"
    }

    // MARK: - Timeout utility

    private func withTimeout<T: Sendable>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
        try await withThrowingTaskGroup(of: T.self) { group in
            group.addTask { try await operation() }
            group.addTask {
                try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
                throw CancellationError()
            }
            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }

    // MARK: - Type registry

    private static func buildAllTypes() -> [MonitoredType] {
        var result: [MonitoredType] = []

        func addQty(_ id: HKQuantityTypeIdentifier, _ name: String, _ cat: TypeCategory, on: Bool) {
            let t = HKObjectType.quantityType(forIdentifier: id)
            result.append(MonitoredType(id: t?.identifier ?? id.rawValue, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
        }

        func addCat(_ id: HKCategoryTypeIdentifier, _ name: String, _ cat: TypeCategory, on: Bool) {
            let t = HKObjectType.categoryType(forIdentifier: id)
            result.append(MonitoredType(id: t?.identifier ?? id.rawValue, displayName: name, category: cat, isEnabledByDefault: on, objectType: t))
        }

        let workout = HKObjectType.workoutType()
        result.append(MonitoredType(id: workout.identifier, displayName: "Workouts", category: .activity, isEnabledByDefault: true, objectType: workout))

        addQty(.stepCount,              "Steps",                          .activity,    on: true)
        addQty(.distanceWalkingRunning, "Walking + Running Distance",     .activity,    on: true)
        addQty(.activeEnergyBurned,     "Active Energy",                  .activity,    on: true)
        addQty(.appleExerciseTime,      "Exercise Minutes",               .activity,    on: true)
        addCat(.appleStandHour,         "Stand Hours",                    .activity,    on: true)

        addQty(.heartRate,              "Heart Rate",                     .heart,       on: true)
        addQty(.restingHeartRate,       "Resting Heart Rate",             .heart,       on: true)
        addCat(.highHeartRateEvent,     "High Heart Rate Notifications",  .heart,       on: true)

        addQty(.respiratoryRate,        "Respiratory Rate",               .respiratory, on: true)

        addCat(.sleepAnalysis,          "Sleep",                          .sleep,       on: true)

        addQty(.environmentalAudioExposure, "Environmental Sound Levels", .hearing,     on: false)
        addQty(.headphoneAudioExposure,     "Headphone Audio Levels",     .hearing,     on: false)

        addQty(.bodyMass, "Body Mass", .body, on: false)
        addQty(.vo2Max,   "VO2 Max",   .body, on: false)

        return result
    }
}

private struct SampleDistribution {
    struct Bin {
        let start: Date
        let end: Date
        let count: Int
    }
    let totalCount: Int
    let bins: [Bin]
}

private enum DistributionCaptureConfiguration {
    static let bucketComponent: Calendar.Component = .day
    static let bucketStep = 1
}