Fix kiosk mode brightness persisting after app backgrounds (#4506)#4532
Fix kiosk mode brightness persisting after app backgrounds (#4506)#4532nstefanelli wants to merge 4 commits intohome-assistant:mainfrom
Conversation
No behavior change. Pulls the switch-on-ScreensaverMode brightness logic out of showScreensaver() into a new private applyBrightnessForActiveScreensaver() so subsequent commits can reuse it from the app-lifecycle foreground-reapply path without duplicating the logic.
…sistant#4506) When kiosk mode's screensaver dims UIScreen.main.brightness (or when managed brightness is active) and the user taps a push notification to open another app, the dim previously persisted system-wide because appDidEnterBackground() never restored the user's original brightness. Fix the background path to restore originalBrightness (captured in enableKioskMode) so the rest of iOS isn't stuck at the kiosk dim, and extend appDidBecomeActive() to re-apply kiosk brightness state on return: screensaver dim if a screensaver is still showing, managed brightness level if brightness control is enabled, otherwise no-op. Wired to UIApplication.didEnterBackgroundNotification (not willResignActiveNotification) so a notification banner or Control Center pulldown alone does NOT restore brightness — only actually leaving the app does. This matches user expectation that the kiosk screensaver keeps the display dim unless the device is truly being used elsewhere. Tests (Swift Testing, Tests/App/Kiosk/KioskLifecycleBrightness.test.swift): - backgroundRestoresOriginalBrightnessWhenScreensaverActive - foregroundReappliesScreensaverDim - foregroundReappliesManagedBrightnessWithoutScreensaver - lifecycleDoesNothingWhenKioskInactive
There was a problem hiding this comment.
Pull request overview
Fixes an iOS kiosk-mode lifecycle issue where kiosk-managed screen brightness could persist system-wide after Home Assistant backgrounds, by restoring the user’s original brightness on background and re-applying the kiosk brightness state on foreground.
Changes:
- Restore
originalBrightnesswhen the app enters background while kiosk mode is active. - Re-apply screensaver dim or managed brightness when the app becomes active again.
- Add Swift Testing coverage for kiosk brightness lifecycle behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
Sources/App/Kiosk/KioskModeManager.swift |
Adds background/foreground brightness handling and factors screensaver brightness application into a shared helper. |
Tests/App/Kiosk/KioskLifecycleBrightness.test.swift |
Introduces lifecycle-focused unit tests for brightness restoration and re-application. |
HomeAssistant.xcodeproj/project.pbxproj |
Registers the new test file in the test target build sources. |
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
|
|
||
| case .clock: | ||
| screenState = .screensaver | ||
| if settings.screensaverDimLevel < currentBrightness { | ||
| Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel)) | ||
| } | ||
| } |
There was a problem hiding this comment.
In .clock screensaver mode, applyBrightnessForActiveScreensaver() can become a no-op when screensaverDimLevel >= currentBrightness. After appDidEnterBackground() restores originalBrightness (often higher than the kiosk/managed level), returning to foreground with an active clock screensaver can leave the screen at the restored (too-bright) level instead of reapplying the intended kiosk brightness. Consider comparing against Current.screenBrightness() (actual current value) and/or explicitly clamping to the expected brightness (e.g., min(desiredBrightness, screensaverDimLevel) / applying managed brightness when clock mode would otherwise not dim).
| if mgr.isKioskModeActive { | ||
| mgr.disableKioskMode() | ||
| } | ||
|
|
||
| let box = BrightnessBox() | ||
| box.value = initialBrightness | ||
| let savedGet = Current.screenBrightness | ||
| let savedSet = Current.setScreenBrightness | ||
| Current.screenBrightness = { box.value } | ||
| Current.setScreenBrightness = { box.value = $0 } | ||
|
|
There was a problem hiding this comment.
setupTest() calls mgr.disableKioskMode() before installing the mocked Current.screenBrightness / Current.setScreenBrightness closures. If kiosk mode is already active when the test starts (e.g., due to persisted settings or a previously-failed run), disableKioskMode() will invoke the real Current.setScreenBrightness and can change the host device/simulator brightness. To avoid side effects/flakiness, install the Current mocks before any call that might touch brightness (including disabling kiosk mode), or provide a disable path that bypasses brightness changes for tests.
| if mgr.isKioskModeActive { | |
| mgr.disableKioskMode() | |
| } | |
| let box = BrightnessBox() | |
| box.value = initialBrightness | |
| let savedGet = Current.screenBrightness | |
| let savedSet = Current.setScreenBrightness | |
| Current.screenBrightness = { box.value } | |
| Current.setScreenBrightness = { box.value = $0 } | |
| let savedGet = Current.screenBrightness | |
| let savedSet = Current.setScreenBrightness | |
| let box = BrightnessBox() | |
| box.value = initialBrightness | |
| Current.screenBrightness = { box.value } | |
| Current.setScreenBrightness = { box.value = $0 } | |
| if mgr.isKioskModeActive { | |
| mgr.disableKioskMode() | |
| } |
| if activeScreensaverMode != nil { | ||
| applyBrightnessForActiveScreensaver() | ||
| } else if settings.brightnessControlEnabled { | ||
| applyBrightness() | ||
| } |
There was a problem hiding this comment.
The new foreground re-apply path is covered for .dim and for managed brightness with no screensaver, but there’s no test case for an active .clock screensaver (especially when screensaverDimLevel is higher than the managed/desired brightness). Adding a unit test for the .clock lifecycle path would help prevent regressions in appDidBecomeActive() / applyBrightnessForActiveScreensaver().
1. Clock mode foreground reapply was a no-op because the helper compared settings.screensaverDimLevel against the stored currentBrightness property rather than the actual display brightness. On the background → foreground cycle the two diverge (background restores originalBrightness to the display without updating the stored property), so the clock-mode guard would wrongly evaluate false and leave the screen stuck at the restored (too-bright) level. Compare against Current.screenBrightness() instead. 2. Test setup installed mocked Current.* brightness closures after calling disableKioskMode() on the shared manager. A stale kiosk-active state from a prior run could therefore invoke the real Current.setScreenBrightness and change the simulator/device brightness as a side effect. Install mocks first. 3. Add foregroundReappliesClockScreensaverDim — a regression test for (1) exercising the exact background → foreground lifecycle path in .clock mode.
|
Thanks for the review, @copilot-pull-request-reviewer — all three points were legit. Pushed ab93ceb addressing them:
|
|
@nstefanelli We (OHF) require that the communication in the PR is not done through AI, also it is not needed to tag copilot reviewer, just manually resolve the comments from copilot that you have addressed |
Summary
Fixes #4506.
When kiosk mode's screensaver dims
UIScreen.main.brightness(or when the managed-brightness setting is active) and the user taps a push notification to open another app, the dim would persist system-wide — HA only restored brightness when re-entering kiosk mode. Only re-opening HA would bring it back.This PR restores the user's original brightness (captured in
enableKioskMode()) when HA backgrounds, and re-applies the kiosk brightness state when HA returns to foreground.Deliberate choice: wired to
UIApplication.didEnterBackgroundNotification, notwillResignActiveNotification. This means a notification banner dropping down, Control Center pulldown, or incoming-call alert alone does NOT undo the kiosk dim — only actually leaving the app does. Matches what users expect from a kiosk: the display stays dim unless someone truly uses the device elsewhere.Behavior matrix
brightnessControlEnabledbrightnessControlEnabledImplementation
Single file change in
Sources/App/Kiosk/KioskModeManager.swift:appDidEnterBackground()— restoreoriginalBrightnesswhen kiosk activeappDidBecomeActive()— re-apply screensaver dim OR managed brightness on return (depending on current state)applyBrightnessForActiveScreensaver()helper fromshowScreensaver()so foreground-reapply and initial-show share one code path (DRY)No new settings, no UI changes, no API additions, no unrelated file touches.
Tests
New file
Tests/App/Kiosk/KioskLifecycleBrightness.test.swift(Swift Testing, per project convention):backgroundRestoresOriginalBrightnessWhenScreensaverActiveforegroundReappliesScreensaverDimforegroundReappliesManagedBrightnessWithoutScreensaverlifecycleDoesNothingWhenKioskInactiveTests drive
KioskModeManager.shareddirectly (there is no injectable instance today). The setup helper snapshotsCurrent.screenBrightness/setScreenBrightnessclosures AND persisted kiosk settings, then restores both on cleanup — so runs do not pollute the GRDB database or leak state between tests.Follow-up candidate (not this PR)
If maintainers prefer,
KioskModeManagercould grow an injectable initializer to allow testing without touching the shared singleton. I did not do that here to keep the PR focused on the fix, but happy to send a separate PR if you'd like that refactor.Test plan
KioskLifecycleBrightnessTests— 4 cases)showScreensaver()refactor)bundle exec fastlane lintclean (SwiftFormat + SwiftLint + Rubocop)xcodebuildclean build (App target)xcodebuild build-for-testing(test bundle compiles)