Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,7 @@
BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; };
BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; };
C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; };
A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */; };
C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; };
C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; };
C8860D27D848451A887BC441 /* WatchFolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */; };
Expand Down Expand Up @@ -3478,6 +3479,7 @@
E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = "<group>"; };
ED4B2D38DF1316D881D79769 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = "<group>"; };
EF91E383A44843F087423FB5 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntitiesTimelineProvider.swift; sourceTree = "<group>"; };
373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskLifecycleBrightness.test.swift; sourceTree = "<group>"; };
EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettings.test.swift; sourceTree = "<group>"; };
F49767602CA2066683EC638F /* KioskSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsView.swift; sourceTree = "<group>"; };
F6DA82FEEE2DDC3B2CC20DA3 /* Pods_iOS_Extensions_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -3684,6 +3686,7 @@
children = (
4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */,
EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */,
373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */,
);
path = Kiosk;
sourceTree = "<group>";
Expand Down Expand Up @@ -9728,6 +9731,7 @@
119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */,
C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */,
DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */,
A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
69 changes: 54 additions & 15 deletions Sources/App/Kiosk/KioskModeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment on lines +371 to +375
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.
}

/// 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
Expand All @@ -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()
}
Expand All @@ -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
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.
}

private func hideScreensaver(source: String) {
guard activeScreensaverMode != nil else { return }

Expand Down
195 changes: 195 additions & 0 deletions Tests/App/Kiosk/KioskLifecycleBrightness.test.swift
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")
}
}
Loading