diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 1a6758dc5d..4a4c8f094b 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -126402,6 +126402,528 @@ } } }, + "Read your heart rate, HRV, blood oxygen, respiratory rate, sleep, steps and energy from Apple Health, and write back your nightly resting heart rate, HRV, blood oxygen and respiratory rate. Continuous heart rate, workouts and body composition are optional — turn them on below once this is connected. Everything stays on %@.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lies deine Herzfrequenz, HRV, deinen Blutsauerstoff, deine Atemfrequenz, deinen Schlaf, deine Schritte und Energie aus Apple Health, und schreibe deine nächtliche Ruheherzfrequenz, HRV, deinen Blutsauerstoff und deine Atemfrequenz zurück. Kontinuierliche Herzfrequenz, Workouts und Körperzusammensetzung sind optional – aktiviere sie unten, sobald die Verbindung steht. Alles bleibt auf %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lee tu frecuencia cardíaca, VFC, oxígeno en sangre, frecuencia respiratoria, sueño, pasos y energía desde Apple Health, y escribe de vuelta tu frecuencia cardíaca en reposo nocturna, VFC, oxígeno en sangre y frecuencia respiratoria. La frecuencia cardíaca continua, los entrenamientos y la composición corporal son opcionales: actívalos abajo una vez conectado. Todo permanece en %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lisez votre fréquence cardiaque, la VFC, l'oxygène sanguin, la fréquence respiratoire, le sommeil, les pas et l'énergie depuis Apple Santé, et réécrivez votre fréquence cardiaque de repos nocturne, la VFC, l'oxygène sanguin et la fréquence respiratoire. La fréquence cardiaque continue, les entraînements et la composition corporelle sont optionnels : activez-les ci-dessous une fois connecté. Tout reste sur %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Leggi la tua frequenza cardiaca, l'HRV, l'ossigeno nel sangue, la frequenza respiratoria, il sonno, i passi e l'energia da Apple Health, e riscrivi la tua frequenza cardiaca a riposo notturna, l'HRV, l'ossigeno nel sangue e la frequenza respiratoria. La frequenza cardiaca continua, gli allenamenti e la composizione corporea sono opzionali — attivali qui sotto una volta connesso. Tutto resta su %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odczytuj tętno, HRV, natlenienie krwi, częstość oddechów, sen, kroki i energię z Apple Health oraz zapisuj nocne tętno spoczynkowe, HRV, natlenienie krwi i częstość oddechów. Ciągły pomiar tętna, treningi i skład ciała są opcjonalne — włącz je poniżej po nawiązaniu połączenia. Wszystko pozostaje na %@." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Lê a tua frequência cardíaca, VFC, oxigénio no sangue, frequência respiratória, sono, passos e energia do Apple Health, e escreve de volta a tua frequência cardíaca de repouso noturna, VFC, oxigénio no sangue e frequência respiratória. A frequência cardíaca contínua, os treinos e a composição corporal são opcionais — ativa-os abaixo assim que estiver ligado. Tudo permanece em %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считывайте пульс, ВСР, кислород в крови, частоту дыхания, сон, шаги и энергию из Apple Health, а также записывайте обратно ночной пульс покоя, ВСР, кислород в крови и частоту дыхания. Непрерывный пульс, тренировки и состав тела необязательны — включите их ниже после подключения. Всё остаётся на %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从 Apple 健康读取你的心率、HRV、血氧、呼吸频率、睡眠、步数和能量,并写回你的夜间静息心率、HRV、血氧和呼吸频率。连续心率、锻炼和身体成分是可选项——连接后可在下方开启。一切都留在 %@ 上。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從 Apple 健康讀取你的心率、HRV、血氧、呼吸頻率、睡眠、步數和能量,並寫回你的夜間靜息心率、HRV、血氧和呼吸頻率。連續心率、鍛鍊和身體組成為選用項目——連接後可於下方開啟。一切都留在 %@ 上。" + } + } + } + }, + "Body composition": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Körperzusammensetzung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Composición corporal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Composition corporelle" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Composizione corporea" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Skład ciała" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Composição corporal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состав тела" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "身体成分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "身體組成" + } + } + } + }, + "Weight, body fat and lean mass, imported read-only. Never written back.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewicht, Körperfett und Muskelmasse, nur lesend importiert. Wird nie zurückgeschrieben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Peso, grasa corporal y masa magra, importados solo lectura. Nunca se escriben de vuelta." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Poids, masse grasse et masse maigre, importés en lecture seule. Jamais réécrits." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Peso, massa grassa e massa magra, importati in sola lettura. Mai riscritti." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Masa ciała, tkanka tłuszczowa i beztłuszczowa masa ciała są importowane tylko do odczytu. Nigdy nie są zapisywane z powrotem." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Peso, gordura corporal e massa magra, importados apenas para leitura. Nunca são reenviados." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вес, процент жира и мышечная масса, импортируются только для чтения. Никогда не записываются обратно." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "体重、体脂和瘦体重,仅以只读方式导入,绝不写回。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "體重、體脂和瘦體重,僅以唯讀方式匯入,絕不寫回。" + } + } + } + }, + "Continuous heart rate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontinuierliche Herzfrequenz" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Frecuencia cardíaca continua" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fréquence cardiaque continue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Frequenza cardiaca continua" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ciągły pomiar tętna" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Frequência cardíaca contínua" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрерывный пульс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连续心率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連續心率" + } + } + } + }, + "Enabled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktiviert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attivato" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włączone" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已啟用" + } + } + } + }, + "Write NOOP's minute-by-minute heart rate into Apple Health.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibt NOOPs minutengenaue Herzfrequenz in Apple Health." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escribe la frecuencia cardíaca minuto a minuto de NOOP en Apple Health." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écrit la fréquence cardiaque minute par minute de NOOP dans Apple Santé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scrive la frequenza cardiaca minuto per minuto di NOOP in Apple Health." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisuje minutowe pomiary tętna z NOOP w Apple Health." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Escreve a frequência cardíaca minuto a minuto do NOOP no Apple Health." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записывает поминутный пульс NOOP в Apple Health." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 NOOP 逐分钟的心率写入 Apple 健康。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 NOOP 逐分鐘的心率寫入 Apple 健康。" + } + } + } + }, + "Add continuous heart rate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontinuierliche Herzfrequenz hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir frecuencia cardíaca continua" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter la fréquence cardiaque continue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi frequenza cardiaca continua" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj ciągły pomiar tętna" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Adicionar frequência cardíaca contínua" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить непрерывный пульс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加连续心率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增連續心率" + } + } + } + }, + "Write your strap and manual workouts, with energy and distance, into Apple Health.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibt deine Band- und manuellen Workouts, mit Energie und Distanz, in Apple Health." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escribe tus entrenamientos de la correa y manuales, con energía y distancia, en Apple Health." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écrit vos entraînements du bracelet et manuels, avec l'énergie et la distance, dans Apple Santé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scrive i tuoi allenamenti del cinturino e manuali, con energia e distanza, in Apple Health." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zapisuje treningi z opaski i dodane ręcznie wraz z energią i dystansem w Apple Health." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Escreve os teus treinos da bracelete e manuais, com energia e distância, no Apple Health." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записывает тренировки с браслета и вручную добавленные, с энергией и дистанцией, в Apple Health." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将你的手环和手动记录的锻炼(含能量和距离)写入 Apple 健康。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將你的手環和手動記錄的鍛鍊(含能量和距離)寫入 Apple 健康。" + } + } + } + }, + "Add workouts": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Workouts hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir entrenamientos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter des entraînements" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi allenamenti" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj treningi" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Adicionar treinos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить тренировки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增鍛鍊" + } + } + } + }, "Readiness": { "localizations": { "de": { @@ -212449,4 +212971,4 @@ } } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Strand/Screens/AppleHealthView.swift b/Strand/Screens/AppleHealthView.swift index 6e93e7a56b..9d2237f8c0 100644 --- a/Strand/Screens/AppleHealthView.swift +++ b/Strand/Screens/AppleHealthView.swift @@ -412,7 +412,11 @@ struct AppleHealthView: View { .fixedSize(horizontal: false, vertical: true) case .unknown, .denied: - Text("Read your heart rate, HRV, blood oxygen, respiratory rate, sleep, steps and energy straight from Apple Health, and write NOOP's strap data back: sleep with full stages, continuous heart rate, workouts, and nightly vitals. Everything stays on \(Platform.deviceNounPhrase).") + // #653: narrower than the old copy — this ask now covers ONLY core reads (heart + // rate, HRV, blood oxygen, respiratory rate, sleep, steps, energy) + the nightly + // vitals write-back. Continuous heart rate, workouts, and body composition are + // separate, optional asks surfaced below once connected (see the `.authorized` case). + Text("Read your heart rate, HRV, blood oxygen, respiratory rate, sleep, steps and energy from Apple Health, and write back your nightly resting heart rate, HRV, blood oxygen and respiratory rate. Continuous heart rate, workouts and body composition are optional — turn them on below once this is connected. Everything stays on \(Platform.deviceNounPhrase).") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -467,6 +471,77 @@ struct AppleHealthView: View { .buttonStyle(.bordered) .tint(StrandPalette.metricCyan) .disabled(health.syncing) + + // #653: optional, narrower extras beyond the core read + nightly vitals write-back + // the button above already covers. Each explains what it reads/writes before its own + // HealthKit prompt fires, instead of bundling everything into one ask. + Divider().overlay(StrandPalette.hairline.opacity(0.4)) + + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: Binding( + get: { health.bodyCompositionEnabled }, + set: { on in health.setBodyCompositionEnabled(on) } + )) { + Text("Body composition") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .tint(StrandPalette.metricCyan) + Text("Weight, body fat and lean mass, imported read-only. Never written back.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Continuous heart rate") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Spacer() + if health.isHeartRateWritebackAuthorized { + StatePill("Enabled", tone: .positive) + } + } + Text("Write NOOP's minute-by-minute heart rate into Apple Health.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if !health.isHeartRateWritebackAuthorized { + Button { + Task { await health.requestHeartRateWriteback() } + } label: { + Text("Add continuous heart rate") + } + .buttonStyle(.bordered) + .tint(StrandPalette.metricCyan) + } + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Workouts") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Spacer() + if health.isWorkoutWritebackAuthorized { + StatePill("Enabled", tone: .positive) + } + } + Text("Write your strap and manual workouts, with energy and distance, into Apple Health.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if !health.isWorkoutWritebackAuthorized { + Button { + Task { await health.requestWorkoutWriteback() } + } label: { + Text("Add workouts") + } + .buttonStyle(.bordered) + .tint(StrandPalette.metricCyan) + } + } } if let err = health.lastError { diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index f7c4c56b08..7d898f23e0 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -61,6 +61,18 @@ final class HealthKitBridge: ObservableObject { /// here so an Apple Health auth revoke, quota hit, or invalid sample is visible instead of silent. @Published private(set) var lastError: String? + /// Whether the user has opted into the body-composition READ import (#653). Unlike the write-back + /// stages below, HealthKit never reveals read-grant status, so there is no live signal to check — + /// NOOP tracks its own "the user asked for this" bit and gates `sync()`'s body-composition reads on + /// it, so a category the user never turned on is neither requested from HealthKit nor read even if + /// (somehow) already authorized. See `isHeartRateWritebackAuthorized` / `isWorkoutWritebackAuthorized` + /// for the two write-back stages, whose granted state IS observable and so needs no persisted flag. + @Published private(set) var bodyCompositionEnabled: Bool + /// Bumped after every staged `request*Authorization` call so SwiftUI re-evaluates the two live + /// `is*WritebackAuthorized` computed properties below (they read `store.authorizationStatus` + /// directly, which isn't itself `@Published` and so wouldn't otherwise trigger a re-render). + @Published private(set) var authRefreshTick = 0 + private let store = HKHealthStore() private let repo: Repository /// Source id imported HealthKit data lands under (matches `AppModel.appleDeviceId`). @@ -77,6 +89,25 @@ final class HealthKitBridge: ObservableObject { self.repo = repo self.appleDeviceId = appleDeviceId self.noopDeviceId = noopDeviceId + + // #653 migration: a pre-existing install that already completed the OLD bundled "Enable Apple + // Health" flow (which requested body-composition reads together with everything else, in one + // sheet) keeps seeing that data instead of it silently vanishing behind a new, unset toggle. A + // genuinely new install has nothing granted yet, so it defaults OFF like every other new staged + // toggle here — matching the explicit-opt-in spirit of #653 rather than assuming consent. + // Detected via the SAME legacy write-status signal `refreshAuthIfPreviouslyGranted` resumes off + // of (HealthKit exposes share/write status reliably; it never exposes read status at all, which + // is exactly why this needs its own persisted flag instead of a live check like the write-back + // stages below). + let bodyCompKey = HealthKitBridge.bodyCompositionDefaultsKey + if let stored = UserDefaults.standard.object(forKey: bodyCompKey) as? Bool { + self.bodyCompositionEnabled = stored + } else { + let migrated = HealthKitBridge.nightlyVitalsWriteTypesGranted(store: HKHealthStore()) + self.bodyCompositionEnabled = migrated + UserDefaults.standard.set(migrated, forKey: bodyCompKey) + } + // Order matters: a free-signed build with no HealthKit entitlement is dead in the water even // where the hardware supports Health, so surface that first. `.unavailable` (no HealthKit at // all, e.g. iPad without the framework) still wins where it applies because we only reach the @@ -88,42 +119,41 @@ final class HealthKitBridge: ObservableObject { } } - // MARK: - Types - - private var readTypes: Set { - var s = Set() - for id in HealthKitBridge.quantityReadIds { if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } } - if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } - s.insert(HKObjectType.workoutType()) - // #1205: workout routes (GPS) so imported workouts can show their map. Routes are separate - // samples associated with each HKWorkout; without this in readTypes the route query returns - // empty and the imported workout shows distance but no map — same as today, so the addition - // is strictly additive. - s.insert(HKSeriesType.workoutRoute()) - return s - } - - private var writeTypes: Set { - var s = Set() - for id in HealthKitBridge.quantityWriteIds + HealthKitBridge.highResQuantityWriteIds - where !HealthKitBridge.writeDenied.contains(id) { - if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } - } - if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } - s.insert(HKObjectType.workoutType()) - return s + // MARK: - Types (#653: staged consent — see the enum + read/write accessors below) + + /// One independently-consented Apple Health scope. Splitting the single "Enable Apple Health" + /// prompt — which used to ask for every read AND write type NOOP touches, in one system sheet — + /// into stages the user opts into individually, each explicit about what it reads/writes BEFORE its + /// own HealthKit prompt fires. `coreRead` + `nightlyVitalsWriteback` are still requested together as + /// the primary "Enable Apple Health" action (`requestAuthorization()`, unchanged in name/signature so + /// both existing call sites keep compiling) — that pairing is what already fed Charge/Rest/Effort + /// scores before this change, so it stays the one-tap default. `bodyComposition`, + /// `heartRateWriteback`, and `workoutWriteback` are additional, narrower asks a user opts into + /// separately once connected. Every existing PER-FEATURE authorization guard (`writeVitals` checking + /// each vital's own share status, `writeHeartRate` / `writeWorkouts` checking theirs) is UNCHANGED — + /// staging only changes what gets requested and when, never what's checked before a write. + private enum ConsentStage { + case coreRead, bodyComposition, nightlyVitalsWriteback, heartRateWriteback, workoutWriteback } - // Every id here ends up in the HealthKit permission dialog. Only request what `sync` actually - // aggregates into `DayAgg`; adding read scopes the app never consumes makes the consent prompt - // noisier and surfaces a privacy ask we don't honour. - private static let quantityReadIds: [HKQuantityTypeIdentifier] = [ + // Every id here ends up in the HealthKit permission dialog for its stage. Only request what `sync` + // actually aggregates into `DayAgg` (coreRead) / what each write-back feature actually writes; adding + // scopes a feature never consumes makes the consent prompt noisier and surfaces a privacy ask we + // don't honour. + private static let coreReadIds: [HKQuantityTypeIdentifier] = [ .heartRate, .restingHeartRate, .heartRateVariabilitySDNN, .oxygenSaturation, .respiratoryRate, .bodyTemperature, .stepCount, .activeEnergyBurned, .basalEnergyBurned, .vo2Max, - // Body composition — READ-ONLY (#20). Imported under the apple-health source like the file - // importer already ingests; deliberately NOT in quantityWriteIds (we never write these back). - .bodyMass, .bodyFatPercentage, .leanBodyMass, .bodyMassIndex, + ] + /// Body composition — READ-ONLY (#20), and its own opt-in stage (#653): imported under the + /// apple-health source like the file importer already ingests, but never in any write-type set. + private static let bodyCompositionReadIds: [HKQuantityTypeIdentifier] = [ + .bodyMass, .bodyFatPercentage, .leanBodyMass, .bodyMassIndex + ] + // Water/caffeine (#949) stay read-only under `.coreRead` via `readTypes(for:)`'s sleep/workout + // additions — they were never part of any write set pre-#653 either, so the split above doesn't + // touch them; see `readTypes(for: .coreRead)`. + private static let dietaryReadIds: [HKQuantityTypeIdentifier] = [ // Water — READ-ONLY (#949), so drinks logged in a dedicated hydration app (or by a smart bottle) // show up without being typed in twice. Lands in the hydration source rather than apple-health, // because the hydration screen is what consumes it. Never written back. @@ -135,26 +165,157 @@ final class HealthKitBridge: ObservableObject { // matched here. Never written back. .dietaryCaffeine ] - private static let quantityWriteIds: [HKQuantityTypeIdentifier] = [ + /// The nightly vitals write-back set (#653's "core nightly vitals writeback" stage). Identical to + /// the set pre-#653 installs already granted under the old bundled flow, so + /// `refreshAuthIfPreviouslyGranted` / the init-time body-composition migration above can both resume + /// off it unchanged. + private static let nightlyVitalsWriteIds: [HKQuantityTypeIdentifier] = [ .restingHeartRate, .heartRateVariabilitySDNN, .oxygenSaturation, .respiratoryRate ] - /// Identifiers that must NEVER enter a `requestAuthorization(toShare:)` set, filtered out of every - /// write set below (#1366). HealthKit reserves some quantity types to Apple Watch — - /// `.appleSleepingWristTemperature`, the only type that fits a worn wrist skin temperature, is one — - /// and asking to SHARE such a type does not fail softly: it raises an uncatchable ObjC - /// `NSInvalidArgumentException` ("Authorization to share the following types is disallowed") that - /// terminates the app on launch (verified on device, iOS 27). Swift `try/catch` cannot intercept an - /// `NSException`, so the ONLY defense is to keep read-only ids out of the share set. Filtering here - /// makes that structural: a read-only id added to a write list by mistake is dropped from the ask - /// instead of bricking launch, turning a ship-and-crash into a no-op. + /// Identifiers that must NEVER enter a `requestAuthorization(toShare:)` set (#1366/#1371). HealthKit + /// reserves some quantity types to Apple Watch — `.appleSleepingWristTemperature`, the only type that + /// fits a worn wrist skin temperature, is one — and asking to SHARE such a type does not fail softly: + /// it raises an uncatchable ObjC `NSInvalidArgumentException` that terminates the app on launch + /// (verified on device, iOS 27). None of the per-stage write lists below currently include it, but + /// each stage's `writeTypes(for:)` case still filters through this denylist — the same structural + /// defense #1371 added to the old single combined write list, kept here so a read-only id added to + /// any ONE stage's list by mistake is dropped from that stage's ask instead of bricking launch. private static let writeDenied: Set = [.appleSleepingWristTemperature] - // High-res write-back shares: the continuous 1-minute HR stream, and the energy/distance samples - // attached to written workouts. Kept separate from the four nightly-vital ids so the permission - // expansion and each independently-authorized writer remain explicit. - private static let highResQuantityWriteIds: [HKQuantityTypeIdentifier] = [ - .heartRate, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling + /// The continuous 1-minute heart-rate stream write-back — its own opt-in stage (#653). + private static let heartRateWriteId: HKQuantityTypeIdentifier = .heartRate + /// The energy/distance samples attached to written workouts — bundled with the workout write-back + /// stage (#653), since neither means anything without the workout it's attached to. + private static let workoutEnergyDistanceWriteIds: [HKQuantityTypeIdentifier] = [ + .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling ] + /// The read types HealthKit should be asked for on behalf of `stage`; empty for a write-only stage. + private func readTypes(for stage: ConsentStage) -> Set { + switch stage { + case .coreRead: + var s = Set() + for id in HealthKitBridge.coreReadIds + HealthKitBridge.dietaryReadIds { + if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } + } + if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } + s.insert(HKObjectType.workoutType()) + // #1205: workout routes are separate samples associated with each HKWorkout. Keep them + // in the core read stage so imported workouts retain their GPS maps after consent is split. + s.insert(HKSeriesType.workoutRoute()) + return s + case .bodyComposition: + var s = Set() + for id in HealthKitBridge.bodyCompositionReadIds { if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } } + return s + case .nightlyVitalsWriteback, .heartRateWriteback, .workoutWriteback: + return [] + } + } + + /// The share (write) types HealthKit should be asked for on behalf of `stage`; empty for a + /// read-only stage. + private func writeTypes(for stage: ConsentStage) -> Set { + switch stage { + case .coreRead, .bodyComposition: + return [] + case .nightlyVitalsWriteback: + var s = Set() + for id in HealthKitBridge.nightlyVitalsWriteIds where !HealthKitBridge.writeDenied.contains(id) { + if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } + } + if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } + return s + case .heartRateWriteback: + var s = Set() + if !HealthKitBridge.writeDenied.contains(HealthKitBridge.heartRateWriteId), + let t = HKObjectType.quantityType(forIdentifier: HealthKitBridge.heartRateWriteId) { + s.insert(t) + } + return s + case .workoutWriteback: + var s = Set() + for id in HealthKitBridge.workoutEnergyDistanceWriteIds where !HealthKitBridge.writeDenied.contains(id) { + if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } + } + s.insert(HKObjectType.workoutType()) + return s + } + } + + /// Self-free helper (usable from `init`, before every stored property is set) for whether the + /// nightly-vitals write types are ALL already `.sharingAuthorized` — the one signal HealthKit gives + /// us reliably, and the same one `refreshAuthIfPreviouslyGranted` resumes `auth` off of. + private static func nightlyVitalsWriteTypesGranted(store: HKHealthStore) -> Bool { + var s = Set() + for id in nightlyVitalsWriteIds { if let t = HKObjectType.quantityType(forIdentifier: id) { s.insert(t) } } + if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } + return s.allSatisfy { store.authorizationStatus(for: $0) == .sharingAuthorized } + } + + /// UserDefaults key for the persisted body-composition read opt-in (#653). + private static let bodyCompositionDefaultsKey = "hkBodyCompositionReadEnabled.v1" + + /// Live HealthKit signal for whether the continuous-HR write-back stage is granted. No persisted + /// flag needed — unlike the two read-only stages, HealthKit reliably reports SHARE status, and the + /// existing `writeHeartRate` guard already checks this exact type before ever writing. + var isHeartRateWritebackAuthorized: Bool { + _ = authRefreshTick // re-evaluated whenever a staged request completes; see the property's doc. + guard let type = HKQuantityType.quantityType(forIdentifier: HealthKitBridge.heartRateWriteId) else { return false } + return store.authorizationStatus(for: type) == .sharingAuthorized + } + + /// Live HealthKit signal for whether the workout write-back stage is granted (workout share type + /// specifically — the same one `writeWorkouts` already gates on before ever writing). + var isWorkoutWritebackAuthorized: Bool { + _ = authRefreshTick + return store.authorizationStatus(for: .workoutType()) == .sharingAuthorized + } + + /// Turn the body-composition READ stage on (requesting HealthKit access if needed) or off (just the + /// local flag — HealthKit has no API to revoke a granted read type from inside the app, so "off" + /// here means "NOOP stops asking for/using it going forward", not "access is rescinded"). + func setBodyCompositionEnabled(_ enabled: Bool) { + if enabled { + Task { await requestBodyCompositionAuthorization() } + } else { + bodyCompositionEnabled = false + UserDefaults.standard.set(false, forKey: HealthKitBridge.bodyCompositionDefaultsKey) + } + } + + /// Request the body-composition read stage (#653). Requires the core stage to already be enabled — + /// this is presented as an "extra" once connected, not a standalone first step. A successful request + /// (sheet shown, regardless of what the user then picks — HealthKit never reveals read decisions, + /// same philosophy as `requestAuthorization()`) flips the persisted flag on so `sync()` starts + /// reading these types. + func requestBodyCompositionAuthorization() async { + guard auth == .authorized else { return } + do { + try await store.requestAuthorization(toShare: writeTypes(for: .bodyComposition), read: readTypes(for: .bodyComposition)) + bodyCompositionEnabled = true + UserDefaults.standard.set(true, forKey: HealthKitBridge.bodyCompositionDefaultsKey) + } catch { + // Leave the flag as it was — a thrown request here means a genuine system-level failure + // (see `requestAuthorization()`'s doc), not a user decline, so there's nothing to roll back. + } + } + + /// Request the continuous heart-rate write-back stage (#653). `writeHeartRate` already checks this + /// exact type's share status before writing, so nothing else needs to change once granted. + func requestHeartRateWriteback() async { + guard auth == .authorized else { return } + try? await store.requestAuthorization(toShare: writeTypes(for: .heartRateWriteback), read: []) + authRefreshTick += 1 + } + + /// Request the workout (+ energy/distance) write-back stage (#653). `writeWorkouts` already checks + /// the workout type's share status before writing, so nothing else needs to change once granted. + func requestWorkoutWriteback() async { + guard auth == .authorized else { return } + try? await store.requestAuthorization(toShare: writeTypes(for: .workoutWriteback), read: []) + authRefreshTick += 1 + } + // MARK: - Authorization /// UserDefaults key holding the read set the user was last asked about. @@ -165,27 +326,32 @@ final class HealthKitBridge: ObservableObject { /// `sync()` only walks the same window the daily collectors already use. private static let hourlyStepsBackfilledKey = "applehealth.hourlySteps.backfilled" - /// A stable fingerprint of the read types currently requested. + /// A stable fingerprint of the `.coreRead` stage's read types currently requested. Deliberately + /// EXCLUDES body composition (#653 gave it its own persisted opt-in flag and its own explicit + /// request path — see `bodyCompositionEnabled` / `requestBodyCompositionAuthorization` — so a user + /// who has that toggled off must not be silently re-asked for it here). private static var readTypeSignature: String { - quantityReadIds.map(\.rawValue).sorted().joined(separator: ",") + (coreReadIds + dietaryReadIds).map(\.rawValue).sorted().joined(separator: ",") } private static func persistReadTypeSignature() { UserDefaults.standard.set(readTypeSignature, forKey: readTypeSignatureKey) } - /// Re-request authorization when the app has STARTED reading a type it never used to (#949). + /// Re-request the `.coreRead` stage when the app has STARTED reading a type it never used to (#949). /// - /// HealthKit never reports read authorization, and `requestAuthorization` is only called from the - /// connect button. So a read type added in an update stays `.notDetermined` for everyone who granted - /// access before it existed, and its queries return empty forever — indistinguishable from "you have - /// no water in Health", and silent. Water and caffeine would have done nothing at all for every - /// existing user, which is most of them. + /// HealthKit never reports read authorization, and the core read set is only requested from the + /// connect button (`requestAuthorization()`). So a read type added in an update stays + /// `.notDetermined` for everyone who granted access before it existed, and its queries return empty + /// forever — indistinguishable from "you have no water in Health", and silent. Water and caffeine + /// would have done nothing at all for every existing user, which is most of them. /// /// Comparing a stored fingerprint of the read set catches that. Re-requesting is cheap and quiet: /// HealthKit presents the sheet ONLY for types that are still undetermined, so a user whose set is /// unchanged sees no UI, and a returning user is asked about exactly the new ones. The signature is - /// stored only on success, so a failed request is retried rather than silently swallowed. + /// stored only on success, so a failed request is retried rather than silently swallowed. Scoped to + /// `.coreRead`'s read types only (empty share set) — body composition, heart-rate write-back, and + /// workout write-back each have their own explicit opt-in and must never be re-requested from here. private func requestNewReadTypesIfNeeded() async { // FOREGROUND only. `sync` is also driven by background observer wakes, and asking there would // spend the one request we get where no sheet can be presented — if that call reported success @@ -196,7 +362,7 @@ final class HealthKitBridge: ObservableObject { != HealthKitBridge.readTypeSignature else { return } do { - try await store.requestAuthorization(toShare: writeTypes, read: readTypes) + try await store.requestAuthorization(toShare: writeTypes(for: .coreRead), read: readTypes(for: .coreRead)) HealthKitBridge.persistReadTypeSignature() } catch { // Leave the signature unset so the next sync tries again. Not surfaced in `lastError`: the @@ -216,7 +382,12 @@ final class HealthKitBridge: ObservableObject { // can never appear). Detect via the embedded provisioning profile up front (#348). guard HealthKitBridge.hasHealthKitEntitlement else { auth = .entitlementMissing; return } do { - try await store.requestAuthorization(toShare: writeTypes, read: readTypes) + // #653: the primary "Enable Apple Health" ask is narrowed to core reads + the nightly + // vitals write-back — the pairing that already fed Charge/Rest/Effort scores. Continuous + // heart rate, workouts, and body composition are separate, narrower asks (see + // `requestHeartRateWriteback` / `requestWorkoutWriteback` / `requestBodyCompositionAuthorization`) + // a user opts into afterward, each explicit about its own scope before its own prompt fires. + try await store.requestAuthorization(toShare: writeTypes(for: .nightlyVitalsWriteback), read: readTypes(for: .coreRead)) // The entitlement is present (the guard above proved it via the embedded profile, or there's // no profile = App Store build), so a successful request means the bridge is usable. We do // NOT reclassify to `.entitlementMissing` off the post-request `.notDetermined` heuristic @@ -249,38 +420,34 @@ final class HealthKitBridge: ObservableObject { /// status, so no system permission sheet is shown. func refreshAuthIfPreviouslyGranted() { guard auth == .unknown, HKHealthStore.isHealthDataAvailable() else { return } - // Share authorization is per type. Resume when at least one write type is granted so a person - // who intentionally declined (for example) workouts still gets sleep/vitals exported after a - // relaunch. Requiring every legacy type made partial grants look wholly disconnected. - let granted = writeTypes.contains { store.authorizationStatus(for: $0) == .sharingAuthorized } + // #1024 (called from the offload write-back, in processes that were never foregrounded) needs + // ONLY the status read below — `auth` resumed to `.authorized` so a completed backfill isn't + // silently dropped while `auth` sits at `.unknown`. #653 needs this to keep reading the SAME + // legacy signal it always has (nightly vitals write share status) so a pre-#653 grant still + // resumes exactly as before staging existed. + let granted = HealthKitBridge.nightlyVitalsWriteTypesGranted(store: store) if granted { auth = .authorized // A returning user who already granted access should get the live stream re-armed for this // process. enableLiveDelivery is idempotent (HealthKit dedups observers + background // delivery per type), so calling it here as well as after a fresh requestAuthorization is safe. enableLiveDelivery() - // The high-res write-back added share types (HR stream, workouts, energy/distance) that a - // pre-update grant has as `.notDetermined`. Re-request once: HealthKit shows a single sheet - // listing ONLY the new types, and each write feature independently guards on its own type's - // share status, so declining any checkbox just skips that feature. - // Raw request, NOT requestAuthorization(): that method reclassifies a thrown error as - // `.denied`, which must never demote a bridge that just resumed a valid legacy grant. - // - // FOREGROUND only, for the same reason `requestNewReadTypesIfNeeded` is: this resume is now - // also called from the offload write-back (#1021), which runs in processes that were never - // foregrounded. Asking there would spend the one request we get where no sheet can be - // presented. The status read above is unaffected, so a legacy grant still resumes. - let newTypesPending = writeTypes.contains { store.authorizationStatus(for: $0) == .notDetermined } - if newTypesPending, UIApplication.shared.applicationState == .active { - Task { try? await store.requestAuthorization(toShare: writeTypes, read: readTypes) } - } + // #653: no more silent auto-reprompt for newly-added share types here. Continuous heart + // rate and workout write-back are now their own explicit, user-initiated toggles + // (`requestHeartRateWriteback` / `requestWorkoutWriteback`) — a returning user who already + // granted those under the old bundled flow keeps them (HealthKit's grant is untouched by + // this change; `writeHeartRate` / `writeWorkouts` still gate on the live share status), but + // nothing here asks HealthKit for anything new on their behalf without a tap — including when + // this resume runs from #1024's backgrounded write-back path, where no sheet could present + // anyway. That removes the one thing #1024 and #653 disagreed about; the status read above, + // which #1024 actually needs, is unchanged by removing it. } } // MARK: - Live delivery (continuous ingestion) /// The scored read types we want a live observer + hourly background delivery on. This is the - /// subset of `quantityReadIds` (plus sleep) that actually feeds Charge/Rest/Effort/Fitness Age, so + /// subset of `coreReadIds` (plus sleep) that actually feeds Charge/Rest/Effort/Fitness Age, so /// a watch-only user's numbers refresh on their own rather than only when the app is foregrounded. /// We deliberately do NOT observe the body-composition reads (weight/BMI/etc.) — those don't move a /// score and a manual weigh-in shouldn't wake the app every hour. @@ -506,20 +673,25 @@ final class HealthKitBridge: ObservableObject { var a = agg(day); a.vo2max = v; byDay[day] = a } - // Body composition — READ-ONLY import under the apple-health source (#20). Weight, lean mass - // and BMI are point-in-time readings, so take the latest-of-day; body-fat reads fine as a - // daily average. Body-fat HealthKit gives a 0…1 fraction, scaled to percent like spo2 above. - await collect(.bodyMass, unit: .gramUnit(with: .kilo), start: start, end: end, op: .discreteMostRecent) { day, v in - var a = agg(day); a.weightKg = v; byDay[day] = a - } - await collect(.bodyFatPercentage, unit: .percent(), start: start, end: end, op: .discreteAverage) { day, v in - var a = agg(day); a.bodyFatPct = v * 100; byDay[day] = a // 0…1 → percent - } - await collect(.leanBodyMass, unit: .gramUnit(with: .kilo), start: start, end: end, op: .discreteMostRecent) { day, v in - var a = agg(day); a.leanMassKg = v; byDay[day] = a - } - await collect(.bodyMassIndex, unit: .count(), start: start, end: end, op: .discreteMostRecent) { day, v in - var a = agg(day); a.bmi = v; byDay[day] = a + // Body composition — READ-ONLY import under the apple-health source (#20), and its own opt-in + // stage (#653): gated on `bodyCompositionEnabled` so a user who never turned this stage on gets + // no reads here even if some stray grant existed — the toggle, not HealthKit's opaque read + // status, is the source of truth for whether this runs. Weight, lean mass and BMI are + // point-in-time readings, so take the latest-of-day; body-fat reads fine as a daily average. + // Body-fat HealthKit gives a 0…1 fraction, scaled to percent like spo2 above. + if bodyCompositionEnabled { + await collect(.bodyMass, unit: .gramUnit(with: .kilo), start: start, end: end, op: .discreteMostRecent) { day, v in + var a = agg(day); a.weightKg = v; byDay[day] = a + } + await collect(.bodyFatPercentage, unit: .percent(), start: start, end: end, op: .discreteAverage) { day, v in + var a = agg(day); a.bodyFatPct = v * 100; byDay[day] = a // 0…1 → percent + } + await collect(.leanBodyMass, unit: .gramUnit(with: .kilo), start: start, end: end, op: .discreteMostRecent) { day, v in + var a = agg(day); a.leanMassKg = v; byDay[day] = a + } + await collect(.bodyMassIndex, unit: .count(), start: start, end: end, op: .discreteMostRecent) { day, v in + var a = agg(day); a.bmi = v; byDay[day] = a + } } // Water logged in other apps (#949). A cumulative day SUM, like steps — HealthKit re-adds every @@ -826,7 +998,7 @@ final class HealthKitBridge: ObservableObject { let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byDate]) var succeededThisRun: Set = [] // Vitals: each quantity type that carried an id-keyed external UUID. - for id in Self.quantityWriteIds { + for id in Self.nightlyVitalsWriteIds { let typeId = id.rawValue guard !swept.contains(typeId), let type = HKQuantityType.quantityType(forIdentifier: id),