-
Notifications
You must be signed in to change notification settings - Fork 445
Fix kiosk mode brightness persisting after app backgrounds (#4506) #4532
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ce8caaf
7e6be5a
ff2ab7d
ab93ceb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -358,14 +358,35 @@ public final class KioskModeManager: ObservableObject { | |
| webViewController?.refresh() | ||
| } | ||
|
|
||
| /// Called when app returns to foreground | ||
| /// Called when app returns to foreground. | ||
| /// If kiosk is active, re-apply whatever brightness state kiosk expects — | ||
| /// screensaver dim if a screensaver is still showing, or the managed | ||
| /// brightness level if brightness control is enabled. Otherwise leave | ||
| /// brightness as iOS restored it when HA backgrounded (home-assistant/iOS#4506). | ||
| public func appDidBecomeActive() { | ||
| appState = .active | ||
|
|
||
| guard isKioskModeActive else { return } | ||
|
|
||
| if activeScreensaverMode != nil { | ||
| applyBrightnessForActiveScreensaver() | ||
| } else if settings.brightnessControlEnabled { | ||
| applyBrightness() | ||
| } | ||
| } | ||
|
|
||
| /// Called when app enters background | ||
| /// Called when app enters background (user switched apps, device locked, etc). | ||
| /// Intentionally wired to didEnterBackgroundNotification, not willResignActiveNotification — | ||
| /// so a notification banner or Control Center pulldown alone does NOT restore brightness, | ||
| /// only actually leaving the app does. | ||
| /// | ||
| /// If kiosk mode has dimmed UIScreen.main.brightness, restore the user's original | ||
| /// brightness so other apps aren't stuck at the kiosk dim level (home-assistant/iOS#4506). | ||
| public func appDidEnterBackground() { | ||
| appState = .background | ||
|
|
||
| guard isKioskModeActive, let original = originalBrightness else { return } | ||
| Current.setScreenBrightness(CGFloat(original)) | ||
| } | ||
|
|
||
| // MARK: - Screensaver State | ||
|
|
@@ -377,21 +398,13 @@ public final class KioskModeManager: ObservableObject { | |
| preScreensaverBrightness = Current.screenBrightness() | ||
|
|
||
| switch mode { | ||
| case .blank: | ||
| screenState = .off | ||
| Current.setScreenBrightness(0) | ||
|
|
||
| case .dim: | ||
| screenState = .dimmed | ||
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
|
|
||
| case .clock: | ||
| screenState = .screensaver | ||
| if settings.screensaverDimLevel < currentBrightness { | ||
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
| } | ||
| case .blank: screenState = .off | ||
| case .dim: screenState = .dimmed | ||
| case .clock: screenState = .screensaver | ||
| } | ||
|
|
||
| applyBrightnessForActiveScreensaver() | ||
|
|
||
| if settings.pixelShiftEnabled { | ||
| startPixelShiftTimer() | ||
| } | ||
|
|
@@ -400,6 +413,32 @@ public final class KioskModeManager: ObservableObject { | |
| notifyObserversOfScreenStateChange() | ||
| } | ||
|
|
||
| /// Apply the brightness level that matches the currently-active screensaver mode. | ||
| /// Used by showScreensaver() for the initial dim, and by appDidBecomeActive() | ||
| /// to reapply dim when returning to foreground while a screensaver is still active. | ||
| /// | ||
| /// Clock mode only dims when the current display brightness exceeds the configured | ||
| /// dim level — so users who've already turned their screen down (or set a low | ||
| /// managed-brightness level) are not brightened by the screensaver. We compare | ||
| /// against the actual display brightness via Current.screenBrightness() rather than | ||
| /// the stored currentBrightness property, because those diverge on the | ||
| /// background → foreground reapply path (background restores originalBrightness | ||
| /// to the display without updating the stored property). | ||
| private func applyBrightnessForActiveScreensaver() { | ||
| guard let mode = activeScreensaverMode else { return } | ||
|
|
||
| switch mode { | ||
| case .blank: | ||
| Current.setScreenBrightness(0) | ||
| case .dim: | ||
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
| case .clock: | ||
| if CGFloat(settings.screensaverDimLevel) < Current.screenBrightness() { | ||
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
| } | ||
| } | ||
|
Comment on lines
434
to
439
|
||
| } | ||
|
|
||
| private func hideScreensaver(source: String) { | ||
| guard activeScreensaverMode != nil else { return } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import Foundation | ||
| @testable import HomeAssistant | ||
| import Shared | ||
| import Testing | ||
|
|
||
| // MARK: - Kiosk Lifecycle Brightness Tests | ||
|
|
||
| // | ||
| // Covers home-assistant/iOS#4506: when HA backgrounds while kiosk mode has | ||
| // dimmed UIScreen.main.brightness, the dim persists system-wide because we | ||
| // never restore it. These tests verify the lifecycle observers restore the | ||
| // user's original brightness on background and reapply kiosk brightness on | ||
| // foreground. | ||
| // | ||
| // The manager is wired to UIApplication.didEnterBackgroundNotification | ||
| // (intentionally not willResignActiveNotification) so a notification banner | ||
| // or Control Center pull-down alone does NOT restore brightness — only | ||
| // actually leaving the app does. | ||
| // | ||
| // Tests drive KioskModeManager.shared directly (no injectable instance | ||
| // exists yet). setupTest() snapshots both Current brightness closures AND | ||
| // persisted kiosk settings; the returned cleanup closure restores both, | ||
| // so tests do not pollute the shared GRDB database or leak state between | ||
| // runs. | ||
|
|
||
| @MainActor | ||
| struct KioskLifecycleBrightnessTests { | ||
| /// Small reference box so the closure-based Current.screenBrightness / setScreenBrightness | ||
| /// mocks can share mutable state by reference rather than by capture-by-value. | ||
| private final class BrightnessBox { | ||
| var value: CGFloat = 0 | ||
| } | ||
|
|
||
| /// Prepare the shared manager for a test: install mocked brightness closures on | ||
| /// Current BEFORE touching kiosk state (so a stale kiosk-active state from a prior | ||
| /// test or prior run cannot invoke the real Current.setScreenBrightness and change | ||
| /// the simulator/device brightness as a side effect), snapshot the current | ||
| /// persisted settings, then ensure kiosk is disabled. | ||
| /// Returns the brightness box plus a single cleanup closure that restores everything. | ||
| private func setupTest(initialBrightness: CGFloat) -> (BrightnessBox, () -> Void) { | ||
| let mgr = KioskModeManager.shared | ||
|
|
||
| let savedGet = Current.screenBrightness | ||
| let savedSet = Current.setScreenBrightness | ||
| let box = BrightnessBox() | ||
| box.value = initialBrightness | ||
| Current.screenBrightness = { box.value } | ||
| Current.setScreenBrightness = { box.value = $0 } | ||
|
|
||
| let savedSettings = mgr.settings | ||
| if mgr.isKioskModeActive { | ||
| mgr.disableKioskMode() | ||
| } | ||
|
|
||
| let cleanup: () -> Void = { | ||
| if mgr.isKioskModeActive { | ||
| mgr.disableKioskMode() | ||
| } | ||
| mgr.updateSettings(savedSettings) | ||
| Current.screenBrightness = savedGet | ||
| Current.setScreenBrightness = savedSet | ||
| } | ||
| return (box, cleanup) | ||
| } | ||
|
|
||
| @Test func backgroundRestoresOriginalBrightnessWhenScreensaverActive() async throws { | ||
| let (box, cleanup) = setupTest(initialBrightness: 0.8) | ||
| defer { cleanup() } | ||
|
|
||
| let mgr = KioskModeManager.shared | ||
|
|
||
| mgr.enableKioskMode() | ||
| #expect(mgr.isKioskModeActive == true) | ||
|
|
||
| var settings = mgr.settings | ||
| settings.screensaverMode = .dim | ||
| settings.screensaverDimLevel = 0.05 | ||
| mgr.updateSettings(settings) | ||
|
|
||
| mgr.sleepScreen(mode: .dim) | ||
| #expect(box.value == 0.05, "screensaver should have dimmed to settings.screensaverDimLevel") | ||
|
|
||
| // Act: HA backgrounds (user taps a notification and opens another app) | ||
| mgr.appDidEnterBackground() | ||
|
|
||
| #expect( | ||
| box.value == 0.8, | ||
| "expected background to restore originalBrightness (0.8), got \(box.value)" | ||
| ) | ||
| } | ||
|
|
||
| @Test func foregroundReappliesScreensaverDim() async throws { | ||
| let (box, cleanup) = setupTest(initialBrightness: 0.8) | ||
| defer { cleanup() } | ||
|
|
||
| let mgr = KioskModeManager.shared | ||
| mgr.enableKioskMode() | ||
|
|
||
| var settings = mgr.settings | ||
| settings.screensaverMode = .dim | ||
| settings.screensaverDimLevel = 0.05 | ||
| mgr.updateSettings(settings) | ||
|
|
||
| mgr.sleepScreen(mode: .dim) | ||
|
|
||
| mgr.appDidEnterBackground() | ||
| #expect(box.value == 0.8) | ||
|
|
||
| #expect( | ||
| mgr.activeScreensaverMode == .dim, | ||
| "screensaver should remain active across background — backgrounding does not wake" | ||
| ) | ||
|
|
||
| mgr.appDidBecomeActive() | ||
|
|
||
| #expect( | ||
| box.value == 0.05, | ||
| "expected foreground to re-apply screensaver dim (0.05), got \(box.value)" | ||
| ) | ||
| } | ||
|
|
||
| @Test func foregroundReappliesManagedBrightnessWithoutScreensaver() async throws { | ||
| let (box, cleanup) = setupTest(initialBrightness: 0.8) | ||
| defer { cleanup() } | ||
|
|
||
| let mgr = KioskModeManager.shared | ||
| mgr.enableKioskMode() | ||
|
|
||
| var settings = mgr.settings | ||
| settings.brightnessControlEnabled = true | ||
| settings.manualBrightness = 0.3 | ||
| mgr.updateSettings(settings) | ||
|
|
||
| #expect(box.value == 0.3, "managed brightness should have been applied on enable/settings change") | ||
|
|
||
| mgr.appDidEnterBackground() | ||
| #expect(box.value == 0.8, "background should restore originalBrightness") | ||
|
|
||
| #expect(mgr.activeScreensaverMode == nil) | ||
|
|
||
| mgr.appDidBecomeActive() | ||
|
|
||
| #expect( | ||
| box.value == 0.3, | ||
| "expected foreground to re-apply managed brightness (0.3), got \(box.value)" | ||
| ) | ||
| } | ||
|
|
||
| @Test func foregroundReappliesClockScreensaverDim() async throws { | ||
| // Regression: the helper previously compared settings.screensaverDimLevel against | ||
| // the stored currentBrightness property rather than the actual display brightness. | ||
| // On the background → foreground cycle, background restores originalBrightness to | ||
| // the display but does not update currentBrightness, so the clock-mode guard | ||
| // wrongly evaluated false and the display stayed stuck at the restored | ||
| // (too-bright) level. This test exercises exactly that path. | ||
| let (box, cleanup) = setupTest(initialBrightness: 0.8) | ||
| defer { cleanup() } | ||
|
|
||
| let mgr = KioskModeManager.shared | ||
| mgr.enableKioskMode() | ||
|
|
||
| var settings = mgr.settings | ||
| settings.screensaverMode = .clock | ||
| settings.screensaverDimLevel = 0.2 | ||
| mgr.updateSettings(settings) | ||
|
|
||
| mgr.sleepScreen(mode: .clock) | ||
| #expect(box.value == 0.2, "clock screensaver should have dimmed below the pre-screensaver brightness") | ||
|
|
||
| mgr.appDidEnterBackground() | ||
| #expect(box.value == 0.8, "background should restore originalBrightness") | ||
|
|
||
| #expect(mgr.activeScreensaverMode == .clock) | ||
| mgr.appDidBecomeActive() | ||
|
|
||
| #expect( | ||
| box.value == 0.2, | ||
| "expected foreground to re-apply clock screensaver dim (0.2), got \(box.value)" | ||
| ) | ||
| } | ||
|
|
||
| @Test func lifecycleDoesNothingWhenKioskInactive() async throws { | ||
| let (box, cleanup) = setupTest(initialBrightness: 0.65) | ||
| defer { cleanup() } | ||
|
|
||
| let mgr = KioskModeManager.shared | ||
| #expect(mgr.isKioskModeActive == false) | ||
|
|
||
| mgr.appDidEnterBackground() | ||
| #expect(box.value == 0.65, "kiosk inactive: background must not touch brightness") | ||
|
|
||
| mgr.appDidBecomeActive() | ||
| #expect(box.value == 0.65, "kiosk inactive: foreground must not touch brightness") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new foreground re-apply path is covered for
.dimand for managed brightness with no screensaver, but there’s no test case for an active.clockscreensaver (especially whenscreensaverDimLevelis higher than the managed/desired brightness). Adding a unit test for the.clocklifecycle path would help prevent regressions inappDidBecomeActive()/applyBrightnessForActiveScreensaver().