From b035d6277203208960d4bad2fb452338de93eb84 Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:32:03 -0400 Subject: [PATCH 1/2] Export the four ring metrics that stopped short of Apple Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respiratory rate, VO2max, blood glucose and blood pressure were all decoded, stored and displayed in-app, but the export path mapped every one of them to nil. Three were a genuine follow-up; blood pressure needed a different shape entirely. Respiratory rate, VO2max and glucose join the quantity path with the units HealthKit expects (count/min, mL/(kg*min), mg/dL) and the same plausibility bounds RingEventBridge already applies on the way into the store. Blood pressure gets its own pass, because Health only recognises a reading when systolic and diastolic are saved together in an HKCorrelation — saved separately they are stored but never surface in the Health app, which is indistinguishable from a silent failure. The two halves are written from one packet at one instant, so that shared timestamp is the pairing key; a half without its partner is skipped rather than guessed at, and a pair with systolic at or below diastolic is rejected as a misframed packet. Its watermark reuses the bloodPressureSystolic slot, which the quantity path never touches, so backfill and reset keep working unchanged. Stress and fatigue stay unmapped, and the comment now says why properly: this isn't a follow-up, HealthKit has no type for a device-derived wellness score. HKStateOfMind is a self-reported mood log and would misrepresent both. The four new toggles only appear for rings that can produce the metric — or that already have — since respiratory rate and VO2max come only from the YCBT records and glucose and BP only from jring and some YCBT units. A jring owner never sees a VO2max switch that could not write anything. Co-Authored-By: Claude Opus 5 --- PulseLoop/Health/HealthKitTypeMappings.swift | 53 +++++-- PulseLoop/Health/HealthSyncService.swift | 96 ++++++++++++- PulseLoop/Services/Repositories.swift | 10 ++ .../Settings/AppleHealthPrefsStore.swift | 12 ++ .../Settings/AppleHealthSettingsView.swift | 33 ++++- PulseLoopTests/HealthSyncNewTypesTests.swift | 132 ++++++++++++++++++ PulseLoopTests/HealthSyncTests.swift | 8 +- 7 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 PulseLoopTests/HealthSyncNewTypesTests.swift 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..ea72b3d 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 } @@ -235,6 +246,87 @@ 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 + let watermark = state.measurementWatermarks[watermarkKey] ?? .distantPast + + let systolicDescriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == systolicRaw && $0.sourceRaw != mockRaw && $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.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 +589,7 @@ final class HealthSyncService { struct SyncCounts { var vitals = 0 + var bloodPressure = 0 var sleepSegments = 0 var dailyTotals = 0 var workouts = 0 @@ -505,6 +598,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/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..9d9caa9 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 @@ -52,6 +60,10 @@ 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 diff --git a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift index b8c6b44..7d1ae21 100644 --- a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift +++ b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift @@ -10,6 +10,7 @@ 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 store = AppleHealthPrefsStore.shared /// First-enable backfill choice ("all history" vs "new only" vs cancel). @@ -21,6 +22,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) { @@ -96,17 +103,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", 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 From 0b7decbf7a1c10bc7682ac8f56c040b94a1ea76d Mon Sep 17 00:00:00 2001 From: ak710 Date: Mon, 3 Aug 2026 01:30:31 -0400 Subject: [PATCH 2/2] Read glucose and body weight back out of Apple Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Health integration only ever wrote. Reading 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 read. Import is its own opt-in, default off, separate from the export master toggle. "Show my ring data elsewhere" and "let other apps' data into mine" are different decisions with different privacy weight; one switch would make one of them implicit. The loop guard is the part that matters. PulseLoop now both exports and imports glucose, so unguarded, one reading would go round forever: import it, export it as ours, import it back. Both directions are guarded, because they fail differently — every read excludes this app's own HKSource, and imported rows carry MeasurementSource.appleHealth, which the export predicates now exclude. A test pins each. Imported readings dedup on (kind, instant, imported), the same rule every other history path uses, so a CGM revising a reading in place updates the row rather than stacking a second beside it. An import never edits a ring row at the same instant: those are two claims about one moment, and the ring's isn't something an import may overwrite. Values pass through RingEventBridge's own gate, so an implausible third-party reading is refused on exactly the same terms as an implausible ring one. Body mass updates the profile rather than becoming a measurement row — that is where the calorie model and BMI already read weight from, and a second home would let the two disagree. Steps and workouts are deliberately not imported. Both would double-count against what the ring records: Health's step count includes the iPhone's own pedometer, and a ring workout PulseLoop exported would return as a second session. Merging those needs provenance-aware reconciliation this doesn't have, so it doesn't pretend to. Import watermarks are a separate map from the export ones, so a full re-export never also re-imports a year of glucose. Both directions documented in docs/project/apple-health.md. Co-Authored-By: Claude Opus 5 --- PulseLoop/Health/HealthImportService.swift | 207 ++++++++++++++++++ PulseLoop/Health/HealthSyncService.swift | 17 +- PulseLoop/Models/PulseModels.swift | 4 + .../Settings/AppleHealthPrefsStore.swift | 22 ++ .../Settings/AppleHealthSettingsView.swift | 44 ++++ PulseLoopTests/HealthImportTests.swift | 160 ++++++++++++++ docs/project/apple-health.md | 107 +++++++++ mkdocs.yml | 1 + 8 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 PulseLoop/Health/HealthImportService.swift create mode 100644 PulseLoopTests/HealthImportTests.swift create mode 100644 docs/project/apple-health.md 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/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index ea72b3d..a0866d3 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -208,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)) ?? [] @@ -272,10 +278,15 @@ final class HealthSyncService { 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.createdAt > watermark }, + 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)) ?? [] @@ -288,7 +299,7 @@ final class HealthSyncService { let spanEnd = systolicRows.map(\.timestamp).max() else { return } let diastolicDescriptor = FetchDescriptor( predicate: #Predicate { - $0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw + $0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw && $0.sourceRaw != importedRaw && $0.timestamp >= spanStart && $0.timestamp <= spanEnd } ) 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/Settings/AppleHealthPrefsStore.swift b/PulseLoop/Settings/AppleHealthPrefsStore.swift index 9d9caa9..49e3eeb 100644 --- a/PulseLoop/Settings/AppleHealthPrefsStore.swift +++ b/PulseLoop/Settings/AppleHealthPrefsStore.swift @@ -44,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() {} @@ -67,6 +81,9 @@ struct AppleHealthPrefs: Codable, Equatable { 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 } } @@ -95,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? @@ -111,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 7d1ae21..fb3949f 100644 --- a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift +++ b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift @@ -12,6 +12,7 @@ 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 @@ -47,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) @@ -210,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/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