@@ -60,7 +60,12 @@ final class HealthSyncService {
6060 private var quantityWriteTypes : [ HKQuantityType ] {
6161 var identifiers : [ HKQuantityTypeIdentifier ] = [
6262 . heartRate , . oxygenSaturation , . heartRateVariabilitySDNN , . bodyTemperature ,
63- . stepCount , . activeEnergyBurned , . distanceWalkingRunning , . distanceCycling
63+ . stepCount , . activeEnergyBurned , . distanceWalkingRunning , . distanceCycling ,
64+ // Ring-dependent: only some families produce these, but the share set is fixed at
65+ // authorization time and can't be re-prompted per device, so all four are requested up
66+ // front. A ring that never reports one simply never writes it.
67+ . respiratoryRate , . vo2Max , . bloodGlucose ,
68+ . bloodPressureSystolic , . bloodPressureDiastolic
6469 ]
6570 // Dietary types join the share set only once the nutrition feature is enabled, so users
6671 // who never opted in never see dietary rows on the Health authorization sheet. Enabling
@@ -116,6 +121,8 @@ final class HealthSyncService {
116121
117122 do { try await exportVitals ( context : context , state : & state , counts : & counts , now : now , device : device ) }
118123 catch { log . error ( " Vitals export failed: \( error . localizedDescription ) " ) }
124+ do { try await exportBloodPressure ( context : context , state : & state , counts : & counts , now : now , device : device ) }
125+ catch { log . error ( " Blood-pressure export failed: \( error . localizedDescription ) " ) }
119126 do { try await exportActivity ( context : context , state : & state , counts : & counts , now : now , device : device ) }
120127 catch { log . error ( " Activity export failed: \( error . localizedDescription ) " ) }
121128 do { try await exportSleep ( context : context , state : & state , counts : & counts , now : now , device : device ) }
@@ -172,6 +179,10 @@ final class HealthSyncService {
172179 if prefs . syncSpO2 { kinds . append ( . spo2 ) }
173180 if prefs . syncHRV { kinds . append ( . hrv ) }
174181 if prefs . syncTemperature { kinds . append ( . temperature ) }
182+ if prefs . syncRespiratoryRate { kinds . append ( . respiratoryRate ) }
183+ if prefs . syncVO2Max { kinds . append ( . vo2max ) }
184+ if prefs . syncBloodSugar { kinds . append ( . bloodSugar ) }
185+ // Blood pressure is deliberately absent: it exports as a correlation, not a quantity.
175186 return kinds
176187 }
177188
@@ -235,6 +246,87 @@ final class HealthSyncService {
235246 )
236247 }
237248
249+ // MARK: - Blood-pressure pass
250+
251+ /// Blood pressure is the one vital that can't ride the quantity path: Health only recognises a
252+ /// reading when systolic and diastolic are saved together inside an `HKCorrelation`. Saved
253+ /// separately they are stored but never surface in the Health app, which looks exactly like a
254+ /// silent failure.
255+ ///
256+ /// Rows for the two halves are written from one packet at one instant (`bloodPressureEvents`), so
257+ /// the shared timestamp is the pairing key. A half without its partner is skipped rather than
258+ /// guessed at.
259+ ///
260+ /// The watermark reuses the `bloodPressureSystolic` slot in `measurementWatermarks`: that kind is
261+ /// never exported as a quantity, so the slot is free, and it inherits the reset/backfill handling
262+ /// `resetWatermarks` already applies to every `MeasurementKind`.
263+ private func exportBloodPressure ( context : ModelContext , state : inout AppleHealthSyncState ,
264+ counts : inout SyncCounts , now : Date , device : HKDevice ? ) async throws {
265+ guard prefsStore . prefs . syncBloodPressure ,
266+ let systolicType = HKQuantityType . quantityType ( forIdentifier : . bloodPressureSystolic ) ,
267+ let diastolicType = HKQuantityType . quantityType ( forIdentifier : . bloodPressureDiastolic ) ,
268+ let correlationType = HKCorrelationType . correlationType ( forIdentifier : . bloodPressure ) ,
269+ canShare ( systolicType ) , canShare ( diastolicType ) else { return }
270+
271+ let watermarkKey = MeasurementKind . bloodPressureSystolic . rawValue
272+ let systolicRaw = MeasurementKind . bloodPressureSystolic . rawValue
273+ let diastolicRaw = MeasurementKind . bloodPressureDiastolic . rawValue
274+ let mockRaw = MeasurementSource . mock . rawValue
275+ let watermark = state . measurementWatermarks [ watermarkKey ] ?? . distantPast
276+
277+ let systolicDescriptor = FetchDescriptor < Measurement > (
278+ predicate : #Predicate { $0 . kindRaw == systolicRaw && $0 . sourceRaw != mockRaw && $0 . createdAt > watermark } ,
279+ sortBy : [ SortDescriptor ( \. createdAt , order : . forward ) ]
280+ )
281+ let systolicRows = ( try? context . fetch ( systolicDescriptor ) ) ?? [ ]
282+ guard ! systolicRows . isEmpty else { return }
283+
284+ // Index the diastolic halves across the same instant span. Bounded by the batch's own range
285+ // rather than the watermark, so a partner row that was persisted in a different pass — and
286+ // therefore carries a different `createdAt` — is still found.
287+ guard let spanStart = systolicRows . map ( \. timestamp ) . min ( ) ,
288+ let spanEnd = systolicRows . map ( \. timestamp ) . max ( ) else { return }
289+ let diastolicDescriptor = FetchDescriptor < Measurement > (
290+ predicate : #Predicate {
291+ $0 . kindRaw == diastolicRaw && $0 . sourceRaw != mockRaw
292+ && $0 . timestamp >= spanStart && $0 . timestamp <= spanEnd
293+ }
294+ )
295+ let diastolicByInstant = Dictionary (
296+ ( ( try? context . fetch ( diastolicDescriptor ) ) ?? [ ] ) . map { ( $0 . timestamp , $0 . value ) } ,
297+ uniquingKeysWith : { first , _ in first }
298+ )
299+
300+ for chunk in systolicRows . chunked ( into : 1000 ) {
301+ let correlations : [ HKCorrelation ] = chunk . compactMap { row in
302+ guard row . timestamp <= now , let diastolic = diastolicByInstant [ row . timestamp ] ,
303+ HealthKitTypeMappings . isPlausibleBloodPressure ( systolic : row . value , diastolic : diastolic )
304+ else { return nil }
305+
306+ let unit = HKUnit . millimeterOfMercury ( )
307+ let metadata = HealthKitTypeMappings . metadata (
308+ syncID : HealthKitTypeMappings . bloodPressureSyncID ( timestamp : row . timestamp ) , version : 1
309+ )
310+ let objects : Set < HKSample > = [
311+ HKQuantitySample ( type : systolicType , quantity : HKQuantity ( unit : unit , doubleValue : row . value ) ,
312+ start : row . timestamp , end : row . timestamp , device : device , metadata : nil ) ,
313+ HKQuantitySample ( type : diastolicType , quantity : HKQuantity ( unit : unit , doubleValue : diastolic ) ,
314+ start : row . timestamp , end : row . timestamp , device : device , metadata : nil ) ,
315+ ]
316+ return HKCorrelation ( type : correlationType , start : row . timestamp , end : row . timestamp ,
317+ objects : objects , device : device , metadata : metadata )
318+ }
319+ if ! correlations . isEmpty {
320+ try await save ( correlations )
321+ counts . bloodPressure += correlations . count
322+ }
323+ if let maxCreated = chunk . map ( \. createdAt ) . max ( ) {
324+ state . measurementWatermarks [ watermarkKey ] = maxCreated
325+ prefsStore . syncState = state
326+ }
327+ }
328+ }
329+
238330 // MARK: - Daily activity pass
239331
240332 private func exportActivity ( context : ModelContext , state : inout AppleHealthSyncState ,
@@ -497,6 +589,7 @@ final class HealthSyncService {
497589
498590 struct SyncCounts {
499591 var vitals = 0
592+ var bloodPressure = 0
500593 var sleepSegments = 0
501594 var dailyTotals = 0
502595 var workouts = 0
@@ -505,6 +598,7 @@ final class HealthSyncService {
505598 var summary : String {
506599 var parts : [ String ] = [ ]
507600 if vitals > 0 { parts . append ( " \( vitals ) vitals " ) }
601+ if bloodPressure > 0 { parts . append ( " \( bloodPressure ) BP reading \( bloodPressure == 1 ? " " : " s " ) " ) }
508602 if sleepSegments > 0 { parts . append ( " \( sleepSegments ) sleep segment \( sleepSegments == 1 ? " " : " s " ) " ) }
509603 if dailyTotals > 0 { parts . append ( " \( dailyTotals ) daily total \( dailyTotals == 1 ? " " : " s " ) " ) }
510604 if workouts > 0 { parts . append ( " \( workouts ) workout \( workouts == 1 ? " " : " s " ) " ) }
0 commit comments