Skip to content

Fix kiosk mode brightness persisting after app backgrounds (#4506)#4532

Open
nstefanelli wants to merge 4 commits intohome-assistant:mainfrom
nstefanelli:fix/kiosk-brightness-restore-on-background
Open

Fix kiosk mode brightness persisting after app backgrounds (#4506)#4532
nstefanelli wants to merge 4 commits intohome-assistant:mainfrom
nstefanelli:fix/kiosk-brightness-restore-on-background

Conversation

@nstefanelli
Copy link
Copy Markdown
Contributor

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, not willResignActiveNotification. 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

Event Kiosk state Result
Notification banner drops (no app switch) on + screensaver dim Stays dimmed
Tap notification → other app opens on + screensaver dim Restored to pre-kiosk brightness
Tap notification → other app opens on + brightnessControlEnabled Restored to pre-kiosk brightness
Return to HA on + screensaver still active Re-dims
Return to HA on + brightnessControlEnabled Re-applies managed level
Device locks/sleeps on Restored to pre-kiosk brightness

Implementation

Single file change in Sources/App/Kiosk/KioskModeManager.swift:

  • appDidEnterBackground() — restore originalBrightness when kiosk active
  • appDidBecomeActive() — re-apply screensaver dim OR managed brightness on return (depending on current state)
  • Extracted applyBrightnessForActiveScreensaver() helper from showScreensaver() 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):

  • backgroundRestoresOriginalBrightnessWhenScreensaverActive
  • foregroundReappliesScreensaverDim
  • foregroundReappliesManagedBrightnessWithoutScreensaver
  • lifecycleDoesNothingWhenKioskInactive

Tests drive KioskModeManager.shared directly (there is no injectable instance today). The setup helper snapshots Current.screenBrightness / setScreenBrightness closures 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, KioskModeManager could 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

  • Unit tests pass (KioskLifecycleBrightnessTests — 4 cases)
  • Existing kiosk tests still pass (no regression from the showScreensaver() refactor)
  • bundle exec fastlane lint clean (SwiftFormat + SwiftLint + Rubocop)
  • xcodebuild clean build (App target)
  • xcodebuild build-for-testing (test bundle compiles)

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 originalBrightness when 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.

Comment on lines 426 to 431
Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel))

case .clock:
screenState = .screensaver
if settings.screensaverDimLevel < currentBrightness {
Current.setScreenBrightness(CGFloat(settings.screensaverDimLevel))
}
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +51
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 }

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()
}

Copilot uses AI. Check for mistakes.
Comment on lines +371 to +375
if activeScreensaverMode != nil {
applyBrightnessForActiveScreensaver()
} else if settings.brightnessControlEnabled {
applyBrightness()
}
Copy link

Copilot AI Apr 16, 2026

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 .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().

Copilot uses AI. Check for mistakes.
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.
@nstefanelli
Copy link
Copy Markdown
Contributor Author

Thanks for the review, @copilot-pull-request-reviewer — all three points were legit. Pushed ab93ceb addressing them:

  1. Clock mode foreground reapply no-op (real bug)applyBrightnessForActiveScreensaver() was comparing settings.screensaverDimLevel against the stored currentBrightness property. On the background → foreground cycle those diverge (background restores originalBrightness to the actual display without updating the stored property), so the clock-mode guard wrongly evaluated false and left the screen stuck at the restored too-bright level. Now compares against Current.screenBrightness() (actual display value). Preserves existing behavior on the initial-show path since the two values are equal at that moment.

  2. Test setup side-effect risk — mocks now install on Current before disableKioskMode() is called. Protects against a stale kiosk-active state from a prior run invoking the real Current.setScreenBrightness and changing the simulator/device brightness mid-test.

  3. Missing clock-mode test — added foregroundReappliesClockScreensaverDim, which is the regression test for (1) and exercises the exact background → foreground lifecycle path in .clock mode. Would have caught the bug earlier.

@bgoncal
Copy link
Copy Markdown
Member

bgoncal commented Apr 20, 2026

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kiosk mode: iPad does not wake up when pressing on notification pop-up from other apps

3 participants