From 1f207b1169d55e59a7d91515cb7f9fdff3c9562a Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 19:42:46 +0100 Subject: [PATCH 1/3] Add HealthKit sensors to iOS app --- .../Entitlements/App-ios.entitlements | 2 + HomeAssistant.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 240 +++++++++++++++++ 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 ++++++++++++++++++ 14 files changed, 1052 insertions(+), 4 deletions(-) create mode 100644 HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 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 8746a94272..b4911a7df5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -944,6 +944,10 @@ 42AAEE882EC4B9880049E1F3 /* URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAEE862EC4B7CC0049E1F3 /* URLOpener.swift */; }; 42AAEE8A2EC4C57F0049E1F3 /* ConnectionURLViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */; }; 42ABB0BB2C888BB10081461D /* CarPlayConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */; }; + 42ABF0012FE3000000A1B2C3 /* HealthKitSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */; }; + 42ABF0032FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */; }; + 42ABF0042FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */; }; + 42ABF0052FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */; }; 42AC20012FA1000000000001 /* PerformActionAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000001 /* PerformActionAppIntent.swift */; }; 42AC20012FA1000000000002 /* AssistPromptAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000002 /* AssistPromptAppIntent.swift */; }; 42AC20012FA1000000000003 /* GetCameraSnapshotAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AC10012FA1000000000003 /* GetCameraSnapshotAppIntent.swift */; }; @@ -2880,6 +2884,9 @@ 42AAEE892EC4C57F0049E1F3 /* ConnectionURLViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionURLViewModel.swift; sourceTree = ""; }; 42ABB0B82C888AA10081461D /* CarPlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfig.swift; sourceTree = ""; }; 42ABB0BA2C888BB10081461D /* CarPlayConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayConfigurationViewModel.swift; sourceTree = ""; }; + 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.test.swift; sourceTree = ""; }; + 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSensor.swift; sourceTree = ""; }; + 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorListViewModelHealthKitTests.swift; sourceTree = ""; }; 42AC10012FA1000000000001 /* PerformActionAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformActionAppIntent.swift; sourceTree = ""; }; 42AC10012FA1000000000002 /* AssistPromptAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistPromptAppIntent.swift; sourceTree = ""; }; 42AC10012FA1000000000003 /* GetCameraSnapshotAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCameraSnapshotAppIntent.swift; sourceTree = ""; }; @@ -4321,6 +4328,7 @@ B613936824F728F8002B8C5D /* InputOutputDeviceSensor.swift */, 11358AEB24FC9F300074C4E2 /* ActiveSensor.swift */, 110ED56225A563D600489AF7 /* DisplaySensor.swift */, + 42ABF0022FE3000000A1B2C3 /* HealthKitSensor.swift */, 118BDA8725A6DBBA00731016 /* FrontmostAppSensor.swift */, 42D3E49B2C5BB88F00444BE6 /* WatchBatterySensor.swift */, 42DEDA992C5B926400E9D29D /* AppVersionSensor.swift */, @@ -4349,6 +4357,7 @@ 11BC9E5624FDC1C900B9FBF7 /* ActiveSensor.test.swift */, 110ED57F25A570F100489AF7 /* DisplaySensor.test.swift */, 115AD72C267C57DA0090B243 /* FocusSensor.test.swift */, + 42ABF0002FE3000000A1B2C3 /* HealthKitSensor.test.swift */, ); path = Sensors; sourceTree = ""; @@ -6066,6 +6075,7 @@ isa = PBXGroup; children = ( 42B980DB2DC256A300BC5C08 /* SensorRow.test.swift */, + 42ABF0062FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift */, 42A42A692DC37311000ABBAF /* PrivacyView.test.swift */, 42A42A6B2DC37F09000ABBAF /* AboutView.test.swift */, 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */, @@ -8176,6 +8186,9 @@ com.apple.BackgroundModes = { enabled = 1; }; + com.apple.HealthKit = { + enabled = 1; + }; com.apple.HomeKit = { enabled = 0; }; @@ -9921,6 +9934,7 @@ 42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */, 42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */, 42A42A6A2DC37311000ABBAF /* PrivacyView.test.swift in Sources */, + 42ABF0052FE3000000A1B2C3 /* SensorListViewModelHealthKitTests.swift in Sources */, 42FCD0082B9B1ECE0057783F /* SimulatorThreadClientService.swift in Sources */, 42A47A872C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift in Sources */, 422E626C2CDCF00A00987BD0 /* AreasService.test.swift in Sources */, @@ -10008,6 +10022,7 @@ B67CE87A22200F220034C1D0 /* LocationTrigger.swift in Sources */, B672333F225DB68B0031D629 /* WebSocketMessage.swift in Sources */, 11AF4D1A249C8253006C74C0 /* PedometerSensor.swift in Sources */, + 42ABF0042FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */, 1117FB4D250C5F7C00895C13 /* DeviceBattery.swift in Sources */, 429C33C32F17A7010033EF5E /* HAUsagePredictionCommonControl.swift in Sources */, 42070EEC2BAC517A0031E96F /* AssistInAppIntentHandler.swift in Sources */, @@ -10417,6 +10432,7 @@ 42FF5E982E22E5C100BDF5EF /* TodoListItem.swift in Sources */, 11C4628824B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11AF4D19249C8253006C74C0 /* PedometerSensor.swift in Sources */, + 42ABF0032FE3000000A1B2C3 /* HealthKitSensor.swift in Sources */, 42991E5B2FF3359D00C2C76B /* LiveActivityPendingStart.swift in Sources */, B6872E632226841400C475D1 /* MobileAppRegistrationRequest.swift in Sources */, 42145A622DAFD49A00891E04 /* HAEntity+CarPlay.swift in Sources */, @@ -10633,6 +10649,7 @@ 11AF4D2E249DA5AF006C74C0 /* GeocoderSensor.test.swift in Sources */, 114FACAE24B2ABA2006C581F /* Promise+WebhookJson.test.swift in Sources */, 115AD72D267C57DA0090B243 /* FocusSensor.test.swift in Sources */, + 42ABF0012FE3000000A1B2C3 /* HealthKitSensor.test.swift in Sources */, 11CB98C6249DE15B00B05222 /* LastUpdateSensor.test.swift in Sources */, D03D894D20E0BC2700D4F28D /* ClientEventTests.swift in Sources */, B7104E0100000000000000C2 /* NotificationHistoryStore.test.swift in Sources */, diff --git a/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..0fb546b06a --- /dev/null +++ b/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,240 @@ +{ + "originHash" : "d5f950bd8bbf94537aad31a8660b19162816de171e80a699a02233e515603f62", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" + } + }, + { + "identity" : "callbackurlkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/phimage/CallbackURLKit.git", + "state" : { + "revision" : "28664b3d4f37bb359d5ed729aa463cbedfba5d82", + "version" : "2.3.2" + } + }, + { + "identity" : "debugswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DebugSwift/DebugSwift.git", + "state" : { + "revision" : "a61ed10addb0d453a0b78798dff45bf02e74f17e", + "version" : "1.16.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", + "version" : "12.15.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", + "version" : "3.6.1" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", + "version" : "12.15.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "state" : { + "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", + "version" : "5.3.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "b2d4cb30735f4fbc3a01963a9c658336dd21e9ba", + "version" : "1.18.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "uicolor-hex-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yeahdongcn/UIColor-Hex-Swift.git", + "state" : { + "revision" : "f37f97bc1a5b545b39ab5330e133e56fa4f38cff", + "version" : "5.1.9" + } + }, + { + "identity" : "webrtc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stasel/WebRTC.git", + "state" : { + "revision" : "86dbb5cb57e4da009b859a6245b1c10d610f215a", + "version" : "140.0.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index c667170e85..07229cdd28 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -96,6 +96,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 3d0329abd9..7cdbc73854 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1430,6 +1430,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 96fa311091..fab07a85f2 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,4 +219,15 @@ 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 + } + } } 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 c5acad10d3..dbd210695e 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -4,6 +4,9 @@ import CoreMotion import Foundation import GRDB import HAKit +#if canImport(HealthKit) +import HealthKit +#endif import NetworkExtension import PromiseKit import RealmSwift @@ -276,6 +279,7 @@ public class AppEnvironment { $0.register(provider: KioskModeSensor.self) $0.register(provider: KioskBrightnessSensor.self) $0.register(provider: KioskVolumeSensor.self) + $0.register(provider: HealthKitSensor.self) } public var localized = LocalizedManager() @@ -507,6 +511,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 34d9062e8b..d23ae6a9cf 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -4808,6 +4808,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 61fe55fc76..e5e5518d5a 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -307,6 +307,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 68bef69b1ce73e14b25a54b04391b9aab9459e2f Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 21:04:03 +0100 Subject: [PATCH 2/3] Address HealthKit sensor review feedback --- .../xcshareddata/swiftpm/Package.resolved | 240 ------------------ .../Resources/en.lproj/Localizable.strings | 2 + .../API/Webhook/Sensors/HealthKitSensor.swift | 12 +- Sources/Shared/Environment/Environment.swift | 6 +- .../Shared/Resources/Swiftgen/Strings.swift | 6 + Sources/Shared/Settings/SettingsStore.swift | 8 +- .../Shared/Sensors/HealthKitSensor.test.swift | 7 + 7 files changed, 37 insertions(+), 244 deletions(-) delete mode 100644 HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 0fb546b06a..0000000000 --- a/HomeAssistant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,240 +0,0 @@ -{ - "originHash" : "d5f950bd8bbf94537aad31a8660b19162816de171e80a699a02233e515603f62", - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", - "state" : { - "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", - "version" : "5.12.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", - "version" : "11.3.0" - } - }, - { - "identity" : "callbackurlkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/phimage/CallbackURLKit.git", - "state" : { - "revision" : "28664b3d4f37bb359d5ed729aa463cbedfba5d82", - "version" : "2.3.2" - } - }, - { - "identity" : "debugswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/DebugSwift/DebugSwift.git", - "state" : { - "revision" : "a61ed10addb0d453a0b78798dff45bf02e74f17e", - "version" : "1.16.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk.git", - "state" : { - "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", - "version" : "12.15.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", - "version" : "3.6.1" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", - "version" : "12.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift.git", - "state" : { - "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", - "version" : "7.8.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "keychainaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", - "state" : { - "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", - "version" : "4.2.2" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", - "version" : "2.30910.1" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", - "version" : "2.4.1" - } - }, - { - "identity" : "sfsafesymbols", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", - "state" : { - "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", - "version" : "5.3.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", - "version" : "1.6.1" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", - "state" : { - "revision" : "b2d4cb30735f4fbc3a01963a9c658336dd21e9ba", - "version" : "1.18.1" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" - } - }, - { - "identity" : "uicolor-hex-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yeahdongcn/UIColor-Hex-Swift.git", - "state" : { - "revision" : "f37f97bc1a5b545b39ab5330e133e56fa4f38cff", - "version" : "5.1.9" - } - }, - { - "identity" : "webrtc", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stasel/WebRTC.git", - "state" : { - "revision" : "86dbb5cb57e4da009b859a6245b1c10d610f215a", - "version" : "140.0.0" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", - "version" : "1.10.1" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation.git", - "state" : { - "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", - "version" : "0.9.20" - } - } - ], - "version" : 3 -} diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 7cdbc73854..3d8310968b 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1430,6 +1430,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 dbd210695e..23139ec251 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -534,11 +534,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 d23ae6a9cf..f5ed199350 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -4809,6 +4809,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 e5e5518d5a..7a5b09eecc 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -334,7 +334,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 270dbcaead527c5ab4bc1620294824a5f8c48cb0 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Thu, 2 Jul 2026 21:16:20 +0100 Subject: [PATCH 3/3] 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 | 12 +-- Sources/Shared/Settings/SettingsStore.swift | 44 --------- .../SensorListViewModelHealthKitTests.swift | 57 ++++------- .../Shared/Sensors/HealthKitSensor.test.swift | 95 +------------------ 9 files changed, 62 insertions(+), 291 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 3d8310968b..b22ba44f0e 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1432,13 +1432,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 fab07a85f2..9fc756e6ff 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 23139ec251..18163cb2bb 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -521,6 +521,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 f5ed199350..2f7b0b18c0 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -4815,19 +4815,17 @@ public enum L10n { /// 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. + /// 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") } - /// 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 7a5b09eecc..61fe55fc76 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -307,50 +307,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..1f5169d402 100644 --- a/Tests/Shared/Sensors/HealthKitSensor.test.swift +++ b/Tests/Shared/Sensors/HealthKitSensor.test.swift @@ -12,9 +12,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 +20,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 +33,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 +48,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 +65,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 +83,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 +101,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 +122,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 +136,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 +151,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)