From 2664263f239c018ba9ec77470779b20ae78bd0b4 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 19:42:46 +0100 Subject: [PATCH 1/8] Add HealthKit sensors to iOS app --- .../Entitlements/App-ios.entitlements | 2 + HomeAssistant.xcodeproj/project.pbxproj | 3 + Sources/App/Resources/Info.plist | 2 + .../App/Resources/en.lproj/InfoPlist.strings | 3 +- .../Resources/en.lproj/Localizable.strings | 7 + .../Sensors/List/SensorListView.swift | 51 +++- .../Sensors/List/SensorListViewModel.swift | 38 ++- .../API/Webhook/Sensors/HealthKitSensor.swift | 187 +++++++++++++ Sources/Shared/Environment/Environment.swift | 109 ++++++++ .../Shared/Resources/Swiftgen/Strings.swift | 18 ++ Sources/Shared/Settings/SettingsStore.swift | 38 +++ .../SensorListViewModelHealthKitTests.swift | 94 +++++++ .../Shared/Sensors/HealthKitSensor.test.swift | 250 ++++++++++++++++++ 13 files changed, 798 insertions(+), 4 deletions(-) create mode 100644 Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift create mode 100644 Tests/App/Settings/SensorListViewModelHealthKitTests.swift create mode 100644 Tests/Shared/Sensors/HealthKitSensor.test.swift diff --git a/Configuration/Entitlements/App-ios.entitlements b/Configuration/Entitlements/App-ios.entitlements index 153010f740..0c5960707b 100644 --- a/Configuration/Entitlements/App-ios.entitlements +++ b/Configuration/Entitlements/App-ios.entitlements @@ -10,6 +10,8 @@ applinks:*.home-assistant.io applinks:my.home-assistant.io + com.apple.developer.healthkit + com.apple.developer.networking.wifi-info com.apple.developer.nfc.readersession.formats diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 3efba79e33..4d11ecb692 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -3249,6 +3249,9 @@ com.apple.BackgroundModes = { enabled = 1; }; + com.apple.HealthKit = { + enabled = 1; + }; com.apple.HomeKit = { enabled = 0; }; diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index a980827e51..e0ac4ff5e9 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -86,6 +86,8 @@ Authenticate to access kiosk mode settings. NSFocusStatusUsageDescription Report your focus status as a sensor. + NSHealthShareUsageDescription + Read selected Apple Health metrics so they can be shared with Home Assistant as sensors. NSLocalNetworkUsageDescription Locate and communicate with your Home Assistant instance. NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/Sources/App/Resources/en.lproj/InfoPlist.strings b/Sources/App/Resources/en.lproj/InfoPlist.strings index 741d2b2e14..c8da7bf0f5 100644 --- a/Sources/App/Resources/en.lproj/InfoPlist.strings +++ b/Sources/App/Resources/en.lproj/InfoPlist.strings @@ -2,6 +2,7 @@ "NSCameraUsageDescription" = "Take photos and send them to your Home Assistant server."; "NSCrossWebsiteTrackingUsageDescription" = "Optionally enable cross-website tracking if your configuration requires it."; "NSFocusStatusUsageDescription" = "Report your focus status as a sensor."; +"NSHealthShareUsageDescription" = "Read selected Apple Health metrics so they can be shared with Home Assistant as sensors."; "NSLocalNetworkUsageDescription" = "Locate and communicate with your Home Assistant instance."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "\n 🔵 We also need permission to access location 'Always' so the App can perform background operations. \n\n ⚠️ Without that, the App is unable to decide which connection (local or remote) to use in background and will use remote always."; "NSLocationAlwaysUsageDescription" = "We always need access to your location for features like iBeacons, geofences, background location updates and accurate reporting."; @@ -14,4 +15,4 @@ "NSSiriUsageDescription" = "We use Siri to allow created shortcuts to interact with the app."; "NSSpeechRecognitionUsageDescription" = "Used to dictate text to Assist."; "SEND_LOCATION_APP_SHORTCUT_TITLE" = "Send Location"; -"TemporaryFullAccuracyReasonManualUpdate" = "Grant full accuracy to use your current location for your device tracker."; \ No newline at end of file +"TemporaryFullAccuracyReasonManualUpdate" = "Grant full accuracy to use your current location for your device tracker."; diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 4586d72056..612cdce613 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1684,6 +1684,13 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings_sensors.detail.state" = "State"; "settings_sensors.disabled_state_replacement" = "Disabled"; "settings_sensors.focus_permission.title" = "Focus Permission"; +"settings_sensors.health.footer" = "Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact."; +"settings_sensors.health.header" = "Apple Health"; +"settings_sensors.health.status" = "Health Data"; +"settings_sensors.health.status.not_requested" = "Not Requested"; +"settings_sensors.health.status.requested" = "Requested"; +"settings_sensors.health.status.unavailable" = "Unavailable"; +"settings_sensors.health.toggle" = "Apple Health Sensors"; "settings_sensors.last_updated.footer" = "Last Updated %@"; "settings_sensors.last_updated.prefix" = "Last Updated"; "settings_sensors.loading_error.title" = "Failed to load sensors"; diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index 781a2476cb..174bfb3af6 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -34,6 +34,7 @@ struct SensorListView: View { subtitle: L10n.SettingsSensors.body ) periodicUpdaterRow + healthKitSection motionFocusPermissionNeededView sensorsList } @@ -76,13 +77,13 @@ struct SensorListView: View { private var sensorsList: some View { Section { Toggle(isOn: .init(get: { - viewModel.sensors.filter { !Current.sensors.isEnabled(sensor: $0) }.isEmpty + viewModel.visibleSensors.filter { !Current.sensors.isEnabled(sensor: $0) }.isEmpty }, set: { newValue in viewModel.updateAllSensors(isEnabled: newValue) })) { Text(L10n.SettingsSensors.Sensors.enableAll) } - ForEach(viewModel.sensors, id: \.UniqueID) { sensor in + ForEach(viewModel.visibleSensors, id: \.UniqueID) { sensor in NavigationLink(destination: SensorDetailView(sensor: sensor)) { SensorRow(sensor: sensor, isEnabled: Current.sensors.isEnabled(sensor: sensor)) } @@ -99,6 +100,41 @@ struct SensorListView: View { } } + private var healthKitSection: some View { + Section { + Toggle(isOn: .init( + get: { viewModel.healthSensorsEnabled }, + set: { newValue in + viewModel.setHealthSensorsEnabled(newValue).done { [viewModel] in + viewModel.refresh() + }.catch { [viewModel] error in + DispatchQueue.main.async { + viewModel.healthSensorsEnabled = Current.settingsStore.healthSensorsEnabled + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true + } + } + } + )) { + Text(L10n.SettingsSensors.Health.toggle) + } + .disabled(viewModel.healthKitStatus == .unavailable) + + if let healthKitStatus = viewModel.healthKitStatus { + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(healthKitStatus)) + .foregroundColor(.secondary) + } + } + } header: { + Text(L10n.SettingsSensors.Health.header) + } footer: { + Text(L10n.SettingsSensors.Health.footer) + } + } + @ViewBuilder private var motionFocusPermissionNeededView: some View { if viewModel.motionAuthorizationStatus != nil || viewModel.focusAuthorizationStatus != nil { @@ -183,6 +219,17 @@ struct SensorListView: View { return L10n.SettingsDetails.Location.FocusPermission.needsRequest } } + + private func healthStatusDescription(_ status: SensorListViewModel.HealthKitStatus) -> String { + switch status { + case .unavailable: + return L10n.SettingsSensors.Health.Status.unavailable + case .notRequested: + return L10n.SettingsSensors.Health.Status.notRequested + case .requested: + return L10n.SettingsSensors.Health.Status.requested + } + } } extension SensorListView: SettingsScreenSearchable { diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift index 724af45534..f882083928 100644 --- a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift +++ b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift @@ -6,10 +6,18 @@ import PromiseKit import Shared class SensorListViewModel: ObservableObject { + enum HealthKitStatus { + case unavailable + case notRequested + case requested + } + @Published var sensors: [WebhookSensor] = [] @Published var lastUpdateDate: Date? @Published var motionAuthorizationStatus: CMAuthorizationStatus? @Published var focusAuthorizationStatus: FocusStatusWrapper.AuthorizationStatus? + @Published var healthKitStatus: HealthKitStatus? + @Published var healthSensorsEnabled: Bool = Current.settingsStore.healthSensorsEnabled @Published var periodicUpdateInterval: TimeInterval? = Current.settingsStore.periodicUpdateInterval @Published var alertMessage: String? @Published var showAlert: Bool = false @@ -18,6 +26,12 @@ class SensorListViewModel: ObservableObject { private var motionManager: CMMotionActivityManager? private var cancellables = Set() + var visibleSensors: [WebhookSensor] { + sensors.filter { sensor in + healthSensorsEnabled || !HealthKitSensor.isHealthSensor(uniqueID: sensor.UniqueID) + } + } + init() { Current.sensors.register(observer: self) updatePermissions() @@ -28,6 +42,9 @@ class SensorListViewModel: ObservableObject { } func updatePermissions() { + healthSensorsEnabled = Current.settingsStore.healthSensorsEnabled + healthKitStatus = Current.healthKit.isAvailable() ? healthStatusForCurrentSettings() : .unavailable + if Current.motion.isActivityAvailable() { motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus() } else { @@ -60,6 +77,21 @@ class SensorListViewModel: ObservableObject { Current.settingsStore.periodicUpdateInterval = interval } + func setHealthSensorsEnabled(_ enabled: Bool) -> Promise { + if enabled { + return Current.healthKit.requestReadAuthorization().get { [weak self] in + Current.settingsStore.healthSensorsEnabled = true + self?.healthSensorsEnabled = true + self?.healthKitStatus = .requested + } + } else { + Current.settingsStore.healthSensorsEnabled = false + healthSensorsEnabled = false + healthKitStatus = healthStatusForCurrentSettings() + return .value(()) + } + } + // MARK: - Permissions Handling func requestMotionAuthorization(completion: @escaping () -> Void) { @@ -97,10 +129,14 @@ class SensorListViewModel: ObservableObject { } func updateAllSensors(isEnabled: Bool) { - for sensor in sensors { + for sensor in visibleSensors { Current.sensors.setEnabled(isEnabled, for: sensor) } } + + private func healthStatusForCurrentSettings() -> HealthKitStatus { + Current.settingsStore.healthSensorsEnabled ? .requested : .notRequested + } } // MARK: - SensorObserver diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift new file mode 100644 index 0000000000..fde0c498db --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -0,0 +1,187 @@ +import Foundation +import PromiseKit + +public final class HealthKitSensor: SensorProvider { + enum HealthKitSensorError: Error { + case unavailable + } + + public enum Metric: CaseIterable, Codable { + case steps + case restingHeartRate + + public var uniqueID: String { + switch self { + case .steps: return "health_steps" + case .restingHeartRate: return "health_resting_heart_rate" + } + } + + public var name: String { + switch self { + case .steps: return "Health Steps" + case .restingHeartRate: return "Resting Heart Rate" + } + } + + public var icon: String { + switch self { + case .steps: return "mdi:walk" + case .restingHeartRate: return "mdi:heart-pulse" + } + } + + public var unit: String { + switch self { + case .steps: return "steps" + case .restingHeartRate: return "bpm" + } + } + } + + private static let cacheLifetime: TimeInterval = 15 * 60 + public let request: SensorProviderRequest + + public init(request: SensorProviderRequest) { + self.request = request + } + + public static func isHealthSensor(uniqueID: String?) -> Bool { + guard let uniqueID else { return false } + return Metric.allCases.contains { $0.uniqueID == uniqueID } + } + + public func sensors() -> Promise<[WebhookSensor]> { + guard Current.settingsStore.healthSensorsEnabled else { + if Current.settingsStore.healthSensorsHaveBeenEnabled { + return .value(Self.unavailableSensors()) + } else { + return .value([]) + } + } + + guard Current.healthKit.isAvailable() else { + return .value(Self.unavailableSensors()) + } + + if shouldUseCache, let cached = Current.settingsStore.healthSensorCache, canUseCache(cached) { + return .value(Self.sensors(from: cached)) + } + + let start = Current.calendar().startOfDay(for: Current.date()) + let end = Current.date() + let restingHeartRateStart = Current.calendar().date(byAdding: .day, value: -7, to: end) ?? start + + return firstly { () -> Guarantee<[Result]> in + when(resolved: Metric.allCases.map { metric in + value(for: metric, start: start, end: end, restingHeartRateStart: restingHeartRateStart) + }) + }.map { results -> HealthSensorCache in + let values = results.compactMap { result -> HealthSensorValue? in + if case let .fulfilled(value) = result { + return value + } else { + return nil + } + } + + return HealthSensorCache(fetchedAt: Current.date(), values: values) + }.get { cache in + Current.settingsStore.healthSensorCache = cache + }.map(Self.sensors(from:)) + } + + private var shouldUseCache: Bool { + switch request.reason { + case .registration: + break + case let .trigger(reason): + if reason == LocationUpdateTrigger.Manual.rawValue { + return false + } + } + + guard let cache = Current.settingsStore.healthSensorCache else { + return false + } + + return Current.date().timeIntervalSince(cache.fetchedAt) < Self.cacheLifetime + } + + private func canUseCache(_ cache: HealthSensorCache) -> Bool { + Metric.allCases.allSatisfy { metric in + !Current.sensors.isEnabled(uniqueID: metric.uniqueID) || cache.values.contains { $0.metric == metric } + } + } + + private func value( + for metric: Metric, + start: Date, + end: Date, + restingHeartRateStart: Date + ) -> Promise { + guard Current.sensors.isEnabled(uniqueID: metric.uniqueID) else { + return .value(nil) + } + + switch metric { + case .steps: + return Current.healthKit.queryStepCount(start, end).map { + HealthSensorValue(metric: metric, value: $0.map(Double.init)) + } + case .restingHeartRate: + return Current.healthKit.queryLatestRestingHeartRate(restingHeartRateStart, end).map { + HealthSensorValue(metric: metric, value: $0) + } + } + } + + private static func sensors(from cache: HealthSensorCache) -> [WebhookSensor] { + Metric.allCases.map { metric in + let value = cache.values.first(where: { $0.metric == metric })?.value + return sensor(metric: metric, value: value) + } + } + + private static func unavailableSensors() -> [WebhookSensor] { + Metric.allCases.map { sensor(metric: $0, value: nil) } + } + + private static func sensor(metric: Metric, value: Double?) -> WebhookSensor { + let state: Any + switch metric { + case .steps: + state = value.map { Int($0) } ?? "unavailable" + case .restingHeartRate: + state = value ?? "unavailable" + } + + return WebhookSensor( + name: metric.name, + uniqueID: metric.uniqueID, + icon: metric.icon, + state: state, + unit: metric.unit + ) + } +} + +public struct HealthSensorCache: Codable, Equatable { + public let fetchedAt: Date + public let values: [HealthSensorValue] + + public init(fetchedAt: Date, values: [HealthSensorValue]) { + self.fetchedAt = fetchedAt + self.values = values + } +} + +public struct HealthSensorValue: Codable, Equatable { + public let metric: HealthKitSensor.Metric + public let value: Double? + + public init(metric: HealthKitSensor.Metric, value: Double?) { + self.metric = metric + self.value = value + } +} diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 1e7d3d1c0a..b855de8b0b 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -5,6 +5,9 @@ import Dependencies import Foundation import GRDB import HAKit +#if canImport(HealthKit) +import HealthKit +#endif import os import PromiseKit import UserNotifications @@ -380,6 +383,7 @@ public class AppEnvironment { $0.register(provider: KioskScreensaverSensor.self) $0.register(provider: CameraMotionSensor.self) $0.register(provider: CameraStreamSensor.self) + $0.register(provider: HealthKitSensor.self) } public var localized = LocalizedManager() @@ -618,6 +622,111 @@ public class AppEnvironment { public var pedometer = Pedometer() + public struct HealthKit { + public var isAvailable: () -> Bool = { + #if canImport(HealthKit) && os(iOS) + guard !Current.isCatalyst, !Current.isAppExtension else { return false } + return HKHealthStore.isHealthDataAvailable() + #else + return false + #endif + } + + public var requestReadAuthorization: () -> Promise = { + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) + } + + let healthStore = HKHealthStore() + let types = Set([ + HKObjectType.quantityType(forIdentifier: .stepCount), + HKObjectType.quantityType(forIdentifier: .restingHeartRate), + ].compactMap { $0 }) + + let (promise, seal) = Promise.pending() + healthStore.requestAuthorization(toShare: Set(), read: types) { _, error in + if let error { + seal.reject(error) + } else { + seal.fulfill(()) + } + } + return promise + #else + return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) + #endif + } + + public var queryStepCount: (Date, Date) -> Promise = { start, end in + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .value(nil) + } + + guard let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else { + return .value(nil) + } + + let healthStore = HKHealthStore() + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let (promise, seal) = Promise.pending() + let query = HKStatisticsQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, statistics, error in + if let error { + seal.reject(error) + } else { + let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) + seal.fulfill(steps.map(Int.init)) + } + } + healthStore.execute(query) + return promise + #else + return .value(nil) + #endif + } + + public var queryLatestRestingHeartRate: (Date, Date) -> Promise = { start, end in + #if canImport(HealthKit) && os(iOS) + guard Current.healthKit.isAvailable() else { + return .value(nil) + } + + guard let quantityType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { + return .value(nil) + } + + let healthStore = HKHealthStore() + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let (promise, seal) = Promise.pending() + let query = HKSampleQuery( + sampleType: quantityType, + predicate: predicate, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + seal.reject(error) + } else { + let sample = samples?.first as? HKQuantitySample + seal.fulfill(sample?.quantity.doubleValue(for: .init(from: "count/min"))) + } + } + healthStore.execute(query) + return promise + #else + return .value(nil) + #endif + } + } + + public var healthKit = HealthKit() + /// Wrapper around CMAltimeter for barometric pressure readings public struct Barometer { private let underlyingAltimeter = CMAltimeter() diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 3b5f3562d6..56c59ecc5e 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5584,6 +5584,24 @@ public enum L10n { /// Focus Permission public static var title: String { return L10n.tr("Localizable", "settings_sensors.focus_permission.title") } } + public enum Health { + /// Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact. + public static var footer: String { return L10n.tr("Localizable", "settings_sensors.health.footer") } + /// Apple Health + public static var header: String { return L10n.tr("Localizable", "settings_sensors.health.header") } + /// Health Data + public static var status: String { return L10n.tr("Localizable", "settings_sensors.health.status") } + /// Apple Health Sensors + public static var toggle: String { return L10n.tr("Localizable", "settings_sensors.health.toggle") } + public enum Status { + /// Not Requested + public static var notRequested: String { return L10n.tr("Localizable", "settings_sensors.health.status.not_requested") } + /// Requested + public static var requested: String { return L10n.tr("Localizable", "settings_sensors.health.status.requested") } + /// Unavailable + public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.status.unavailable") } + } + } public enum LastUpdated { /// Last Updated %@ public static func footer(_ p1: Any) -> String { diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index fed0a3b015..f3785c0f4b 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -362,6 +362,44 @@ public class SettingsStore { } } + public var healthSensorsEnabled: Bool { + get { + prefs.bool(forKey: "healthSensorsEnabled") + } + set { + prefs.set(newValue, forKey: "healthSensorsEnabled") + if newValue { + healthSensorsHaveBeenEnabled = true + } + if !newValue { + healthSensorCache = nil + } + } + } + + public var healthSensorsHaveBeenEnabled: Bool { + get { + prefs.bool(forKey: "healthSensorsHaveBeenEnabled") + } + set { + prefs.set(newValue, forKey: "healthSensorsHaveBeenEnabled") + } + } + + public var healthSensorCache: HealthSensorCache? { + get { + guard let data = prefs.data(forKey: "healthSensorCache") else { return nil } + return try? JSONDecoder().decode(HealthSensorCache.self, from: data) + } + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + prefs.set(data, forKey: "healthSensorCache") + } else { + prefs.removeObject(forKey: "healthSensorCache") + } + } + } + public struct Privacy { public var messaging: Bool public var crashes: Bool diff --git a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift new file mode 100644 index 0000000000..bbad10a4ac --- /dev/null +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -0,0 +1,94 @@ +@testable import HomeAssistant +import PromiseKit +@testable import Shared +import Version +import XCTest + +class SensorListViewModelHealthKitTests: XCTestCase { + private var originalHealthKit: AppEnvironment.HealthKit! + private var previousHealthSensorsEnabled: Any? + private var previousHealthSensorsHaveBeenEnabled: Any? + private var previousHealthSensorCache: Any? + + override func setUp() { + super.setUp() + + originalHealthKit = Current.healthKit + previousHealthSensorsEnabled = Current.settingsStore.prefs.object(forKey: "healthSensorsEnabled") + previousHealthSensorsHaveBeenEnabled = Current.settingsStore.prefs + .object(forKey: "healthSensorsHaveBeenEnabled") + previousHealthSensorCache = Current.settingsStore.prefs.object(forKey: "healthSensorCache") + + Current.settingsStore.prefs.removeObject(forKey: "healthSensorsEnabled") + Current.settingsStore.prefs.removeObject(forKey: "healthSensorsHaveBeenEnabled") + Current.settingsStore.prefs.removeObject(forKey: "healthSensorCache") + Current.healthKit.isAvailable = { true } + } + + override func tearDown() { + restore(previousHealthSensorsEnabled, forKey: "healthSensorsEnabled") + restore(previousHealthSensorsHaveBeenEnabled, forKey: "healthSensorsHaveBeenEnabled") + restore(previousHealthSensorCache, forKey: "healthSensorCache") + Current.healthKit = originalHealthKit + originalHealthKit = nil + super.tearDown() + } + + private func restore(_ value: Any?, forKey key: String) { + if let value { + Current.settingsStore.prefs.set(value, forKey: key) + } else { + Current.settingsStore.prefs.removeObject(forKey: key) + } + } + + func testEnablingHealthSensorsRequestsAuthorizationBeforeEnabling() throws { + var requested = false + Current.healthKit.requestReadAuthorization = { + XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) + requested = true + return .value(()) + } + let viewModel = SensorListViewModel() + + try hang(viewModel.setHealthSensorsEnabled(true)) + + XCTAssertTrue(requested) + XCTAssertTrue(Current.settingsStore.healthSensorsEnabled) + XCTAssertTrue(viewModel.healthSensorsEnabled) + } + + func testDisablingHealthSensorsTurnsSettingOffImmediately() throws { + Current.settingsStore.healthSensorsEnabled = true + Current.settingsStore.healthSensorCache = .init( + fetchedAt: Date(), + values: [.init(metric: .steps, value: 123)] + ) + var requested = false + Current.healthKit.requestReadAuthorization = { + requested = true + return .value(()) + } + let viewModel = SensorListViewModel() + + try hang(viewModel.setHealthSensorsEnabled(false)) + + XCTAssertFalse(requested) + XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) + XCTAssertFalse(viewModel.healthSensorsEnabled) + XCTAssertNil(Current.settingsStore.healthSensorCache) + } + + func testUpdateAllSensorsDoesNotEnableHealthSensorsMasterToggle() { + Current.settingsStore.healthSensorsEnabled = false + let viewModel = SensorListViewModel() + viewModel.sensors = [ + WebhookSensor(name: "Health Steps", uniqueID: HealthKitSensor.Metric.steps.uniqueID), + ] + + viewModel.updateAllSensors(isEnabled: true) + + XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) + XCTAssertFalse(viewModel.healthSensorsEnabled) + } +} diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift new file mode 100644 index 0000000000..e8a22f6e94 --- /dev/null +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -0,0 +1,250 @@ +import Foundation +import PromiseKit +@testable import Shared +import Version +import XCTest + +class HealthKitSensorTests: XCTestCase { + private var request: SensorProviderRequest! + private var stepQueryCount: Int! + private var restingHeartRateQueryCount: Int! + private var originalDate: (() -> Date)! + private var originalCalendar: (() -> Calendar)! + private var originalHealthKit: AppEnvironment.HealthKit! + private var previousDisabledSensors: Any? + private var previousHealthSensorsEnabled: Any? + private var previousHealthSensorsHaveBeenEnabled: Any? + private var previousHealthSensorCache: Any? + + override func setUp() { + super.setUp() + + originalDate = Current.date + originalCalendar = Current.calendar + originalHealthKit = Current.healthKit + previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") + previousHealthSensorsEnabled = Current.settingsStore.prefs.object(forKey: "healthSensorsEnabled") + previousHealthSensorsHaveBeenEnabled = Current.settingsStore.prefs + .object(forKey: "healthSensorsHaveBeenEnabled") + previousHealthSensorCache = Current.settingsStore.prefs.object(forKey: "healthSensorCache") + + request = .init( + reason: .trigger("unit-test"), + dependencies: .init(), + location: nil, + serverVersion: Version() + ) + + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + Current.date = { Date(timeIntervalSince1970: 1_000_000) } + Current.calendar = { Calendar(identifier: .gregorian) } + Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") + Current.settingsStore.prefs.removeObject(forKey: "healthSensorsEnabled") + Current.settingsStore.prefs.removeObject(forKey: "healthSensorsHaveBeenEnabled") + Current.settingsStore.prefs.removeObject(forKey: "healthSensorCache") + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + Current.healthKit.isAvailable = { true } + Current.healthKit.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return .value(1234) + } + Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return .value(62.4) + } + } + + override func tearDown() { + restore(previousDisabledSensors, forKey: "disabledSensors") + restore(previousHealthSensorsEnabled, forKey: "healthSensorsEnabled") + restore(previousHealthSensorsHaveBeenEnabled, forKey: "healthSensorsHaveBeenEnabled") + restore(previousHealthSensorCache, forKey: "healthSensorCache") + Current.date = originalDate + Current.calendar = originalCalendar + Current.healthKit = originalHealthKit + originalDate = nil + originalCalendar = nil + originalHealthKit = nil + super.tearDown() + } + + private func restore(_ value: Any?, forKey key: String) { + if let value { + Current.settingsStore.prefs.set(value, forKey: key) + } else { + Current.settingsStore.prefs.removeObject(forKey: key) + } + } + + func testMasterOffReturnsNoSensorsWhenNeverEnabled() throws { + Current.settingsStore.healthSensorsEnabled = false + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertTrue(sensors.isEmpty) + XCTAssertEqual(stepQueryCount, 0) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testMasterOffReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { + Current.settingsStore.healthSensorsHaveBeenEnabled = true + Current.settingsStore.healthSensorsEnabled = false + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual(stepQueryCount, 0) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testUnavailableHealthKitReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { + Current.settingsStore.healthSensorsEnabled = true + Current.healthKit.isAvailable = { false } + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual(stepQueryCount, 0) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testSuccessfulDataMapsBothSensors() throws { + Current.settingsStore.healthSensorsEnabled = true + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + let steps = try XCTUnwrap(sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })) + XCTAssertEqual(steps.Name, "Health Steps") + XCTAssertEqual(steps.Icon, "mdi:walk") + XCTAssertEqual(steps.UnitOfMeasurement, "steps") + XCTAssertEqual(steps.State as? Int, 1234) + + let restingHeartRate = try XCTUnwrap(sensors.first( + where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID } + )) + XCTAssertEqual(restingHeartRate.Name, "Resting Heart Rate") + XCTAssertEqual(restingHeartRate.Icon, "mdi:heart-pulse") + XCTAssertEqual(restingHeartRate.UnitOfMeasurement, "bpm") + XCTAssertEqual(restingHeartRate.State as? Double, 62.4) + } + + func testMissingDataReturnsUnavailableRows() throws { + Current.settingsStore.healthSensorsEnabled = true + Current.healthKit.queryStepCount = { [weak self] _, _ in + self?.stepQueryCount += 1 + return .value(nil) + } + Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + self?.restingHeartRateQueryCount += 1 + return .value(nil) + } + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + } + + func testDisabledIndividualSensorDoesNotQueryThatMetric() throws { + Current.settingsStore.healthSensorsEnabled = true + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertNotNil(sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, + "unavailable" + ) + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testReEnabledIndividualSensorRefreshesMissingCachedMetric() throws { + Current.settingsStore.healthSensorsEnabled = true + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } + Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) + + _ = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 1) + } + + func testAutomaticUpdateWithinCacheWindowUsesCachedValues() throws { + Current.settingsStore.healthSensorsEnabled = true + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } + + let sensors = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? Int, + 1234 + ) + XCTAssertEqual( + sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? Double, + 62.4 + ) + XCTAssertEqual(stepQueryCount, 0) + XCTAssertEqual(restingHeartRateQueryCount, 0) + } + + func testAutomaticUpdateAfterCacheWindowRefreshesHealthKit() throws { + Current.settingsStore.healthSensorsEnabled = true + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 901) } + + _ = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 1) + } + + func testManualUpdateBypassesCache() throws { + Current.settingsStore.healthSensorsEnabled = true + _ = try hang(HealthKitSensor(request: request).sensors()) + stepQueryCount = 0 + restingHeartRateQueryCount = 0 + request.reason = .trigger(LocationUpdateTrigger.Manual.rawValue) + Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } + + _ = try hang(HealthKitSensor(request: request).sensors()) + + XCTAssertEqual(stepQueryCount, 1) + XCTAssertEqual(restingHeartRateQueryCount, 1) + } +} From 142d70555656d1a11e79f931301e679f0ccc337f Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 21:04:03 +0100 Subject: [PATCH 2/8] Address HealthKit sensor review feedback --- Sources/App/Resources/en.lproj/Localizable.strings | 2 ++ .../Shared/API/Webhook/Sensors/HealthKitSensor.swift | 12 +++++++++++- Sources/Shared/Environment/Environment.swift | 6 ++++-- Sources/Shared/Resources/Swiftgen/Strings.swift | 6 ++++++ Sources/Shared/Settings/SettingsStore.swift | 8 +++++++- Tests/Shared/Sensors/HealthKitSensor.test.swift | 7 +++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 612cdce613..5d5f73b0ac 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1684,6 +1684,8 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings_sensors.detail.state" = "State"; "settings_sensors.disabled_state_replacement" = "Disabled"; "settings_sensors.focus_permission.title" = "Focus Permission"; +"settings_sensors.health.error.authorization_failed" = "Apple Health authorization was not granted."; +"settings_sensors.health.error.unavailable" = "Apple Health is not available on this device."; "settings_sensors.health.footer" = "Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact."; "settings_sensors.health.header" = "Apple Health"; "settings_sensors.health.status" = "Health Data"; diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift index fde0c498db..9b1d1d5f24 100644 --- a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -2,8 +2,18 @@ import Foundation import PromiseKit public final class HealthKitSensor: SensorProvider { - enum HealthKitSensorError: Error { + enum HealthKitSensorError: LocalizedError { + case authorizationFailed case unavailable + + var errorDescription: String? { + switch self { + case .authorizationFailed: + return L10n.SettingsSensors.Health.Error.authorizationFailed + case .unavailable: + return L10n.SettingsSensors.Health.Error.unavailable + } + } } public enum Metric: CaseIterable, Codable { diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index b855de8b0b..4fbefdd574 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -645,11 +645,13 @@ public class AppEnvironment { ].compactMap { $0 }) let (promise, seal) = Promise.pending() - healthStore.requestAuthorization(toShare: Set(), read: types) { _, error in + healthStore.requestAuthorization(toShare: Set(), read: types) { success, error in if let error { seal.reject(error) - } else { + } else if success { seal.fulfill(()) + } else { + seal.reject(HealthKitSensor.HealthKitSensorError.authorizationFailed) } } return promise diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 56c59ecc5e..c53adc6821 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5585,6 +5585,12 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "settings_sensors.focus_permission.title") } } public enum Health { + public enum Error { + /// Apple Health authorization was not granted. + public static var authorizationFailed: String { return L10n.tr("Localizable", "settings_sensors.health.error.authorization_failed") } + /// Apple Health is not available on this device. + public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.error.unavailable") } + } /// Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact. public static var footer: String { return L10n.tr("Localizable", "settings_sensors.health.footer") } /// Apple Health diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index f3785c0f4b..689501e378 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -389,7 +389,13 @@ public class SettingsStore { public var healthSensorCache: HealthSensorCache? { get { guard let data = prefs.data(forKey: "healthSensorCache") else { return nil } - return try? JSONDecoder().decode(HealthSensorCache.self, from: data) + do { + return try JSONDecoder().decode(HealthSensorCache.self, from: data) + } catch { + Current.Log.error("Failed to decode health sensor cache, dropping it: \(error)") + prefs.removeObject(forKey: "healthSensorCache") + return nil + } } set { if let newValue, let data = try? JSONEncoder().encode(newValue) { diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift index e8a22f6e94..94701bbb32 100644 --- a/Tests/Shared/Sensors/HealthKitSensor.test.swift +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -167,6 +167,13 @@ class HealthKitSensorTests: XCTestCase { ) } + func testInvalidCachedDataIsCleared() { + Current.settingsStore.prefs.set(Data("not-json".utf8), forKey: "healthSensorCache") + + XCTAssertNil(Current.settingsStore.healthSensorCache) + XCTAssertNil(Current.settingsStore.prefs.data(forKey: "healthSensorCache")) + } + func testDisabledIndividualSensorDoesNotQueryThatMetric() throws { Current.settingsStore.healthSensorsEnabled = true Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) From c33e9f49b2a861834809caa02ab39d8d00d63e81 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 21:16:20 +0100 Subject: [PATCH 3/8] Align HealthKit sensors with sensor settings --- .../Resources/en.lproj/Localizable.strings | 7 +- .../Sensors/List/SensorListView.swift | 34 +++---- .../Sensors/List/SensorListViewModel.swift | 39 ++------ .../API/Webhook/Sensors/HealthKitSensor.swift | 61 ++---------- Sources/Shared/Environment/Environment.swift | 4 + .../Shared/Resources/Swiftgen/Strings.swift | 22 ++--- Sources/Shared/Settings/SettingsStore.swift | 44 --------- .../SensorListViewModelHealthKitTests.swift | 57 ++++------- .../Shared/Sensors/HealthKitSensor.test.swift | 96 +------------------ 9 files changed, 67 insertions(+), 297 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 5d5f73b0ac..26805c9b2d 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1686,13 +1686,12 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings_sensors.focus_permission.title" = "Focus Permission"; "settings_sensors.health.error.authorization_failed" = "Apple Health authorization was not granted."; "settings_sensors.health.error.unavailable" = "Apple Health is not available on this device."; -"settings_sensors.health.footer" = "Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact."; +"settings_sensors.health.footer" = "Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates."; "settings_sensors.health.header" = "Apple Health"; +"settings_sensors.health.request_access" = "Request Apple Health Access"; "settings_sensors.health.status" = "Health Data"; -"settings_sensors.health.status.not_requested" = "Not Requested"; -"settings_sensors.health.status.requested" = "Requested"; +"settings_sensors.health.status.available" = "Available"; "settings_sensors.health.status.unavailable" = "Unavailable"; -"settings_sensors.health.toggle" = "Apple Health Sensors"; "settings_sensors.last_updated.footer" = "Last Updated %@"; "settings_sensors.last_updated.prefix" = "Last Updated"; "settings_sensors.loading_error.title" = "Failed to load sensors"; diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index 174bfb3af6..41c9770e07 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -77,13 +77,13 @@ struct SensorListView: View { private var sensorsList: some View { Section { Toggle(isOn: .init(get: { - viewModel.visibleSensors.filter { !Current.sensors.isEnabled(sensor: $0) }.isEmpty + viewModel.sensors.filter { !Current.sensors.isEnabled(sensor: $0) }.isEmpty }, set: { newValue in viewModel.updateAllSensors(isEnabled: newValue) })) { Text(L10n.SettingsSensors.Sensors.enableAll) } - ForEach(viewModel.visibleSensors, id: \.UniqueID) { sensor in + ForEach(viewModel.sensors, id: \.UniqueID) { sensor in NavigationLink(destination: SensorDetailView(sensor: sensor)) { SensorRow(sensor: sensor, isEnabled: Current.sensors.isEnabled(sensor: sensor)) } @@ -102,21 +102,19 @@ struct SensorListView: View { private var healthKitSection: some View { Section { - Toggle(isOn: .init( - get: { viewModel.healthSensorsEnabled }, - set: { newValue in - viewModel.setHealthSensorsEnabled(newValue).done { [viewModel] in + Button(action: { + viewModel.requestHealthAuthorization().done { [viewModel] in + DispatchQueue.main.async { viewModel.refresh() - }.catch { [viewModel] error in - DispatchQueue.main.async { - viewModel.healthSensorsEnabled = Current.settingsStore.healthSensorsEnabled - viewModel.alertMessage = error.localizedDescription - viewModel.showAlert = true - } + } + }.catch { [viewModel] error in + DispatchQueue.main.async { + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true } } - )) { - Text(L10n.SettingsSensors.Health.toggle) + }) { + Text(L10n.SettingsSensors.Health.requestAccess) } .disabled(viewModel.healthKitStatus == .unavailable) @@ -220,14 +218,12 @@ struct SensorListView: View { } } - private func healthStatusDescription(_ status: SensorListViewModel.HealthKitStatus) -> String { + private func healthStatusDescription(_ status: HealthKitSensor.AuthorizationStatus) -> String { switch status { case .unavailable: return L10n.SettingsSensors.Health.Status.unavailable - case .notRequested: - return L10n.SettingsSensors.Health.Status.notRequested - case .requested: - return L10n.SettingsSensors.Health.Status.requested + case .available: + return L10n.SettingsSensors.Health.Status.available } } } diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift index f882083928..dade7dc9b8 100644 --- a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift +++ b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift @@ -6,18 +6,11 @@ import PromiseKit import Shared class SensorListViewModel: ObservableObject { - enum HealthKitStatus { - case unavailable - case notRequested - case requested - } - @Published var sensors: [WebhookSensor] = [] @Published var lastUpdateDate: Date? @Published var motionAuthorizationStatus: CMAuthorizationStatus? @Published var focusAuthorizationStatus: FocusStatusWrapper.AuthorizationStatus? - @Published var healthKitStatus: HealthKitStatus? - @Published var healthSensorsEnabled: Bool = Current.settingsStore.healthSensorsEnabled + @Published var healthKitStatus: HealthKitSensor.AuthorizationStatus? @Published var periodicUpdateInterval: TimeInterval? = Current.settingsStore.periodicUpdateInterval @Published var alertMessage: String? @Published var showAlert: Bool = false @@ -26,12 +19,6 @@ class SensorListViewModel: ObservableObject { private var motionManager: CMMotionActivityManager? private var cancellables = Set() - var visibleSensors: [WebhookSensor] { - sensors.filter { sensor in - healthSensorsEnabled || !HealthKitSensor.isHealthSensor(uniqueID: sensor.UniqueID) - } - } - init() { Current.sensors.register(observer: self) updatePermissions() @@ -42,8 +29,7 @@ class SensorListViewModel: ObservableObject { } func updatePermissions() { - healthSensorsEnabled = Current.settingsStore.healthSensorsEnabled - healthKitStatus = Current.healthKit.isAvailable() ? healthStatusForCurrentSettings() : .unavailable + healthKitStatus = Current.healthKit.authorizationStatus() if Current.motion.isActivityAvailable() { motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus() @@ -77,18 +63,9 @@ class SensorListViewModel: ObservableObject { Current.settingsStore.periodicUpdateInterval = interval } - func setHealthSensorsEnabled(_ enabled: Bool) -> Promise { - if enabled { - return Current.healthKit.requestReadAuthorization().get { [weak self] in - Current.settingsStore.healthSensorsEnabled = true - self?.healthSensorsEnabled = true - self?.healthKitStatus = .requested - } - } else { - Current.settingsStore.healthSensorsEnabled = false - healthSensorsEnabled = false - healthKitStatus = healthStatusForCurrentSettings() - return .value(()) + func requestHealthAuthorization() -> Promise { + Current.healthKit.requestReadAuthorization().get { [weak self] in + self?.healthKitStatus = Current.healthKit.authorizationStatus() } } @@ -129,14 +106,10 @@ class SensorListViewModel: ObservableObject { } func updateAllSensors(isEnabled: Bool) { - for sensor in visibleSensors { + for sensor in sensors { Current.sensors.setEnabled(isEnabled, for: sensor) } } - - private func healthStatusForCurrentSettings() -> HealthKitStatus { - Current.settingsStore.healthSensorsEnabled ? .requested : .notRequested - } } // MARK: - SensorObserver diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift index 9b1d1d5f24..6d8161c3b7 100644 --- a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -16,6 +16,11 @@ public final class HealthKitSensor: SensorProvider { } } + public enum AuthorizationStatus: Equatable { + case unavailable + case available + } + public enum Metric: CaseIterable, Codable { case steps case restingHeartRate @@ -49,7 +54,6 @@ public final class HealthKitSensor: SensorProvider { } } - private static let cacheLifetime: TimeInterval = 15 * 60 public let request: SensorProviderRequest public init(request: SensorProviderRequest) { @@ -62,22 +66,10 @@ public final class HealthKitSensor: SensorProvider { } public func sensors() -> Promise<[WebhookSensor]> { - guard Current.settingsStore.healthSensorsEnabled else { - if Current.settingsStore.healthSensorsHaveBeenEnabled { - return .value(Self.unavailableSensors()) - } else { - return .value([]) - } - } - guard Current.healthKit.isAvailable() else { return .value(Self.unavailableSensors()) } - if shouldUseCache, let cached = Current.settingsStore.healthSensorCache, canUseCache(cached) { - return .value(Self.sensors(from: cached)) - } - let start = Current.calendar().startOfDay(for: Current.date()) let end = Current.date() let restingHeartRateStart = Current.calendar().date(byAdding: .day, value: -7, to: end) ?? start @@ -86,7 +78,7 @@ public final class HealthKitSensor: SensorProvider { when(resolved: Metric.allCases.map { metric in value(for: metric, start: start, end: end, restingHeartRateStart: restingHeartRateStart) }) - }.map { results -> HealthSensorCache in + }.map { results -> [HealthSensorValue] in let values = results.compactMap { result -> HealthSensorValue? in if case let .fulfilled(value) = result { return value @@ -95,35 +87,10 @@ public final class HealthKitSensor: SensorProvider { } } - return HealthSensorCache(fetchedAt: Current.date(), values: values) - }.get { cache in - Current.settingsStore.healthSensorCache = cache + return values }.map(Self.sensors(from:)) } - private var shouldUseCache: Bool { - switch request.reason { - case .registration: - break - case let .trigger(reason): - if reason == LocationUpdateTrigger.Manual.rawValue { - return false - } - } - - guard let cache = Current.settingsStore.healthSensorCache else { - return false - } - - return Current.date().timeIntervalSince(cache.fetchedAt) < Self.cacheLifetime - } - - private func canUseCache(_ cache: HealthSensorCache) -> Bool { - Metric.allCases.allSatisfy { metric in - !Current.sensors.isEnabled(uniqueID: metric.uniqueID) || cache.values.contains { $0.metric == metric } - } - } - private func value( for metric: Metric, start: Date, @@ -146,9 +113,9 @@ public final class HealthKitSensor: SensorProvider { } } - private static func sensors(from cache: HealthSensorCache) -> [WebhookSensor] { + private static func sensors(from values: [HealthSensorValue]) -> [WebhookSensor] { Metric.allCases.map { metric in - let value = cache.values.first(where: { $0.metric == metric })?.value + let value = values.first(where: { $0.metric == metric })?.value return sensor(metric: metric, value: value) } } @@ -176,16 +143,6 @@ public final class HealthKitSensor: SensorProvider { } } -public struct HealthSensorCache: Codable, Equatable { - public let fetchedAt: Date - public let values: [HealthSensorValue] - - public init(fetchedAt: Date, values: [HealthSensorValue]) { - self.fetchedAt = fetchedAt - self.values = values - } -} - public struct HealthSensorValue: Codable, Equatable { public let metric: HealthKitSensor.Metric public let value: Double? diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 4fbefdd574..60af1b7e4f 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -632,6 +632,10 @@ public class AppEnvironment { #endif } + public var authorizationStatus: () -> HealthKitSensor.AuthorizationStatus = { + Current.healthKit.isAvailable() ? .available : .unavailable + } + public var requestReadAuthorization: () -> Promise = { #if canImport(HealthKit) && os(iOS) guard Current.healthKit.isAvailable() else { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index c53adc6821..2cea89a264 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5585,25 +5585,23 @@ public enum L10n { public static var title: String { return L10n.tr("Localizable", "settings_sensors.focus_permission.title") } } public enum Health { + /// Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates. + public static var footer: String { return L10n.tr("Localizable", "settings_sensors.health.footer") } + /// Apple Health + public static var header: String { return L10n.tr("Localizable", "settings_sensors.health.header") } + /// Request Apple Health Access + public static var requestAccess: String { return L10n.tr("Localizable", "settings_sensors.health.request_access") } + /// Health Data + public static var status: String { return L10n.tr("Localizable", "settings_sensors.health.status") } public enum Error { /// Apple Health authorization was not granted. public static var authorizationFailed: String { return L10n.tr("Localizable", "settings_sensors.health.error.authorization_failed") } /// Apple Health is not available on this device. public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.error.unavailable") } } - /// Apple Health sensors are opt-in. When enabled, data is read only during existing sensor updates and is cached to reduce battery impact. - public static var footer: String { return L10n.tr("Localizable", "settings_sensors.health.footer") } - /// Apple Health - public static var header: String { return L10n.tr("Localizable", "settings_sensors.health.header") } - /// Health Data - public static var status: String { return L10n.tr("Localizable", "settings_sensors.health.status") } - /// Apple Health Sensors - public static var toggle: String { return L10n.tr("Localizable", "settings_sensors.health.toggle") } public enum Status { - /// Not Requested - public static var notRequested: String { return L10n.tr("Localizable", "settings_sensors.health.status.not_requested") } - /// Requested - public static var requested: String { return L10n.tr("Localizable", "settings_sensors.health.status.requested") } + /// Available + public static var available: String { return L10n.tr("Localizable", "settings_sensors.health.status.available") } /// Unavailable public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.status.unavailable") } } diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index 689501e378..fed0a3b015 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -362,50 +362,6 @@ public class SettingsStore { } } - public var healthSensorsEnabled: Bool { - get { - prefs.bool(forKey: "healthSensorsEnabled") - } - set { - prefs.set(newValue, forKey: "healthSensorsEnabled") - if newValue { - healthSensorsHaveBeenEnabled = true - } - if !newValue { - healthSensorCache = nil - } - } - } - - public var healthSensorsHaveBeenEnabled: Bool { - get { - prefs.bool(forKey: "healthSensorsHaveBeenEnabled") - } - set { - prefs.set(newValue, forKey: "healthSensorsHaveBeenEnabled") - } - } - - public var healthSensorCache: HealthSensorCache? { - get { - guard let data = prefs.data(forKey: "healthSensorCache") else { return nil } - do { - return try JSONDecoder().decode(HealthSensorCache.self, from: data) - } catch { - Current.Log.error("Failed to decode health sensor cache, dropping it: \(error)") - prefs.removeObject(forKey: "healthSensorCache") - return nil - } - } - set { - if let newValue, let data = try? JSONEncoder().encode(newValue) { - prefs.set(data, forKey: "healthSensorCache") - } else { - prefs.removeObject(forKey: "healthSensorCache") - } - } - } - public struct Privacy { public var messaging: Bool public var crashes: Bool diff --git a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift index bbad10a4ac..eebee80ee9 100644 --- a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -1,34 +1,25 @@ @testable import HomeAssistant import PromiseKit @testable import Shared -import Version import XCTest class SensorListViewModelHealthKitTests: XCTestCase { private var originalHealthKit: AppEnvironment.HealthKit! - private var previousHealthSensorsEnabled: Any? - private var previousHealthSensorsHaveBeenEnabled: Any? - private var previousHealthSensorCache: Any? + private var previousDisabledSensors: Any? override func setUp() { super.setUp() originalHealthKit = Current.healthKit - previousHealthSensorsEnabled = Current.settingsStore.prefs.object(forKey: "healthSensorsEnabled") - previousHealthSensorsHaveBeenEnabled = Current.settingsStore.prefs - .object(forKey: "healthSensorsHaveBeenEnabled") - previousHealthSensorCache = Current.settingsStore.prefs.object(forKey: "healthSensorCache") + previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorsEnabled") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorsHaveBeenEnabled") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorCache") + Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") Current.healthKit.isAvailable = { true } + Current.healthKit.authorizationStatus = { .available } } override func tearDown() { - restore(previousHealthSensorsEnabled, forKey: "healthSensorsEnabled") - restore(previousHealthSensorsHaveBeenEnabled, forKey: "healthSensorsHaveBeenEnabled") - restore(previousHealthSensorCache, forKey: "healthSensorCache") + restore(previousDisabledSensors, forKey: "disabledSensors") Current.healthKit = originalHealthKit originalHealthKit = nil super.tearDown() @@ -42,45 +33,34 @@ class SensorListViewModelHealthKitTests: XCTestCase { } } - func testEnablingHealthSensorsRequestsAuthorizationBeforeEnabling() throws { + func testRequestHealthAuthorizationRefreshesHealthKitStatus() throws { var requested = false + var status = HealthKitSensor.AuthorizationStatus.unavailable + Current.healthKit.authorizationStatus = { status } Current.healthKit.requestReadAuthorization = { - XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) requested = true + status = .available return .value(()) } let viewModel = SensorListViewModel() - try hang(viewModel.setHealthSensorsEnabled(true)) + try hang(viewModel.requestHealthAuthorization()) XCTAssertTrue(requested) - XCTAssertTrue(Current.settingsStore.healthSensorsEnabled) - XCTAssertTrue(viewModel.healthSensorsEnabled) + XCTAssertEqual(viewModel.healthKitStatus, .available) } - func testDisablingHealthSensorsTurnsSettingOffImmediately() throws { - Current.settingsStore.healthSensorsEnabled = true - Current.settingsStore.healthSensorCache = .init( - fetchedAt: Date(), - values: [.init(metric: .steps, value: 123)] - ) - var requested = false - Current.healthKit.requestReadAuthorization = { - requested = true - return .value(()) - } + func testUpdatePermissionsUsesHealthKitStatus() { + Current.healthKit.authorizationStatus = { .unavailable } let viewModel = SensorListViewModel() - try hang(viewModel.setHealthSensorsEnabled(false)) + viewModel.updatePermissions() - XCTAssertFalse(requested) - XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) - XCTAssertFalse(viewModel.healthSensorsEnabled) - XCTAssertNil(Current.settingsStore.healthSensorCache) + XCTAssertEqual(viewModel.healthKitStatus, .unavailable) } - func testUpdateAllSensorsDoesNotEnableHealthSensorsMasterToggle() { - Current.settingsStore.healthSensorsEnabled = false + func testUpdateAllSensorsIncludesHealthSensors() { + Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) let viewModel = SensorListViewModel() viewModel.sensors = [ WebhookSensor(name: "Health Steps", uniqueID: HealthKitSensor.Metric.steps.uniqueID), @@ -88,7 +68,6 @@ class SensorListViewModelHealthKitTests: XCTestCase { viewModel.updateAllSensors(isEnabled: true) - XCTAssertFalse(Current.settingsStore.healthSensorsEnabled) - XCTAssertFalse(viewModel.healthSensorsEnabled) + XCTAssertTrue(Current.sensors.isEnabled(uniqueID: HealthKitSensor.Metric.steps.uniqueID)) } } diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift index 94701bbb32..501e54a636 100644 --- a/Tests/Shared/Sensors/HealthKitSensor.test.swift +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -1,7 +1,6 @@ import Foundation import PromiseKit @testable import Shared -import Version import XCTest class HealthKitSensorTests: XCTestCase { @@ -12,9 +11,6 @@ class HealthKitSensorTests: XCTestCase { private var originalCalendar: (() -> Calendar)! private var originalHealthKit: AppEnvironment.HealthKit! private var previousDisabledSensors: Any? - private var previousHealthSensorsEnabled: Any? - private var previousHealthSensorsHaveBeenEnabled: Any? - private var previousHealthSensorCache: Any? override func setUp() { super.setUp() @@ -23,10 +19,6 @@ class HealthKitSensorTests: XCTestCase { originalCalendar = Current.calendar originalHealthKit = Current.healthKit previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") - previousHealthSensorsEnabled = Current.settingsStore.prefs.object(forKey: "healthSensorsEnabled") - previousHealthSensorsHaveBeenEnabled = Current.settingsStore.prefs - .object(forKey: "healthSensorsHaveBeenEnabled") - previousHealthSensorCache = Current.settingsStore.prefs.object(forKey: "healthSensorCache") request = .init( reason: .trigger("unit-test"), @@ -40,9 +32,6 @@ class HealthKitSensorTests: XCTestCase { Current.date = { Date(timeIntervalSince1970: 1_000_000) } Current.calendar = { Calendar(identifier: .gregorian) } Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorsEnabled") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorsHaveBeenEnabled") - Current.settingsStore.prefs.removeObject(forKey: "healthSensorCache") Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) Current.healthKit.isAvailable = { true } @@ -58,9 +47,6 @@ class HealthKitSensorTests: XCTestCase { override func tearDown() { restore(previousDisabledSensors, forKey: "disabledSensors") - restore(previousHealthSensorsEnabled, forKey: "healthSensorsEnabled") - restore(previousHealthSensorsHaveBeenEnabled, forKey: "healthSensorsHaveBeenEnabled") - restore(previousHealthSensorCache, forKey: "healthSensorCache") Current.date = originalDate Current.calendar = originalCalendar Current.healthKit = originalHealthKit @@ -78,36 +64,7 @@ class HealthKitSensorTests: XCTestCase { } } - func testMasterOffReturnsNoSensorsWhenNeverEnabled() throws { - Current.settingsStore.healthSensorsEnabled = false - - let sensors = try hang(HealthKitSensor(request: request).sensors()) - - XCTAssertTrue(sensors.isEmpty) - XCTAssertEqual(stepQueryCount, 0) - XCTAssertEqual(restingHeartRateQueryCount, 0) - } - - func testMasterOffReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { - Current.settingsStore.healthSensorsHaveBeenEnabled = true - Current.settingsStore.healthSensorsEnabled = false - - let sensors = try hang(HealthKitSensor(request: request).sensors()) - - XCTAssertEqual( - sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? String, - "unavailable" - ) - XCTAssertEqual( - sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? String, - "unavailable" - ) - XCTAssertEqual(stepQueryCount, 0) - XCTAssertEqual(restingHeartRateQueryCount, 0) - } - func testUnavailableHealthKitReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { - Current.settingsStore.healthSensorsEnabled = true Current.healthKit.isAvailable = { false } let sensors = try hang(HealthKitSensor(request: request).sensors()) @@ -125,8 +82,6 @@ class HealthKitSensorTests: XCTestCase { } func testSuccessfulDataMapsBothSensors() throws { - Current.settingsStore.healthSensorsEnabled = true - let sensors = try hang(HealthKitSensor(request: request).sensors()) let steps = try XCTUnwrap(sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })) @@ -145,7 +100,6 @@ class HealthKitSensorTests: XCTestCase { } func testMissingDataReturnsUnavailableRows() throws { - Current.settingsStore.healthSensorsEnabled = true Current.healthKit.queryStepCount = { [weak self] _, _ in self?.stepQueryCount += 1 return .value(nil) @@ -167,15 +121,7 @@ class HealthKitSensorTests: XCTestCase { ) } - func testInvalidCachedDataIsCleared() { - Current.settingsStore.prefs.set(Data("not-json".utf8), forKey: "healthSensorCache") - - XCTAssertNil(Current.settingsStore.healthSensorCache) - XCTAssertNil(Current.settingsStore.prefs.data(forKey: "healthSensorCache")) - } - func testDisabledIndividualSensorDoesNotQueryThatMetric() throws { - Current.settingsStore.healthSensorsEnabled = true Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) let sensors = try hang(HealthKitSensor(request: request).sensors()) @@ -189,8 +135,7 @@ class HealthKitSensorTests: XCTestCase { XCTAssertEqual(restingHeartRateQueryCount, 0) } - func testReEnabledIndividualSensorRefreshesMissingCachedMetric() throws { - Current.settingsStore.healthSensorsEnabled = true + func testReEnabledIndividualSensorQueriesThatMetric() throws { Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) _ = try hang(HealthKitSensor(request: request).sensors()) stepQueryCount = 0 @@ -205,50 +150,13 @@ class HealthKitSensorTests: XCTestCase { XCTAssertEqual(restingHeartRateQueryCount, 1) } - func testAutomaticUpdateWithinCacheWindowUsesCachedValues() throws { - Current.settingsStore.healthSensorsEnabled = true + func testAutomaticUpdateQueriesHealthKit() throws { _ = try hang(HealthKitSensor(request: request).sensors()) stepQueryCount = 0 restingHeartRateQueryCount = 0 request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } - let sensors = try hang(HealthKitSensor(request: request).sensors()) - - XCTAssertEqual( - sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.steps.uniqueID })?.State as? Int, - 1234 - ) - XCTAssertEqual( - sensors.first(where: { $0.UniqueID == HealthKitSensor.Metric.restingHeartRate.uniqueID })?.State as? Double, - 62.4 - ) - XCTAssertEqual(stepQueryCount, 0) - XCTAssertEqual(restingHeartRateQueryCount, 0) - } - - func testAutomaticUpdateAfterCacheWindowRefreshesHealthKit() throws { - Current.settingsStore.healthSensorsEnabled = true - _ = try hang(HealthKitSensor(request: request).sensors()) - stepQueryCount = 0 - restingHeartRateQueryCount = 0 - request.reason = .trigger(LocationUpdateTrigger.Periodic.rawValue) - Current.date = { Date(timeIntervalSince1970: 1_000_000 + 901) } - - _ = try hang(HealthKitSensor(request: request).sensors()) - - XCTAssertEqual(stepQueryCount, 1) - XCTAssertEqual(restingHeartRateQueryCount, 1) - } - - func testManualUpdateBypassesCache() throws { - Current.settingsStore.healthSensorsEnabled = true - _ = try hang(HealthKitSensor(request: request).sensors()) - stepQueryCount = 0 - restingHeartRateQueryCount = 0 - request.reason = .trigger(LocationUpdateTrigger.Manual.rawValue) - Current.date = { Date(timeIntervalSince1970: 1_000_000 + 60) } - _ = try hang(HealthKitSensor(request: request).sensors()) XCTAssertEqual(stepQueryCount, 1) From f2c6170b6dcf8a7d806ad9876397ab46f3ba37bd Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Fri, 24 Jul 2026 18:34:51 +0100 Subject: [PATCH 4/8] Address HealthKit review feedback --- .../Resources/en.lproj/Localizable.strings | 3 +- .../Sensors/List/SensorListView.swift | 36 +++--- .../Sensors/List/SensorListViewModel.swift | 12 +- .../API/Webhook/Sensors/HealthKitSensor.swift | 82 +++++------- .../Webhook/Sensors/HealthKitService.swift | 94 ++++++++++++++ .../Webhook/Sensors/HealthSensorValue.swift | 13 ++ Sources/Shared/Environment/Environment.swift | 118 +----------------- .../Shared/Resources/Swiftgen/Strings.swift | 2 - .../SensorListViewModelHealthKitTests.swift | 33 +++-- .../Shared/Sensors/HealthKitSensor.test.swift | 28 ++--- 10 files changed, 193 insertions(+), 228 deletions(-) create mode 100644 Sources/Shared/API/Webhook/Sensors/HealthKitService.swift create mode 100644 Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 26805c9b2d..79526107eb 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1684,7 +1684,6 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho "settings_sensors.detail.state" = "State"; "settings_sensors.disabled_state_replacement" = "Disabled"; "settings_sensors.focus_permission.title" = "Focus Permission"; -"settings_sensors.health.error.authorization_failed" = "Apple Health authorization was not granted."; "settings_sensors.health.error.unavailable" = "Apple Health is not available on this device."; "settings_sensors.health.footer" = "Apple Health sensors use the existing per-sensor controls below. Health data is read only during normal sensor updates."; "settings_sensors.health.header" = "Apple Health"; @@ -2414,4 +2413,4 @@ While no URL is considered safe, this server's data won't sync to the watch and "widgets.todo_list.select_list" = "Edit widget to select list."; "widgets.todo_list.title" = "To-do List"; "yaml_preview.share" = "Share Contents"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index 41c9770e07..a21c591aa1 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -103,12 +103,11 @@ struct SensorListView: View { private var healthKitSection: some View { Section { Button(action: { - viewModel.requestHealthAuthorization().done { [viewModel] in - DispatchQueue.main.async { + Task { @MainActor [viewModel] in + do { + try await viewModel.requestHealthAuthorization() viewModel.refresh() - } - }.catch { [viewModel] error in - DispatchQueue.main.async { + } catch { viewModel.alertMessage = error.localizedDescription viewModel.showAlert = true } @@ -116,15 +115,13 @@ struct SensorListView: View { }) { Text(L10n.SettingsSensors.Health.requestAccess) } - .disabled(viewModel.healthKitStatus == .unavailable) - - if let healthKitStatus = viewModel.healthKitStatus { - HStack { - Text(L10n.SettingsSensors.Health.status) - Spacer() - Text(healthStatusDescription(healthKitStatus)) - .foregroundColor(.secondary) - } + .disabled(!viewModel.isHealthKitAvailable) + + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) + .foregroundColor(.secondary) } } header: { Text(L10n.SettingsSensors.Health.header) @@ -218,13 +215,10 @@ struct SensorListView: View { } } - private func healthStatusDescription(_ status: HealthKitSensor.AuthorizationStatus) -> String { - switch status { - case .unavailable: - return L10n.SettingsSensors.Health.Status.unavailable - case .available: - return L10n.SettingsSensors.Health.Status.available - } + private func healthStatusDescription(isAvailable: Bool) -> String { + isAvailable + ? L10n.SettingsSensors.Health.Status.available + : L10n.SettingsSensors.Health.Status.unavailable } } diff --git a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift index dade7dc9b8..f21426b866 100644 --- a/Sources/App/Settings/Sensors/List/SensorListViewModel.swift +++ b/Sources/App/Settings/Sensors/List/SensorListViewModel.swift @@ -10,7 +10,7 @@ class SensorListViewModel: ObservableObject { @Published var lastUpdateDate: Date? @Published var motionAuthorizationStatus: CMAuthorizationStatus? @Published var focusAuthorizationStatus: FocusStatusWrapper.AuthorizationStatus? - @Published var healthKitStatus: HealthKitSensor.AuthorizationStatus? + @Published var isHealthKitAvailable = false @Published var periodicUpdateInterval: TimeInterval? = Current.settingsStore.periodicUpdateInterval @Published var alertMessage: String? @Published var showAlert: Bool = false @@ -29,7 +29,7 @@ class SensorListViewModel: ObservableObject { } func updatePermissions() { - healthKitStatus = Current.healthKit.authorizationStatus() + isHealthKitAvailable = Current.healthKitService.isAvailable() if Current.motion.isActivityAvailable() { motionAuthorizationStatus = CMMotionActivityManager.authorizationStatus() @@ -63,10 +63,10 @@ class SensorListViewModel: ObservableObject { Current.settingsStore.periodicUpdateInterval = interval } - func requestHealthAuthorization() -> Promise { - Current.healthKit.requestReadAuthorization().get { [weak self] in - self?.healthKitStatus = Current.healthKit.authorizationStatus() - } + @MainActor + func requestHealthAuthorization() async throws { + try await Current.healthKitService.requestReadAuthorization() + isHealthKitAvailable = Current.healthKitService.isAvailable() } // MARK: - Permissions Handling diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift index 6d8161c3b7..78784627bb 100644 --- a/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitSensor.swift @@ -1,26 +1,8 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) import Foundation import PromiseKit public final class HealthKitSensor: SensorProvider { - enum HealthKitSensorError: LocalizedError { - case authorizationFailed - case unavailable - - var errorDescription: String? { - switch self { - case .authorizationFailed: - return L10n.SettingsSensors.Health.Error.authorizationFailed - case .unavailable: - return L10n.SettingsSensors.Health.Error.unavailable - } - } - } - - public enum AuthorizationStatus: Equatable { - case unavailable - case available - } - public enum Metric: CaseIterable, Codable { case steps case restingHeartRate @@ -66,29 +48,34 @@ public final class HealthKitSensor: SensorProvider { } public func sensors() -> Promise<[WebhookSensor]> { - guard Current.healthKit.isAvailable() else { + guard Current.healthKitService.isAvailable() else { return .value(Self.unavailableSensors()) } let start = Current.calendar().startOfDay(for: Current.date()) let end = Current.date() let restingHeartRateStart = Current.calendar().date(byAdding: .day, value: -7, to: end) ?? start + let (promise, seal) = Promise<[WebhookSensor]>.pending() + + Task { + async let steps = value( + for: .steps, + start: start, + end: end, + restingHeartRateStart: restingHeartRateStart + ) + async let restingHeartRate = value( + for: .restingHeartRate, + start: start, + end: end, + restingHeartRateStart: restingHeartRateStart + ) + + let values = await [steps, restingHeartRate].compactMap { $0 } + seal.fulfill(Self.sensors(from: values)) + } - return firstly { () -> Guarantee<[Result]> in - when(resolved: Metric.allCases.map { metric in - value(for: metric, start: start, end: end, restingHeartRateStart: restingHeartRateStart) - }) - }.map { results -> [HealthSensorValue] in - let values = results.compactMap { result -> HealthSensorValue? in - if case let .fulfilled(value) = result { - return value - } else { - return nil - } - } - - return values - }.map(Self.sensors(from:)) + return promise } private func value( @@ -96,20 +83,18 @@ public final class HealthKitSensor: SensorProvider { start: Date, end: Date, restingHeartRateStart: Date - ) -> Promise { + ) async -> HealthSensorValue? { guard Current.sensors.isEnabled(uniqueID: metric.uniqueID) else { - return .value(nil) + return nil } switch metric { case .steps: - return Current.healthKit.queryStepCount(start, end).map { - HealthSensorValue(metric: metric, value: $0.map(Double.init)) - } + let value = try? await Current.healthKitService.queryStepCount(start, end) + return HealthSensorValue(metric: metric, value: value.map(Double.init)) case .restingHeartRate: - return Current.healthKit.queryLatestRestingHeartRate(restingHeartRateStart, end).map { - HealthSensorValue(metric: metric, value: $0) - } + let value = try? await Current.healthKitService.queryLatestRestingHeartRate(restingHeartRateStart, end) + return HealthSensorValue(metric: metric, value: value) } } @@ -142,13 +127,4 @@ public final class HealthKitSensor: SensorProvider { ) } } - -public struct HealthSensorValue: Codable, Equatable { - public let metric: HealthKitSensor.Metric - public let value: Double? - - public init(metric: HealthKitSensor.Metric, value: Double?) { - self.metric = metric - self.value = value - } -} +#endif diff --git a/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift b/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift new file mode 100644 index 0000000000..9ef7bafa55 --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthKitService.swift @@ -0,0 +1,94 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) +import Foundation +import HealthKit + +public struct HealthKitService { + public enum HealthKitServiceError: LocalizedError { + case unavailable + + public var errorDescription: String? { + switch self { + case .unavailable: + return L10n.SettingsSensors.Health.Error.unavailable + } + } + } + + private static let healthStore = HKHealthStore() + + public var isAvailable: () -> Bool = { + HKHealthStore.isHealthDataAvailable() && !Current.isAppExtension + } + + public var requestReadAuthorization: () async throws -> Void = { + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension else { + throw HealthKitServiceError.unavailable + } + + try await healthStore.requestAuthorization( + toShare: Set(), + read: healthDataTypes() + ) + } + + public var queryStepCount: (Date, Date) async throws -> Int? = { start, end in + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension, + let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else { + return nil + } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + return try await withCheckedThrowingContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, statistics, error in + if let error { + continuation.resume(throwing: error) + } else { + let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) + continuation.resume(returning: steps.map(Int.init)) + } + } + healthStore.execute(query) + } + } + + public var queryLatestRestingHeartRate: (Date, Date) async throws -> Double? = { start, end in + guard HKHealthStore.isHealthDataAvailable(), !Current.isAppExtension, + let quantityType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { + return nil + } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: quantityType, + predicate: predicate, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + } else { + let sample = samples?.first as? HKQuantitySample + let unit = HKUnit.count().unitDivided(by: .minute()) + continuation.resume(returning: sample?.quantity.doubleValue(for: unit)) + } + } + healthStore.execute(query) + } + } + + public init() {} + + private static func healthDataTypes() -> Set { + Set([ + HKObjectType.quantityType(forIdentifier: .stepCount), + HKObjectType.quantityType(forIdentifier: .restingHeartRate), + ].compactMap { $0 }) + } +} +#endif diff --git a/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift b/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift new file mode 100644 index 0000000000..4ac9eaa0f9 --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/HealthSensorValue.swift @@ -0,0 +1,13 @@ +#if os(iOS) && !targetEnvironment(macCatalyst) +import Foundation + +public struct HealthSensorValue: Codable, Equatable { + public let metric: HealthKitSensor.Metric + public let value: Double? + + public init(metric: HealthKitSensor.Metric, value: Double?) { + self.metric = metric + self.value = value + } +} +#endif diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 60af1b7e4f..20f8416606 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -5,9 +5,6 @@ import Dependencies import Foundation import GRDB import HAKit -#if canImport(HealthKit) -import HealthKit -#endif import os import PromiseKit import UserNotifications @@ -383,7 +380,9 @@ public class AppEnvironment { $0.register(provider: KioskScreensaverSensor.self) $0.register(provider: CameraMotionSensor.self) $0.register(provider: CameraStreamSensor.self) + #if os(iOS) && !targetEnvironment(macCatalyst) $0.register(provider: HealthKitSensor.self) + #endif } public var localized = LocalizedManager() @@ -622,116 +621,9 @@ public class AppEnvironment { public var pedometer = Pedometer() - public struct HealthKit { - public var isAvailable: () -> Bool = { - #if canImport(HealthKit) && os(iOS) - guard !Current.isCatalyst, !Current.isAppExtension else { return false } - return HKHealthStore.isHealthDataAvailable() - #else - return false - #endif - } - - public var authorizationStatus: () -> HealthKitSensor.AuthorizationStatus = { - Current.healthKit.isAvailable() ? .available : .unavailable - } - - public var requestReadAuthorization: () -> Promise = { - #if canImport(HealthKit) && os(iOS) - guard Current.healthKit.isAvailable() else { - return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) - } - - let healthStore = HKHealthStore() - let types = Set([ - HKObjectType.quantityType(forIdentifier: .stepCount), - HKObjectType.quantityType(forIdentifier: .restingHeartRate), - ].compactMap { $0 }) - - let (promise, seal) = Promise.pending() - healthStore.requestAuthorization(toShare: Set(), read: types) { success, error in - if let error { - seal.reject(error) - } else if success { - seal.fulfill(()) - } else { - seal.reject(HealthKitSensor.HealthKitSensorError.authorizationFailed) - } - } - return promise - #else - return .init(error: HealthKitSensor.HealthKitSensorError.unavailable) - #endif - } - - public var queryStepCount: (Date, Date) -> Promise = { start, end in - #if canImport(HealthKit) && os(iOS) - guard Current.healthKit.isAvailable() else { - return .value(nil) - } - - guard let quantityType = HKObjectType.quantityType(forIdentifier: .stepCount) else { - return .value(nil) - } - - let healthStore = HKHealthStore() - let predicate = HKQuery.predicateForSamples(withStart: start, end: end) - let (promise, seal) = Promise.pending() - let query = HKStatisticsQuery( - quantityType: quantityType, - quantitySamplePredicate: predicate, - options: .cumulativeSum - ) { _, statistics, error in - if let error { - seal.reject(error) - } else { - let steps = statistics?.sumQuantity()?.doubleValue(for: .count()) - seal.fulfill(steps.map(Int.init)) - } - } - healthStore.execute(query) - return promise - #else - return .value(nil) - #endif - } - - public var queryLatestRestingHeartRate: (Date, Date) -> Promise = { start, end in - #if canImport(HealthKit) && os(iOS) - guard Current.healthKit.isAvailable() else { - return .value(nil) - } - - guard let quantityType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) else { - return .value(nil) - } - - let healthStore = HKHealthStore() - let predicate = HKQuery.predicateForSamples(withStart: start, end: end) - let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - let (promise, seal) = Promise.pending() - let query = HKSampleQuery( - sampleType: quantityType, - predicate: predicate, - limit: 1, - sortDescriptors: [sort] - ) { _, samples, error in - if let error { - seal.reject(error) - } else { - let sample = samples?.first as? HKQuantitySample - seal.fulfill(sample?.quantity.doubleValue(for: .init(from: "count/min"))) - } - } - healthStore.execute(query) - return promise - #else - return .value(nil) - #endif - } - } - - public var healthKit = HealthKit() + #if os(iOS) && !targetEnvironment(macCatalyst) + public var healthKitService = HealthKitService() + #endif /// Wrapper around CMAltimeter for barometric pressure readings public struct Barometer { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 2cea89a264..933b0c83ca 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -5594,8 +5594,6 @@ public enum L10n { /// Health Data public static var status: String { return L10n.tr("Localizable", "settings_sensors.health.status") } public enum Error { - /// Apple Health authorization was not granted. - public static var authorizationFailed: String { return L10n.tr("Localizable", "settings_sensors.health.error.authorization_failed") } /// Apple Health is not available on this device. public static var unavailable: String { return L10n.tr("Localizable", "settings_sensors.health.error.unavailable") } } diff --git a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift index eebee80ee9..7d8544a716 100644 --- a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -4,24 +4,23 @@ import PromiseKit import XCTest class SensorListViewModelHealthKitTests: XCTestCase { - private var originalHealthKit: AppEnvironment.HealthKit! + private var originalHealthKitService: HealthKitService! private var previousDisabledSensors: Any? override func setUp() { super.setUp() - originalHealthKit = Current.healthKit + originalHealthKitService = Current.healthKitService previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") - Current.healthKit.isAvailable = { true } - Current.healthKit.authorizationStatus = { .available } + Current.healthKitService.isAvailable = { true } } override func tearDown() { restore(previousDisabledSensors, forKey: "disabledSensors") - Current.healthKit = originalHealthKit - originalHealthKit = nil + Current.healthKitService = originalHealthKitService + originalHealthKitService = nil super.tearDown() } @@ -33,30 +32,30 @@ class SensorListViewModelHealthKitTests: XCTestCase { } } - func testRequestHealthAuthorizationRefreshesHealthKitStatus() throws { + @MainActor + func testRequestHealthAuthorizationRefreshesHealthKitAvailability() async throws { var requested = false - var status = HealthKitSensor.AuthorizationStatus.unavailable - Current.healthKit.authorizationStatus = { status } - Current.healthKit.requestReadAuthorization = { + var isAvailable = false + Current.healthKitService.isAvailable = { isAvailable } + Current.healthKitService.requestReadAuthorization = { requested = true - status = .available - return .value(()) + isAvailable = true } let viewModel = SensorListViewModel() - try hang(viewModel.requestHealthAuthorization()) + try await viewModel.requestHealthAuthorization() XCTAssertTrue(requested) - XCTAssertEqual(viewModel.healthKitStatus, .available) + XCTAssertTrue(viewModel.isHealthKitAvailable) } - func testUpdatePermissionsUsesHealthKitStatus() { - Current.healthKit.authorizationStatus = { .unavailable } + func testUpdatePermissionsUsesHealthKitAvailability() { + Current.healthKitService.isAvailable = { false } let viewModel = SensorListViewModel() viewModel.updatePermissions() - XCTAssertEqual(viewModel.healthKitStatus, .unavailable) + XCTAssertFalse(viewModel.isHealthKitAvailable) } func testUpdateAllSensorsIncludesHealthSensors() { diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift index 501e54a636..67d959ef59 100644 --- a/Tests/Shared/Sensors/HealthKitSensor.test.swift +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -9,7 +9,7 @@ class HealthKitSensorTests: XCTestCase { private var restingHeartRateQueryCount: Int! private var originalDate: (() -> Date)! private var originalCalendar: (() -> Calendar)! - private var originalHealthKit: AppEnvironment.HealthKit! + private var originalHealthKitService: HealthKitService! private var previousDisabledSensors: Any? override func setUp() { @@ -17,7 +17,7 @@ class HealthKitSensorTests: XCTestCase { originalDate = Current.date originalCalendar = Current.calendar - originalHealthKit = Current.healthKit + originalHealthKitService = Current.healthKitService previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") request = .init( @@ -34,14 +34,14 @@ class HealthKitSensorTests: XCTestCase { Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) - Current.healthKit.isAvailable = { true } - Current.healthKit.queryStepCount = { [weak self] _, _ in + Current.healthKitService.isAvailable = { true } + Current.healthKitService.queryStepCount = { [weak self] _, _ in self?.stepQueryCount += 1 - return .value(1234) + return 1234 } - Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + Current.healthKitService.queryLatestRestingHeartRate = { [weak self] _, _ in self?.restingHeartRateQueryCount += 1 - return .value(62.4) + return 62.4 } } @@ -49,10 +49,10 @@ class HealthKitSensorTests: XCTestCase { restore(previousDisabledSensors, forKey: "disabledSensors") Current.date = originalDate Current.calendar = originalCalendar - Current.healthKit = originalHealthKit + Current.healthKitService = originalHealthKitService originalDate = nil originalCalendar = nil - originalHealthKit = nil + originalHealthKitService = nil super.tearDown() } @@ -65,7 +65,7 @@ class HealthKitSensorTests: XCTestCase { } func testUnavailableHealthKitReturnsUnavailableSensorsAndDoesNotQueryHealthKit() throws { - Current.healthKit.isAvailable = { false } + Current.healthKitService.isAvailable = { false } let sensors = try hang(HealthKitSensor(request: request).sensors()) @@ -100,13 +100,13 @@ class HealthKitSensorTests: XCTestCase { } func testMissingDataReturnsUnavailableRows() throws { - Current.healthKit.queryStepCount = { [weak self] _, _ in + Current.healthKitService.queryStepCount = { [weak self] _, _ in self?.stepQueryCount += 1 - return .value(nil) + return nil } - Current.healthKit.queryLatestRestingHeartRate = { [weak self] _, _ in + Current.healthKitService.queryLatestRestingHeartRate = { [weak self] _, _ in self?.restingHeartRateQueryCount += 1 - return .value(nil) + return nil } let sensors = try hang(HealthKitSensor(request: request).sensors()) From a0142643c29851bd7ab2cb60d9168e787603f534 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Fri, 24 Jul 2026 19:30:04 +0100 Subject: [PATCH 5/8] Align HealthKit permission with settings UI --- .../Sensors/List/SensorListView.swift | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index a21c591aa1..b1ba3f4589 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -34,8 +34,7 @@ struct SensorListView: View { subtitle: L10n.SettingsSensors.body ) periodicUpdaterRow - healthKitSection - motionFocusPermissionNeededView + permissionsSection sensorsList } .onAppear { @@ -100,44 +99,42 @@ struct SensorListView: View { } } - private var healthKitSection: some View { + private var permissionsSection: some View { Section { - Button(action: { - Task { @MainActor [viewModel] in - do { - try await viewModel.requestHealthAuthorization() - viewModel.refresh() - } catch { - viewModel.alertMessage = error.localizedDescription - viewModel.showAlert = true - } - } - }) { - Text(L10n.SettingsSensors.Health.requestAccess) + healthAuthorizationButton + if viewModel.motionAuthorizationStatus != nil { + motionAuthorizationButton } - .disabled(!viewModel.isHealthKitAvailable) - - HStack { - Text(L10n.SettingsSensors.Health.status) - Spacer() - Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) - .foregroundColor(.secondary) + if viewModel.focusAuthorizationStatus != nil { + focusAuthorizationButton } } header: { - Text(L10n.SettingsSensors.Health.header) + Text(L10n.SettingsSensors.Permissions.header) } footer: { Text(L10n.SettingsSensors.Health.footer) } } - @ViewBuilder - private var motionFocusPermissionNeededView: some View { - if viewModel.motionAuthorizationStatus != nil || viewModel.focusAuthorizationStatus != nil { - Section(L10n.SettingsSensors.Permissions.header) { - motionAuthorizationButton - focusAuthorizationButton + private var healthAuthorizationButton: some View { + Button(action: { + Task { @MainActor [viewModel] in + do { + try await viewModel.requestHealthAuthorization() + viewModel.refresh() + } catch { + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true + } + } + }) { + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) + .foregroundColor(.secondary) } } + .disabled(!viewModel.isHealthKitAvailable) } private var motionAuthorizationButton: some View { @@ -222,11 +219,18 @@ struct SensorListView: View { } } +#Preview { + NavigationView { + SensorListView() + } +} + extension SensorListView: SettingsScreenSearchable { static var settingsSearchEntries: [SettingsSearchEntry] { [ SettingsSearchEntry(L10n.SettingsSensors.PeriodicUpdate.title), SettingsSearchEntry(L10n.SettingsSensors.Permissions.header), + SettingsSearchEntry(L10n.SettingsSensors.Health.status), SettingsSearchEntry(L10n.SettingsDetails.Location.MotionPermission.title), SettingsSearchEntry(L10n.SettingsSensors.FocusPermission.title), SettingsSearchEntry(L10n.SettingsSensors.Sensors.header), From c722bae91a2b917f02750121b429df4d310d2d74 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Fri, 24 Jul 2026 19:42:26 +0100 Subject: [PATCH 6/8] Revert "Align HealthKit permission with settings UI" This reverts commit a0142643c29851bd7ab2cb60d9168e787603f534. --- .../Sensors/List/SensorListView.swift | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/Sources/App/Settings/Sensors/List/SensorListView.swift b/Sources/App/Settings/Sensors/List/SensorListView.swift index b1ba3f4589..a21c591aa1 100644 --- a/Sources/App/Settings/Sensors/List/SensorListView.swift +++ b/Sources/App/Settings/Sensors/List/SensorListView.swift @@ -34,7 +34,8 @@ struct SensorListView: View { subtitle: L10n.SettingsSensors.body ) periodicUpdaterRow - permissionsSection + healthKitSection + motionFocusPermissionNeededView sensorsList } .onAppear { @@ -99,42 +100,44 @@ struct SensorListView: View { } } - private var permissionsSection: some View { + private var healthKitSection: some View { Section { - healthAuthorizationButton - if viewModel.motionAuthorizationStatus != nil { - motionAuthorizationButton + Button(action: { + Task { @MainActor [viewModel] in + do { + try await viewModel.requestHealthAuthorization() + viewModel.refresh() + } catch { + viewModel.alertMessage = error.localizedDescription + viewModel.showAlert = true + } + } + }) { + Text(L10n.SettingsSensors.Health.requestAccess) } - if viewModel.focusAuthorizationStatus != nil { - focusAuthorizationButton + .disabled(!viewModel.isHealthKitAvailable) + + HStack { + Text(L10n.SettingsSensors.Health.status) + Spacer() + Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) + .foregroundColor(.secondary) } } header: { - Text(L10n.SettingsSensors.Permissions.header) + Text(L10n.SettingsSensors.Health.header) } footer: { Text(L10n.SettingsSensors.Health.footer) } } - private var healthAuthorizationButton: some View { - Button(action: { - Task { @MainActor [viewModel] in - do { - try await viewModel.requestHealthAuthorization() - viewModel.refresh() - } catch { - viewModel.alertMessage = error.localizedDescription - viewModel.showAlert = true - } - } - }) { - HStack { - Text(L10n.SettingsSensors.Health.status) - Spacer() - Text(healthStatusDescription(isAvailable: viewModel.isHealthKitAvailable)) - .foregroundColor(.secondary) + @ViewBuilder + private var motionFocusPermissionNeededView: some View { + if viewModel.motionAuthorizationStatus != nil || viewModel.focusAuthorizationStatus != nil { + Section(L10n.SettingsSensors.Permissions.header) { + motionAuthorizationButton + focusAuthorizationButton } } - .disabled(!viewModel.isHealthKitAvailable) } private var motionAuthorizationButton: some View { @@ -219,18 +222,11 @@ struct SensorListView: View { } } -#Preview { - NavigationView { - SensorListView() - } -} - extension SensorListView: SettingsScreenSearchable { static var settingsSearchEntries: [SettingsSearchEntry] { [ SettingsSearchEntry(L10n.SettingsSensors.PeriodicUpdate.title), SettingsSearchEntry(L10n.SettingsSensors.Permissions.header), - SettingsSearchEntry(L10n.SettingsSensors.Health.status), SettingsSearchEntry(L10n.SettingsDetails.Location.MotionPermission.title), SettingsSearchEntry(L10n.SettingsSensors.FocusPermission.title), SettingsSearchEntry(L10n.SettingsSensors.Sensors.header), From bd0c3dff87a3969e37cd54322d3048f7ea5ff64d Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Fri, 24 Jul 2026 22:25:55 +0100 Subject: [PATCH 7/8] Retrigger CI From 0965a80a3bbeefb5c97a185472b095a7c93d1e0e Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Tue, 28 Jul 2026 18:53:18 +0100 Subject: [PATCH 8/8] Isolate HealthKit sensor tests --- .../Settings/SensorListViewModelHealthKitTests.swift | 11 ++++++++++- Tests/Shared/Sensors/HealthKitSensor.test.swift | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift index 7d8544a716..3d34bb2d52 100644 --- a/Tests/App/Settings/SensorListViewModelHealthKitTests.swift +++ b/Tests/App/Settings/SensorListViewModelHealthKitTests.swift @@ -5,14 +5,17 @@ import XCTest class SensorListViewModelHealthKitTests: XCTestCase { private var originalHealthKitService: HealthKitService! + private var originalSensors: SensorContainer! private var previousDisabledSensors: Any? override func setUp() { super.setUp() originalHealthKitService = Current.healthKitService + originalSensors = Current.sensors previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") + Current.sensors = SensorContainer() Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") Current.healthKitService.isAvailable = { true } } @@ -20,7 +23,9 @@ class SensorListViewModelHealthKitTests: XCTestCase { override func tearDown() { restore(previousDisabledSensors, forKey: "disabledSensors") Current.healthKitService = originalHealthKitService + Current.sensors = originalSensors originalHealthKitService = nil + originalSensors = nil super.tearDown() } @@ -60,7 +65,7 @@ class SensorListViewModelHealthKitTests: XCTestCase { func testUpdateAllSensorsIncludesHealthSensors() { Current.sensors.setEnabled(false, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) - let viewModel = SensorListViewModel() + let viewModel = SensorListViewModelWithoutRefresh() viewModel.sensors = [ WebhookSensor(name: "Health Steps", uniqueID: HealthKitSensor.Metric.steps.uniqueID), ] @@ -69,4 +74,8 @@ class SensorListViewModelHealthKitTests: XCTestCase { XCTAssertTrue(Current.sensors.isEnabled(uniqueID: HealthKitSensor.Metric.steps.uniqueID)) } + + private final class SensorListViewModelWithoutRefresh: SensorListViewModel { + override func refresh() {} + } } diff --git a/Tests/Shared/Sensors/HealthKitSensor.test.swift b/Tests/Shared/Sensors/HealthKitSensor.test.swift index 67d959ef59..d75ebbd173 100644 --- a/Tests/Shared/Sensors/HealthKitSensor.test.swift +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -10,6 +10,7 @@ class HealthKitSensorTests: XCTestCase { private var originalDate: (() -> Date)! private var originalCalendar: (() -> Calendar)! private var originalHealthKitService: HealthKitService! + private var originalSensors: SensorContainer! private var previousDisabledSensors: Any? override func setUp() { @@ -18,6 +19,7 @@ class HealthKitSensorTests: XCTestCase { originalDate = Current.date originalCalendar = Current.calendar originalHealthKitService = Current.healthKitService + originalSensors = Current.sensors previousDisabledSensors = Current.settingsStore.prefs.object(forKey: "disabledSensors") request = .init( @@ -31,6 +33,7 @@ class HealthKitSensorTests: XCTestCase { restingHeartRateQueryCount = 0 Current.date = { Date(timeIntervalSince1970: 1_000_000) } Current.calendar = { Calendar(identifier: .gregorian) } + Current.sensors = SensorContainer() Current.settingsStore.prefs.removeObject(forKey: "disabledSensors") Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.steps.uniqueID) Current.sensors.setEnabled(true, forUniqueID: HealthKitSensor.Metric.restingHeartRate.uniqueID) @@ -50,9 +53,11 @@ class HealthKitSensorTests: XCTestCase { Current.date = originalDate Current.calendar = originalCalendar Current.healthKitService = originalHealthKitService + Current.sensors = originalSensors originalDate = nil originalCalendar = nil originalHealthKitService = nil + originalSensors = nil super.tearDown() }