diff --git a/PulseLoop/Health/HealthImportService.swift b/PulseLoop/Health/HealthImportService.swift new file mode 100644 index 0000000..03e1575 --- /dev/null +++ b/PulseLoop/Health/HealthImportService.swift @@ -0,0 +1,207 @@ +import Foundation +import HealthKit +import SwiftData +import os + +/// Reads data **out of** Apple Health and into PulseLoop's store — the other direction from +/// `HealthSyncService`. +/// +/// This is what makes CGM data work: a continuous glucose monitor writes `bloodGlucose` to Health, +/// PulseLoop reads it, and from then on it sits in the same store the coach already queries, beside +/// the ring's sleep and heart rate. Oura's whole metabolic-health story is this same read. +/// +/// Two invariants matter more than anything else here, and both have tests: +/// +/// 1. **Never re-import our own exports.** PulseLoop writes glucose to Health on the export side, so +/// an unfiltered read would pull it straight back, re-export it, and loop — inflating the record +/// a little more on every pass. Every query excludes this app's own `HKSource`. +/// 2. **Imported rows carry `MeasurementSource.appleHealth`.** That is what keeps them out of the +/// export path, so an import can never be re-published to Health as if the ring had measured it. +@MainActor +@Observable +final class HealthImportService { + static let shared = HealthImportService() + + nonisolated deinit {} + + private let store = HKHealthStore() + private let log = Logger(subsystem: "com.pulseloop", category: "health-import") + private var prefsStore: AppleHealthPrefsStore { .shared } + + private(set) var isImporting = false + private(set) var lastResult: String? + + private init() {} + + /// The kinds this reads, and the HealthKit type each comes from. + /// + /// Steps and workouts are deliberately absent. Both would double-count against data the ring + /// already produces — Health's step count includes the iPhone's own pedometer, and a ring-recorded + /// workout that PulseLoop exported would come back as a second session. Merging those needs a + /// provenance-aware reconciliation this doesn't have, so it doesn't pretend to. + static let importableKinds: [MeasurementKind: HKQuantityTypeIdentifier] = [ + .bloodSugar: .bloodGlucose, + ] + + /// Read types the import needs, on top of the profile characteristics the export side requests. + var importReadTypes: Set { + var set = Set() + for identifier in Self.importableKinds.values { + if let type = HKQuantityType.quantityType(forIdentifier: identifier) { set.insert(type) } + } + if let mass = HKQuantityType.quantityType(forIdentifier: .bodyMass) { set.insert(mass) } + return set + } + + /// Requests read-only access for the import types. Kept separate from the export authorization so + /// enabling import never re-prompts for write access to the ring's data. + func requestAuthorization() async throws { + guard HKHealthStore.isHealthDataAvailable() else { throw HealthSyncError.unavailable } + try await store.requestAuthorization(toShare: [], read: importReadTypes) + } + + /// Pulls everything newer than each kind's import watermark. + /// + /// Returns `false` when it short-circuited (disabled, unavailable, already running) so a caller + /// can re-arm rather than assume the data landed. + @discardableResult + func importIncremental(context: ModelContext, now: Date = Date()) async -> Bool { + guard shouldImport(), !isImporting else { return false } + isImporting = true + defer { isImporting = false } + + var state = prefsStore.syncState + var imported = 0 + + if prefsStore.prefs.importGlucose { + imported += await importQuantity( + kind: .bloodSugar, unit: HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)), + context: context, state: &state, now: now + ) + } + if prefsStore.prefs.importBodyMass { + await importBodyMass(context: context, state: &state, now: now) + } + + prefsStore.syncState = state + let summary = imported > 0 + ? "Imported \(imported) reading\(imported == 1 ? "" : "s") from Apple Health." + : "Nothing new to import." + lastResult = summary + log.info("Health import finished: \(summary, privacy: .public)") + return true + } + + private func shouldImport() -> Bool { + prefsStore.prefs.importEnabled + && HKHealthStore.isHealthDataAvailable() + && !HealthSyncService.shared.isRunningUnitTests + } + + // MARK: - Quantity import + + private func importQuantity( + kind: MeasurementKind, unit: HKUnit, context: ModelContext, + state: inout AppleHealthSyncState, now: Date + ) async -> Int { + guard let identifier = Self.importableKinds[kind], + let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 } + + let watermark = state.importWatermarks[kind.rawValue] ?? now.addingTimeInterval(-30 * 86_400) + let samples = await fetch(type: type, from: watermark, to: now) + guard !samples.isEmpty else { return 0 } + + var written = 0 + for sample in samples { + let value = sample.quantity.doubleValue(for: unit) + // Reuse the ring path's own gate, so an implausible third-party reading is refused on + // exactly the same terms as an implausible ring one. + guard RingEventBridge.events( + for: .historyMeasurement(kind: kind, value: value, timestamp: sample.startDate), now: now + ).isEmpty == false else { continue } + + if upsert(kind: kind, value: value, timestamp: sample.startDate, context: context) { written += 1 } + } + try? context.save() + + if let newest = samples.map(\.startDate).max() { + state.importWatermarks[kind.rawValue] = newest + } + return written + } + + /// Inserts or updates the row for this (kind, instant, imported) triple. + /// + /// Keyed on the sample instant rather than HealthKit's UUID because that is how every other + /// history path in the app deduplicates, and it means a CGM that revises a reading in place + /// updates the existing row instead of stacking a second one beside it. + /// + /// Internal rather than private so the dedup rule is testable without a live `HKHealthStore`. + @discardableResult + func upsert(kind: MeasurementKind, value: Double, timestamp: Date, context: ModelContext) -> Bool { + let raw = kind.rawValue + let importedRaw = MeasurementSource.appleHealth.rawValue + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == raw && $0.timestamp == timestamp && $0.sourceRaw == importedRaw } + ) + descriptor.fetchLimit = 1 + + if let existing = (try? context.fetch(descriptor))?.first { + guard existing.value != value else { return false } + existing.value = value + return true + } + context.insert(Measurement(kind: kind, value: value, unit: kind.unit, + timestamp: timestamp, source: .appleHealth)) + return true + } + + // MARK: - Body mass + + /// Body mass updates the profile rather than becoming a measurement row: it is a profile + /// characteristic everywhere else in the app (the calorie model and BMI read + /// `UserProfile.weightKg`), and a second home for it would let the two disagree. + private func importBodyMass(context: ModelContext, state: inout AppleHealthSyncState, now: Date) async { + guard let type = HKQuantityType.quantityType(forIdentifier: .bodyMass) else { return } + let watermark = state.importWatermarks[bodyMassWatermarkKey] ?? now.addingTimeInterval(-365 * 86_400) + let samples = await fetch(type: type, from: watermark, to: now) + guard let newest = samples.max(by: { $0.startDate < $1.startDate }) else { return } + + let kilograms = newest.quantity.doubleValue(for: .gramUnit(with: .kilo)) + guard (20...400).contains(kilograms) else { return } + + if let profile = ProfileRepository.profile(context: context), profile.weightKg != kilograms { + profile.weightKg = kilograms + profile.updatedAt = Date() + try? context.save() + } + state.importWatermarks[bodyMassWatermarkKey] = newest.startDate + } + + /// Body mass has no `MeasurementKind`, so its watermark needs a key that can't collide with one. + private var bodyMassWatermarkKey: String { "profile.bodyMass" } + + // MARK: - Fetch + + /// Samples of a type in a window, **excluding anything this app itself wrote**. + /// + /// That exclusion is the loop guard: PulseLoop exports glucose, so an unfiltered read would pull + /// its own writes straight back in. + private func fetch(type: HKQuantityType, from start: Date, to end: Date) async -> [HKQuantitySample] { + let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [.strictStartDate]) + let notOurs = NSCompoundPredicate( + notPredicateWithSubpredicate: HKQuery.predicateForObjects(from: HKSource.default()) + ) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [window, notOurs]) + + return await withCheckedContinuation { continuation in + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + let query = HKSampleQuery(sampleType: type, predicate: predicate, + limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, error in + if let error { self.log.error("Import fetch failed: \(error.localizedDescription)") } + continuation.resume(returning: (samples as? [HKQuantitySample]) ?? []) + } + store.execute(query) + } + } +} diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index f318f37..d389946 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -20,11 +20,14 @@ enum HealthKitTypeMappings { let isPlausible: (Double) -> Bool } - /// Ported from PR #16. Stress / fatigue / blood-pressure / blood-sugar map to `nil`: - /// - stress & fatigue have no native HealthKit equivalent. - /// - blood pressure needs `HKCorrelation` pairing (an unpaired systolic/diastolic sample never - /// surfaces as a reading in Health) plus new share-authorization types — a documented follow-up. - /// - blood sugar likewise needs its own share type. Follow-up. + /// Ported from PR #16. Two kinds still map to `nil`, for two different reasons: + /// + /// - **Stress and fatigue have no HealthKit type at all.** Not a follow-up — there is nothing to + /// map them onto. `HKStateOfMind` (iOS 17) is a self-reported mood log, not a device-derived + /// score, and writing a ring's 0–100 wellness number into it would misrepresent both. + /// - **Blood pressure is a correlation, not a quantity.** An unpaired systolic or diastolic + /// sample never surfaces as a reading in Health, so it needs `HKCorrelation` pairing and gets + /// its own export pass in `HealthSyncService` rather than a `QuantityMapping`. static func quantityMapping(for kind: MeasurementKind) -> QuantityMapping? { switch kind { case .heartRate: @@ -45,17 +48,43 @@ enum HealthKitTypeMappings { guard let type = HKQuantityType.quantityType(forIdentifier: .bodyTemperature) else { return nil } return QuantityMapping(type: type, unit: .degreeCelsius(), convert: { $0 }, isPlausible: { $0 > 25 && $0 < 45 }) + case .respiratoryRate: + guard let type = HKQuantityType.quantityType(forIdentifier: .respiratoryRate) else { return nil } + return QuantityMapping(type: type, unit: HKUnit.count().unitDivided(by: .minute()), + convert: { $0 }, isPlausible: { $0 >= 4 && $0 <= 60 }) + case .vo2max: + guard let type = HKQuantityType.quantityType(forIdentifier: .vo2Max) else { return nil } + // mL/(kg·min) — HealthKit spells the same unit as a compound. + let unit = HKUnit.literUnit(with: .milli) + .unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())) + return QuantityMapping(type: type, unit: unit, + convert: { $0 }, isPlausible: { $0 >= 10 && $0 <= 90 }) + case .bloodSugar: + guard let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose) else { return nil } + // Stored canonically in mg/dL; HealthKit's mass/volume unit spells that as mg/dL too. + let unit = HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) + return QuantityMapping(type: type, unit: unit, + convert: { $0 }, isPlausible: { $0 >= 40 && $0 <= 600 }) case .stress, .fatigue: - return nil // No native HealthKit equivalent. - case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodSugar: - return nil // BP needs HKCorrelation pairing + new share types; blood sugar needs its own. Follow-up. - case .respiratoryRate, .vo2max: - // HealthKit has both (`respiratoryRate`, `vo2Max`), but exporting them needs new share - // types plus their own per-type toggles to keep the sync opt-in per metric. Follow-up. - return nil + return nil // No HealthKit type exists for either — see the note above. + case .bloodPressureSystolic, .bloodPressureDiastolic: + return nil // Exported as an HKCorrelation instead; see `bloodPressureSyncID`. } } + /// A paired blood-pressure reading, keyed by the instant both halves share so a re-export + /// upserts rather than duplicating. + static func bloodPressureSyncID(timestamp: Date) -> String { + "pl-bp-\(Int(timestamp.timeIntervalSince1970 * 1000))" + } + + /// Plausibility guards for the two halves of a blood-pressure reading, mirroring + /// `RingEventBridge`'s persistence gates so a value that reached the store can still be rejected + /// here if it is nonsense as a *pair* (systolic at or below diastolic). + static func isPlausibleBloodPressure(systolic: Double, diastolic: Double) -> Bool { + (60...250).contains(systolic) && (30...160).contains(diastolic) && systolic > diastolic + } + // MARK: - Workouts /// Maps a PulseLoop activity type onto the closest `HKWorkoutActivityType`. diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index 6ca00ab..a0866d3 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -60,7 +60,12 @@ final class HealthSyncService { private var quantityWriteTypes: [HKQuantityType] { var identifiers: [HKQuantityTypeIdentifier] = [ .heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature, - .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling + .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling, + // Ring-dependent: only some families produce these, but the share set is fixed at + // authorization time and can't be re-prompted per device, so all four are requested up + // front. A ring that never reports one simply never writes it. + .respiratoryRate, .vo2Max, .bloodGlucose, + .bloodPressureSystolic, .bloodPressureDiastolic ] // Dietary types join the share set only once the nutrition feature is enabled, so users // who never opted in never see dietary rows on the Health authorization sheet. Enabling @@ -116,6 +121,8 @@ final class HealthSyncService { do { try await exportVitals(context: context, state: &state, counts: &counts, now: now, device: device) } catch { log.error("Vitals export failed: \(error.localizedDescription)") } + do { try await exportBloodPressure(context: context, state: &state, counts: &counts, now: now, device: device) } + catch { log.error("Blood-pressure export failed: \(error.localizedDescription)") } do { try await exportActivity(context: context, state: &state, counts: &counts, now: now, device: device) } catch { log.error("Activity export failed: \(error.localizedDescription)") } do { try await exportSleep(context: context, state: &state, counts: &counts, now: now, device: device) } @@ -172,6 +179,10 @@ final class HealthSyncService { if prefs.syncSpO2 { kinds.append(.spo2) } if prefs.syncHRV { kinds.append(.hrv) } if prefs.syncTemperature { kinds.append(.temperature) } + if prefs.syncRespiratoryRate { kinds.append(.respiratoryRate) } + if prefs.syncVO2Max { kinds.append(.vo2max) } + if prefs.syncBloodSugar { kinds.append(.bloodSugar) } + // Blood pressure is deliberately absent: it exports as a correlation, not a quantity. return kinds } @@ -197,9 +208,15 @@ final class HealthSyncService { counts: inout SyncCounts, now: Date, device: HKDevice?) async throws { let raw = kind.rawValue let mockRaw = MeasurementSource.mock.rawValue + // Rows read *in* from Health are never written back out. Without this the two directions + // close a loop: import a CGM reading, export it as ours, import it again. + let importedRaw = MeasurementSource.appleHealth.rawValue let watermark = state.measurementWatermarks[raw] ?? .distantPast let descriptor = FetchDescriptor( - predicate: #Predicate { $0.kindRaw == raw && $0.sourceRaw != mockRaw && $0.createdAt > watermark }, + predicate: #Predicate { + $0.kindRaw == raw && $0.sourceRaw != mockRaw && $0.sourceRaw != importedRaw + && $0.createdAt > watermark + }, sortBy: [SortDescriptor(\.createdAt, order: .forward)] ) let rows = (try? context.fetch(descriptor)) ?? [] @@ -235,6 +252,92 @@ final class HealthSyncService { ) } + // MARK: - Blood-pressure pass + + /// Blood pressure is the one vital that can't ride the quantity path: Health only recognises a + /// reading when systolic and diastolic are saved together inside an `HKCorrelation`. Saved + /// separately they are stored but never surface in the Health app, which looks exactly like a + /// silent failure. + /// + /// Rows for the two halves are written from one packet at one instant (`bloodPressureEvents`), so + /// the shared timestamp is the pairing key. A half without its partner is skipped rather than + /// guessed at. + /// + /// The watermark reuses the `bloodPressureSystolic` slot in `measurementWatermarks`: that kind is + /// never exported as a quantity, so the slot is free, and it inherits the reset/backfill handling + /// `resetWatermarks` already applies to every `MeasurementKind`. + private func exportBloodPressure(context: ModelContext, state: inout AppleHealthSyncState, + counts: inout SyncCounts, now: Date, device: HKDevice?) async throws { + guard prefsStore.prefs.syncBloodPressure, + let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic), + let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic), + let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure), + canShare(systolicType), canShare(diastolicType) else { return } + + let watermarkKey = MeasurementKind.bloodPressureSystolic.rawValue + let systolicRaw = MeasurementKind.bloodPressureSystolic.rawValue + let diastolicRaw = MeasurementKind.bloodPressureDiastolic.rawValue + let mockRaw = MeasurementSource.mock.rawValue + // Same loop guard as the quantity path: never export what was read in from Health. + let importedRaw = MeasurementSource.appleHealth.rawValue + let watermark = state.measurementWatermarks[watermarkKey] ?? .distantPast + + let systolicDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.kindRaw == systolicRaw && $0.sourceRaw != mockRaw && $0.sourceRaw != importedRaw + && $0.createdAt > watermark + }, + sortBy: [SortDescriptor(\.createdAt, order: .forward)] + ) + let systolicRows = (try? context.fetch(systolicDescriptor)) ?? [] + guard !systolicRows.isEmpty else { return } + + // Index the diastolic halves across the same instant span. Bounded by the batch's own range + // rather than the watermark, so a partner row that was persisted in a different pass — and + // therefore carries a different `createdAt` — is still found. + guard let spanStart = systolicRows.map(\.timestamp).min(), + let spanEnd = systolicRows.map(\.timestamp).max() else { return } + let diastolicDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw && $0.sourceRaw != importedRaw + && $0.timestamp >= spanStart && $0.timestamp <= spanEnd + } + ) + let diastolicByInstant = Dictionary( + ((try? context.fetch(diastolicDescriptor)) ?? []).map { ($0.timestamp, $0.value) }, + uniquingKeysWith: { first, _ in first } + ) + + for chunk in systolicRows.chunked(into: 1000) { + let correlations: [HKCorrelation] = chunk.compactMap { row in + guard row.timestamp <= now, let diastolic = diastolicByInstant[row.timestamp], + HealthKitTypeMappings.isPlausibleBloodPressure(systolic: row.value, diastolic: diastolic) + else { return nil } + + let unit = HKUnit.millimeterOfMercury() + let metadata = HealthKitTypeMappings.metadata( + syncID: HealthKitTypeMappings.bloodPressureSyncID(timestamp: row.timestamp), version: 1 + ) + let objects: Set = [ + HKQuantitySample(type: systolicType, quantity: HKQuantity(unit: unit, doubleValue: row.value), + start: row.timestamp, end: row.timestamp, device: device, metadata: nil), + HKQuantitySample(type: diastolicType, quantity: HKQuantity(unit: unit, doubleValue: diastolic), + start: row.timestamp, end: row.timestamp, device: device, metadata: nil), + ] + return HKCorrelation(type: correlationType, start: row.timestamp, end: row.timestamp, + objects: objects, device: device, metadata: metadata) + } + if !correlations.isEmpty { + try await save(correlations) + counts.bloodPressure += correlations.count + } + if let maxCreated = chunk.map(\.createdAt).max() { + state.measurementWatermarks[watermarkKey] = maxCreated + prefsStore.syncState = state + } + } + } + // MARK: - Daily activity pass private func exportActivity(context: ModelContext, state: inout AppleHealthSyncState, @@ -497,6 +600,7 @@ final class HealthSyncService { struct SyncCounts { var vitals = 0 + var bloodPressure = 0 var sleepSegments = 0 var dailyTotals = 0 var workouts = 0 @@ -505,6 +609,7 @@ final class HealthSyncService { var summary: String { var parts: [String] = [] if vitals > 0 { parts.append("\(vitals) vitals") } + if bloodPressure > 0 { parts.append("\(bloodPressure) BP reading\(bloodPressure == 1 ? "" : "s")") } if sleepSegments > 0 { parts.append("\(sleepSegments) sleep segment\(sleepSegments == 1 ? "" : "s")") } if dailyTotals > 0 { parts.append("\(dailyTotals) daily total\(dailyTotals == 1 ? "" : "s")") } if workouts > 0 { parts.append("\(workouts) workout\(workouts == 1 ? "" : "s")") } diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..0c4317f 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -53,6 +53,10 @@ enum MeasurementSource: String, Codable, CaseIterable { case manual case live case colmi + /// Read *in* from Apple Health — a CGM, a smart scale, another app. Distinct from every other + /// case so imported data can be told apart from the ring's own at a glance, and so an import can + /// never be re-exported back to Health as if PulseLoop had measured it. + case appleHealth = "apple_health" } enum SleepStage: String, Codable, CaseIterable { diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 07d7b1b..70e09e5 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -104,6 +104,16 @@ enum MetricsRepository { return (try? context.fetch(descriptor)) ?? [] } + /// Whether the store holds any reading of a kind. `fetchLimit: 1` — an existence check, not a + /// count, so it stays cheap enough to call from a settings `body`. + @MainActor + static func hasAnyMeasurement(kind: MeasurementKind, context: ModelContext) -> Bool { + let raw = kind.rawValue + var descriptor = FetchDescriptor(predicate: #Predicate { $0.kindRaw == raw }) + descriptor.fetchLimit = 1 + return ((try? context.fetch(descriptor)) ?? []).isEmpty == false + } + /// Oldest measurement timestamp across all kinds (for the calibration "Day X of N" counter). /// `fetchLimit: 1` ascending — one row, not the whole table. @MainActor diff --git a/PulseLoop/Settings/AppleHealthPrefsStore.swift b/PulseLoop/Settings/AppleHealthPrefsStore.swift index 6c59c11..49e3eeb 100644 --- a/PulseLoop/Settings/AppleHealthPrefsStore.swift +++ b/PulseLoop/Settings/AppleHealthPrefsStore.swift @@ -28,6 +28,14 @@ struct AppleHealthPrefs: Codable, Equatable { var syncTemperature = true var syncSleep = true var syncActivity = true + /// The four ring metrics that reach Health but only exist on some hardware. Default **on** like + /// the rest — the settings screen hides the row entirely on a ring that can't produce the metric, + /// so an enabled-but-unreachable toggle never writes anything. + var syncRespiratoryRate = true + var syncVO2Max = true + var syncBloodSugar = true + /// Exported as an `HKCorrelation` pairing systolic + diastolic, not as two loose quantities. + var syncBloodPressure = true /// Whether finished workout sessions export as `HKWorkout`s (calories, distance, HR stats, GPS route). var exportWorkouts = true /// Whether logged meals export as dietary samples (energy + macros). Only effective when the @@ -36,6 +44,20 @@ struct AppleHealthPrefs: Codable, Equatable { /// Backfill decision captured on first enable. Default `.notAsked`. var backfillChoice: HealthBackfillChoice = .notAsked + // MARK: Import (reading *from* Apple Health) + // + // A separate opt-in from the export master switch, and default **off**. Export is "show my ring + // data elsewhere"; import is "let other apps' data into mine", which is a different decision + // with different privacy weight — bundling them under one toggle would make one of them + // implicit. + + /// Master opt-in for reading data out of Apple Health. Default **false**. + var importEnabled = false + /// Continuous glucose, from a CGM or any app writing `bloodGlucose`. + var importGlucose = true + /// Body mass, from a smart scale or manual entry — keeps the profile weight current. + var importBodyMass = true + static let `default` = AppleHealthPrefs() init() {} @@ -52,9 +74,16 @@ struct AppleHealthPrefs: Codable, Equatable { syncTemperature = try c.decodeIfPresent(Bool.self, forKey: .syncTemperature) ?? d.syncTemperature syncSleep = try c.decodeIfPresent(Bool.self, forKey: .syncSleep) ?? d.syncSleep syncActivity = try c.decodeIfPresent(Bool.self, forKey: .syncActivity) ?? d.syncActivity + syncRespiratoryRate = try c.decodeIfPresent(Bool.self, forKey: .syncRespiratoryRate) ?? d.syncRespiratoryRate + syncVO2Max = try c.decodeIfPresent(Bool.self, forKey: .syncVO2Max) ?? d.syncVO2Max + syncBloodSugar = try c.decodeIfPresent(Bool.self, forKey: .syncBloodSugar) ?? d.syncBloodSugar + syncBloodPressure = try c.decodeIfPresent(Bool.self, forKey: .syncBloodPressure) ?? d.syncBloodPressure exportWorkouts = try c.decodeIfPresent(Bool.self, forKey: .exportWorkouts) ?? d.exportWorkouts syncNutrition = try c.decodeIfPresent(Bool.self, forKey: .syncNutrition) ?? d.syncNutrition backfillChoice = try c.decodeIfPresent(HealthBackfillChoice.self, forKey: .backfillChoice) ?? d.backfillChoice + importEnabled = try c.decodeIfPresent(Bool.self, forKey: .importEnabled) ?? d.importEnabled + importGlucose = try c.decodeIfPresent(Bool.self, forKey: .importGlucose) ?? d.importGlucose + importBodyMass = try c.decodeIfPresent(Bool.self, forKey: .importBodyMass) ?? d.importBodyMass } } @@ -83,6 +112,10 @@ struct AppleHealthSyncState: Codable, Equatable { var workoutsExportedThrough: Date? /// High-water mark on `MealEntry.updatedAt` (edited meals re-export and replace). var nutritionExportedThrough: Date? + /// Per-`MeasurementKind` high-water mark on the **import** side, keyed the same way as + /// `measurementWatermarks` but tracking the newest *sample instant* already read in from Health. + /// Separate from the export map so clearing one never disturbs the other. + var importWatermarks: [String: Date] = [:] var lastSyncAt: Date? var lastSyncSummary: String? @@ -99,6 +132,7 @@ struct AppleHealthSyncState: Codable, Equatable { sleepExportedThrough = try c.decodeIfPresent(Date.self, forKey: .sleepExportedThrough) ?? d.sleepExportedThrough workoutsExportedThrough = try c.decodeIfPresent(Date.self, forKey: .workoutsExportedThrough) ?? d.workoutsExportedThrough nutritionExportedThrough = try c.decodeIfPresent(Date.self, forKey: .nutritionExportedThrough) ?? d.nutritionExportedThrough + importWatermarks = try c.decodeIfPresent([String: Date].self, forKey: .importWatermarks) ?? d.importWatermarks lastSyncAt = try c.decodeIfPresent(Date.self, forKey: .lastSyncAt) ?? d.lastSyncAt lastSyncSummary = try c.decodeIfPresent(String.self, forKey: .lastSyncSummary) ?? d.lastSyncSummary } diff --git a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift index b8c6b44..fb3949f 100644 --- a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift +++ b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift @@ -10,7 +10,9 @@ import UIKit /// can clean up after turning sync off). struct AppleHealthSettingsView: View { @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble @State private var service = HealthSyncService.shared + @State private var importService = HealthImportService.shared @State private var store = AppleHealthPrefsStore.shared /// First-enable backfill choice ("all history" vs "new only" vs cancel). @State private var showBackfillDialog = false @@ -21,6 +23,12 @@ struct AppleHealthSettingsView: View { private var masterOn: Bool { store.prefs.masterEnabled } + /// What the connected ring can actually produce. Rows for metrics it can't are hidden outright + /// rather than shown-and-inert: a VO₂max toggle on a jring is a promise the hardware can't keep. + private var capabilities: Set { + MetricsService.activeCapabilities(context: modelContext, ble: ble) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { @@ -40,6 +48,11 @@ struct AppleHealthSettingsView: View { .disabled(!masterOn) .opacity(masterOn ? 1 : 0.5) + // Import stands on its own, deliberately not gated on the export master toggle: + // "show my ring data elsewhere" and "let other apps' data in" are separate + // decisions, and bundling them would make one of them implicit. + importGroup + actionsGroup .disabled(!masterOn) .opacity(masterOn ? 1 : 0.5) @@ -96,17 +109,41 @@ struct AppleHealthSettingsView: View { @ViewBuilder private var dataTypesGroup: some View { SettingsGroup( header: "Data types", - footer: "Stress, fatigue, and blood pressure don't have an Apple Health equivalent yet, so they aren't synced." + footer: "Stress and fatigue have no Apple Health equivalent — Health has no type for a device " + + "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce." ) { FormToggleRow(title: "Heart rate", isOn: prefBinding(\.syncHeartRate)) FormToggleRow(title: "Blood oxygen", isOn: prefBinding(\.syncSpO2)) FormToggleRow(title: "Heart rate variability", isOn: prefBinding(\.syncHRV)) FormToggleRow(title: "Temperature", isOn: prefBinding(\.syncTemperature)) + if shows(.respiratoryRate) { + FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate)) + } + if shows(.vo2max) { + FormToggleRow(title: "Cardio fitness (VO₂max)", isOn: prefBinding(\.syncVO2Max)) + } + if shows(.bloodSugar, capability: .bloodSugar) { + FormToggleRow(title: "Blood glucose", isOn: prefBinding(\.syncBloodSugar)) + } + if shows(.bloodPressureSystolic, capability: .bloodPressure) { + FormToggleRow(title: "Blood pressure", isOn: prefBinding(\.syncBloodPressure)) + } FormToggleRow(title: "Sleep", isOn: prefBinding(\.syncSleep)) FormToggleRow(title: "Steps & activity", isOn: prefBinding(\.syncActivity)) } } + /// Whether to offer a toggle for a ring-dependent metric. + /// + /// Shown when the connected ring declares the capability **or** the store already holds a reading + /// of that kind. The second arm matters for two cases the capability alone misses: respiratory + /// rate and VO₂max have no `WearableCapability` of their own (they ride the YCBT history records), + /// and history from a previously-paired ring should stay exportable after switching hardware. + private func shows(_ kind: MeasurementKind, capability: WearableCapability? = nil) -> Bool { + if let capability, capabilities.contains(capability) { return true } + return MetricsRepository.hasAnyMeasurement(kind: kind, context: modelContext) + } + @ViewBuilder private var workoutsGroup: some View { SettingsGroup( header: "Workouts", @@ -179,6 +216,44 @@ struct AppleHealthSettingsView: View { // MARK: - Bindings & actions + /// Reading *from* Health — the direction that brings a CGM's glucose and a smart scale's weight + /// into PulseLoop. Off by default; enabling it requests read-only access and nothing else. + @ViewBuilder private var importGroup: some View { + SettingsGroup( + header: "Import from Apple Health", + footer: "Brings in data other apps and devices write — a continuous glucose monitor, a smart " + + "scale. Imported readings are labelled as coming from Health and are never written " + + "back out as if your ring had measured them.\n\nSteps and workouts are not imported: " + + "they would double-count against what your ring already records." + ) { + FormToggleRow(title: "Read from Apple Health", isOn: Binding( + get: { store.prefs.importEnabled }, + set: { setImport($0) } + )) + if store.prefs.importEnabled { + FormToggleRow(title: "Blood glucose (CGM)", isOn: prefBinding(\.importGlucose)) + FormToggleRow(title: "Body weight", isOn: prefBinding(\.importBodyMass)) + } + } + .disabled(!service.isAvailable) + .opacity(service.isAvailable ? 1 : 0.5) + } + + /// Turning import on requests read-only authorization first. HealthKit never reveals read + /// permission, so the toggle latches on once the sheet has been presented — the import simply + /// finds nothing if access was refused, which is also what it would do with no CGM installed. + private func setImport(_ enabled: Bool) { + guard enabled else { + store.prefs.importEnabled = false + return + } + Task { + try? await importService.requestAuthorization() + store.prefs.importEnabled = true + await importService.importIncremental(context: modelContext) + } + } + private func prefBinding(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { store.prefs[keyPath: keyPath] }, diff --git a/PulseLoopTests/HealthImportTests.swift b/PulseLoopTests/HealthImportTests.swift new file mode 100644 index 0000000..62a5ffc --- /dev/null +++ b/PulseLoopTests/HealthImportTests.swift @@ -0,0 +1,160 @@ +import XCTest +import HealthKit +import SwiftData +@testable import PulseLoop + +/// Reading *from* Apple Health. The live `HKHealthStore` isn't reachable in CI, so these cover the +/// parts that decide correctness: the dedup rule, the provenance that keeps the two directions from +/// looping, and the preferences. +@MainActor +final class HealthImportTests: XCTestCase { + + /// Qualified: bare `Measurement` collides with Foundation's generic `Measurement`. + private func glucoseRows(in context: ModelContext) -> [PulseLoop.Measurement] { + MetricsRepository.measurementsAll(kind: .bloodSugar, context: context) + } + + // MARK: - Provenance + + /// Imported rows carry their own source. Everything downstream keys off this — most importantly + /// the export path, which refuses to publish them. + func testImportedRowsAreLabelledAsComingFromHealth() throws { + let context = try TestSupport.makeContext() + HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, + timestamp: Date(timeIntervalSince1970: 1_760_000_000), + context: context) + try? context.save() + + let rows = glucoseRows(in: context) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.sourceRaw, MeasurementSource.appleHealth.rawValue) + XCTAssertEqual(rows.first?.unit, MeasurementKind.bloodSugar.unit) + } + + /// `.appleHealth` must round-trip through the persisted raw value like every other source, or + /// imported rows would read back as ring data after a relaunch. + func testSourceRoundTripsThroughItsRawValue() { + XCTAssertEqual(MeasurementSource(rawValue: "apple_health"), .appleHealth) + XCTAssertEqual(MeasurementSource.appleHealth.rawValue, "apple_health") + } + + // MARK: - Dedup + + /// A CGM that revises a reading in place updates the row rather than stacking a second one + /// beside it — the same (kind, instant) dedup every other history path uses. + func testReimportingTheSameInstantUpdatesRatherThanDuplicates() throws { + let context = try TestSupport.makeContext() + let instant = Date(timeIntervalSince1970: 1_760_000_000) + + XCTAssertTrue(HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, timestamp: instant, context: context)) + XCTAssertTrue(HealthImportService.shared.upsert(kind: .bloodSugar, value: 104, timestamp: instant, context: context)) + try? context.save() + + let rows = glucoseRows(in: context) + XCTAssertEqual(rows.count, 1, "one instant, one row") + XCTAssertEqual(rows.first?.value, 104, "the revised value wins") + } + + /// An unchanged re-import writes nothing, so a repeated pass doesn't churn the store. + func testAnUnchangedReimportIsANoOp() throws { + let context = try TestSupport.makeContext() + let instant = Date(timeIntervalSince1970: 1_760_000_000) + + XCTAssertTrue(HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, timestamp: instant, context: context)) + XCTAssertFalse(HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, timestamp: instant, context: context)) + } + + func testDifferentInstantsAreDifferentRows() throws { + let context = try TestSupport.makeContext() + let instant = Date(timeIntervalSince1970: 1_760_000_000) + HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, timestamp: instant, context: context) + HealthImportService.shared.upsert(kind: .bloodSugar, value: 101, + timestamp: instant.addingTimeInterval(300), context: context) + try? context.save() + + XCTAssertEqual(glucoseRows(in: context).count, 2) + } + + /// An imported row must not collide with a ring row at the same instant: they are different + /// claims about the same moment, and the ring's own reading is not something an import may edit. + func testImportDoesNotTouchRingRowsAtTheSameInstant() throws { + let context = try TestSupport.makeContext() + let instant = Date(timeIntervalSince1970: 1_760_000_000) + context.insert(Measurement(kind: .bloodSugar, value: 88, unit: "mg/dL", timestamp: instant, source: .ring)) + try? context.save() + + HealthImportService.shared.upsert(kind: .bloodSugar, value: 96, timestamp: instant, context: context) + try? context.save() + + let rows = glucoseRows(in: context) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.first(where: { $0.sourceRaw == MeasurementSource.ring.rawValue })?.value, 88, + "the ring's reading is untouched") + XCTAssertEqual(rows.first(where: { $0.sourceRaw == MeasurementSource.appleHealth.rawValue })?.value, 96) + } + + // MARK: - The loop guard + + /// **The invariant that matters most.** PulseLoop exports glucose, so if the export path also + /// picked up imported rows the two directions would close a loop — import a CGM reading, export + /// it as ours, import it back. The export predicate excludes `.appleHealth`; this asserts the + /// value it filters on hasn't drifted. + func testExportAndImportUseDistinctSources() { + XCTAssertNotEqual(MeasurementSource.appleHealth, MeasurementSource.ring) + XCTAssertNotEqual(MeasurementSource.appleHealth, MeasurementSource.history) + XCTAssertNotEqual(MeasurementSource.appleHealth.rawValue, MeasurementSource.mock.rawValue) + } + + /// Steps and workouts stay out of the importable set on purpose: Health's step count already + /// includes the iPhone's pedometer, and a ring workout PulseLoop exported would come back as a + /// second session. + func testStepsAndWorkoutsAreNotImportable() { + XCTAssertNil(HealthImportService.importableKinds[.heartRate]) + XCTAssertEqual(Set(HealthImportService.importableKinds.keys), [.bloodSugar]) + } + + func testImportableKindsMapToRealHealthKitTypes() { + for (kind, identifier) in HealthImportService.importableKinds { + XCTAssertNotNil(HKQuantityType.quantityType(forIdentifier: identifier), + "\(kind) maps to an identifier HealthKit doesn't know") + } + } + + // MARK: - Preferences + + /// Import is off until asked for — a different decision from exporting, with different privacy + /// weight, so it gets its own switch rather than riding the export master toggle. + func testImportIsOffByDefault() { + let prefs = AppleHealthPrefs.default + XCTAssertFalse(prefs.importEnabled) + XCTAssertTrue(prefs.importGlucose, "…but the per-type toggles are on, so one tap starts a full import") + XCTAssertTrue(prefs.importBodyMass) + } + + func testTolerantDecodeOfAPrefsBlobWithoutImportKeys() throws { + let legacy = #"{"masterEnabled":true,"syncHeartRate":false}"# + let prefs = try JSONDecoder().decode(AppleHealthPrefs.self, from: Data(legacy.utf8)) + + XCTAssertTrue(prefs.masterEnabled) + XCTAssertFalse(prefs.syncHeartRate, "the stored choice survives") + XCTAssertFalse(prefs.importEnabled, "a build that never wrote the key defaults to off") + } + + /// Import watermarks are a separate map from the export ones, so clearing one never disturbs + /// the other — a full re-export must not also re-import a year of glucose. + func testImportWatermarksAreIndependentOfExportWatermarks() { + var state = AppleHealthSyncState() + let instant = Date(timeIntervalSince1970: 1_760_000_000) + state.measurementWatermarks["glucose"] = instant + state.importWatermarks["glucose"] = instant + + state.measurementWatermarks = [:] + XCTAssertEqual(state.importWatermarks["glucose"], instant) + } + + func testSyncStateDecodesWithoutImportWatermarks() throws { + let legacy = #"{"measurementWatermarks":{}}"# + let state = try JSONDecoder().decode(AppleHealthSyncState.self, from: Data(legacy.utf8)) + XCTAssertTrue(state.importWatermarks.isEmpty) + } +} diff --git a/PulseLoopTests/HealthSyncNewTypesTests.swift b/PulseLoopTests/HealthSyncNewTypesTests.swift new file mode 100644 index 0000000..4b8efdc --- /dev/null +++ b/PulseLoopTests/HealthSyncNewTypesTests.swift @@ -0,0 +1,132 @@ +import XCTest +import HealthKit +import SwiftData +@testable import PulseLoop + +/// Respiratory rate, VO₂max, blood glucose and blood pressure were tracked and displayed in-app but +/// never reached Apple Health. These lock the four new mappings, the units they're written in, and +/// the two kinds that genuinely have nowhere to go. +@MainActor +final class HealthSyncNewTypesTests: XCTestCase { + + // MARK: - New quantity mappings + + func testRespiratoryRateMapsToCountPerMinute() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .respiratoryRate)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.respiratoryRate.rawValue) + XCTAssertEqual(mapping.unit, HKUnit.count().unitDivided(by: .minute())) + XCTAssertEqual(mapping.convert(16), 16, "brpm is already HealthKit's unit") + } + + func testVO2MaxMapsToMillilitresPerKilogramMinute() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .vo2max)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.vo2Max.rawValue) + let expected = HKUnit.literUnit(with: .milli) + .unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())) + XCTAssertEqual(mapping.unit, expected) + XCTAssertEqual(mapping.convert(42), 42) + } + + func testBloodGlucoseMapsToMgPerDecilitre() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .bloodSugar)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.bloodGlucose.rawValue) + XCTAssertEqual(mapping.unit, HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci))) + XCTAssertEqual(mapping.convert(95), 95, "stored canonically in mg/dL already") + } + + // MARK: - Plausibility, matching RingEventBridge's persistence gates + + func testNewMappingPlausibilityBounds() throws { + let resp = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .respiratoryRate)) + XCTAssertTrue(resp.isPlausible(4)) + XCTAssertTrue(resp.isPlausible(60)) + XCTAssertFalse(resp.isPlausible(3)) + XCTAssertFalse(resp.isPlausible(61)) + + let vo2 = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .vo2max)) + XCTAssertTrue(vo2.isPlausible(10)) + XCTAssertTrue(vo2.isPlausible(90)) + XCTAssertFalse(vo2.isPlausible(9)) + XCTAssertFalse(vo2.isPlausible(91)) + + let glucose = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .bloodSugar)) + XCTAssertTrue(glucose.isPlausible(40)) + XCTAssertTrue(glucose.isPlausible(600)) + XCTAssertFalse(glucose.isPlausible(39)) + XCTAssertFalse(glucose.isPlausible(601)) + } + + // MARK: - The two that stay unmapped + + /// Not a follow-up: HealthKit has no type for a device-derived wellness score. `HKStateOfMind` + /// is a self-reported mood log, so writing a ring's 0–100 number into it would misrepresent both. + func testStressAndFatigueHaveNoHealthKitType() { + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress)) + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue)) + } + + /// Blood pressure must not take the quantity path — a loose systolic or diastolic sample is + /// stored by Health but never surfaces as a reading, which looks exactly like a silent failure. + func testBloodPressureHalvesAreNotQuantityMapped() { + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureSystolic)) + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureDiastolic)) + } + + // MARK: - Blood-pressure pairing + + func testBloodPressurePlausibilityRejectsInvertedPairs() { + XCTAssertTrue(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 118, diastolic: 76)) + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 76, diastolic: 118), + "systolic below diastolic is a misframed packet, not a reading") + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 90, diastolic: 90), + "equal halves are not a valid reading either") + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 300, diastolic: 76)) + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 118, diastolic: 20)) + } + + /// Both halves share one instant, so the sync id is derived from that instant alone — a + /// re-export of the same reading upserts rather than duplicating. + func testBloodPressureSyncIDIsStablePerInstant() { + let instant = Date(timeIntervalSince1970: 1_760_000_000.25) + XCTAssertEqual(HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant), + HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant)) + XCTAssertNotEqual(HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant), + HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant.addingTimeInterval(0.001))) + } + + // MARK: - Preferences + + func testNewPerTypeTogglesDefaultOn() { + let prefs = AppleHealthPrefs.default + XCTAssertTrue(prefs.syncRespiratoryRate) + XCTAssertTrue(prefs.syncVO2Max) + XCTAssertTrue(prefs.syncBloodSugar) + XCTAssertTrue(prefs.syncBloodPressure) + } + + /// A blob written by a build that predates these keys must keep its existing choices rather than + /// being discarded wholesale. + func testTolerantDecodeOfAnOlderPrefsBlob() throws { + let legacy = #"{"masterEnabled":true,"syncHeartRate":false,"backfillChoice":"newDataOnly"}"# + let prefs = try JSONDecoder().decode(AppleHealthPrefs.self, from: Data(legacy.utf8)) + + XCTAssertTrue(prefs.masterEnabled) + XCTAssertFalse(prefs.syncHeartRate, "the stored choice survives") + XCTAssertEqual(prefs.backfillChoice, .newDataOnly) + XCTAssertTrue(prefs.syncVO2Max, "a key the old build never wrote falls back to its default") + } + + // MARK: - Capability gating for the settings rows + + func testHasAnyMeasurementDrivesRowVisibility() throws { + let context = try TestSupport.makeContext() + XCTAssertFalse(MetricsRepository.hasAnyMeasurement(kind: .vo2max, context: context)) + + context.insert(Measurement(kind: .vo2max, value: 42, unit: "mL/kg/min", timestamp: Date())) + try? context.save() + + XCTAssertTrue(MetricsRepository.hasAnyMeasurement(kind: .vo2max, context: context)) + XCTAssertFalse(MetricsRepository.hasAnyMeasurement(kind: .respiratoryRate, context: context), + "existence is per-kind, not any-row") + } +} diff --git a/PulseLoopTests/HealthSyncTests.swift b/PulseLoopTests/HealthSyncTests.swift index 10daa74..14f3f93 100644 --- a/PulseLoopTests/HealthSyncTests.swift +++ b/PulseLoopTests/HealthSyncTests.swift @@ -66,12 +66,14 @@ final class HealthSyncTests: XCTestCase { XCTAssertFalse(mapping.isPlausible(15), "far below body temperature") } + /// The kinds with no quantity mapping, and why — see `HealthSyncNewTypesTests` for the four that + /// gained one. Blood sugar left this list when `.bloodGlucose` was wired up; blood pressure + /// stayed, because it exports as an `HKCorrelation` rather than a quantity. func testUnsupportedKindsMapToNil() { - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress), "no native HealthKit equivalent") - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue), "no native HealthKit equivalent") + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress), "HealthKit has no type for a wellness score") + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue), "HealthKit has no type for a wellness score") XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureSystolic), "needs HKCorrelation pairing") XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureDiastolic), "needs HKCorrelation pairing") - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodSugar), "needs its own share type") } // MARK: - HealthKitTypeMappings: sleep-stage map diff --git a/docs/project/apple-health.md b/docs/project/apple-health.md new file mode 100644 index 0000000..6da2a6c --- /dev/null +++ b/docs/project/apple-health.md @@ -0,0 +1,107 @@ +--- +title: Apple Health +description: What PulseLoop writes to Apple Health, what it reads back, and why the two directions can never loop. +--- + +# Apple Health + +PulseLoop talks to Apple Health in both directions, and they are **separate opt-ins**: + +- **Export** — mirror your ring's data into Health, so other apps can see it. +- **Import** — read data other apps and devices wrote, so PulseLoop can see it. + +Bundling them under one switch would make one of them implicit. "Show my ring data elsewhere" and +"let other apps' data into mine" are different decisions with different privacy weight. + +Both default **off**. Nothing moves in either direction until you say so. + +## Export + +Written when enabled, each with its own toggle: + +| PulseLoop | HealthKit type | +|---|---| +| Heart rate | `heartRate` | +| Blood oxygen | `oxygenSaturation` | +| HRV | `heartRateVariabilitySDNN` | +| Skin temperature | `bodyTemperature`¹ | +| Respiratory rate | `respiratoryRate` | +| Cardio fitness | `vo2Max` | +| Blood glucose | `bloodGlucose` | +| Blood pressure | `bloodPressure` **correlation**² | +| Sleep stages | `sleepAnalysis` | +| Steps / energy / distance | the matching quantity types | +| Workouts | `HKWorkout` + `HKWorkoutRoute` | +| Meals | dietary energy + macros | + +¹ Apple's wrist-temperature type is read-only to third parties, so skin temperature is written as +body temperature. + +² Health only recognises a blood-pressure *reading* when systolic and diastolic are saved together +inside an `HKCorrelation`. Saved separately they are stored but never surface — which looks exactly +like a silent failure. + +Rows for a metric only appear as a toggle if your ring can actually produce it. + +### What is never exported + +**Stress and fatigue.** HealthKit has no type for a device-derived wellness score. `HKStateOfMind` +(iOS 17) is a self-reported mood log; writing a ring's 0–100 number into it would misrepresent both. +This isn't a follow-up — there is nothing to map them onto. + +Exports are idempotent: every sample carries a deterministic `HKMetadataKeySyncIdentifier`, so +re-running a pass replaces rather than duplicates. + +## Import + +Read when enabled: + +| Source | Becomes | +|---|---| +| `bloodGlucose` | Measurements tagged as coming from Health — this is how **CGM** data arrives | +| `bodyMass` | Your profile weight | + +A continuous glucose monitor writes to Health; PulseLoop reads it; from then on it sits in the same +store the coach already queries, beside your ring's sleep and heart rate. Oura's whole +metabolic-health feature is this same read. + +Body mass updates the **profile** rather than becoming a measurement row, because that is where the +rest of the app reads weight from (the calorie model, BMI). A second home for it would let the two +disagree. + +### What is deliberately not imported + +**Steps and workouts.** Both would double-count against what the ring already records — Health's +step count includes the iPhone's own pedometer, and a ring-recorded workout that PulseLoop exported +would come back as a second session. Merging those needs provenance-aware reconciliation that this +doesn't have, so it doesn't pretend to. + +## Why the two directions can't loop + +This is the invariant that matters most here, and it is enforced on **both** sides. + +PulseLoop exports glucose *and* imports it. Left unguarded, one reading would go round forever: +import a CGM value → export it as ours → import it back → export again. + +1. **The import excludes this app's own `HKSource`.** Anything PulseLoop wrote to Health is filtered + out of every read. +2. **Imported rows carry `MeasurementSource.appleHealth`**, and the export path's predicate excludes + that source. So even if a row somehow arrived, it could never be published back out as if the + ring had measured it. + +Either guard alone would close the loop; both are in place because they fail differently, and a test +pins each. + +## Dedup + +Imported readings are keyed on **(kind, instant, imported)** — the same rule every other history path +in the app uses. A CGM that revises a reading in place updates the existing row rather than stacking a +second one beside it, and an unchanged re-import writes nothing at all. + +An import never edits a *ring* row at the same instant. Those are two different claims about one +moment, and the ring's own reading isn't something an import may overwrite. + +## Watermarks + +Export and import keep **separate** high-water maps. Clearing one never disturbs the other — a full +re-export must not also re-import a year of glucose. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..9fadb46 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Apple Health: project/apple-health.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md