1 contributor
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
}