From cc1d4f94de91566dcdd47dbb13e81db94c5b5b3c Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 3 Aug 2026 18:55:21 -0400 Subject: [PATCH 1/3] fix: use Android-owned Configuration.uiMode for SYSTEM dark mode (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In SYSTEM mode, Theme.kt delegated the dark/light decision to the transient Compose isSystemInDarkTheme() signal with no app-owned reconciliation. A stale or false signal deterministically selected the light dynamic/static branch, producing light surfaces even when Android night mode was active. This fix introduces rememberPlatformSystemDark() — an Android-owned lifecycle-safe system appearance source that: - Seeds from Configuration.uiMode (the OS-owned source of truth) - Refreshes on ON_RESUME so lock/unlock and configuration changes are captured - Logs Compose-vs-Configuration mismatches for diagnostics - Follows the existing LifecycleEventObserver pattern from App.kt PR #678's manifest behavior (no uiMode in configChanges) is preserved. Permission-gated roots (BlePermissionHandler, OptionalPermissionsHandler) have their own isSystemInDarkTheme() calls and are outside this fix scope. Acceptance criteria: - SYSTEM + dynamic colors renders dark when Android night mode is dark - DARK/LIGHT remain stable across foreground/resume - Material You dark clamp contract preserved - Diagnostic mismatch logging active Fixes #677 --- .../theme/ThemeModeUiContractGuardTest.kt | 10 ++- .../ui/theme/PlatformSystemDark.android.kt | 73 +++++++++++++++++++ .../ui/theme/PlatformSystemDark.kt | 17 +++++ .../devil/phoenixproject/ui/theme/Theme.kt | 3 +- .../ui/theme/PlatformSystemDark.ios.kt | 12 +++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt create mode 100644 shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.kt create mode 100644 shared/src/iosMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.ios.kt diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt index b4ba81d9a..82b6bfc7e 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt @@ -62,12 +62,16 @@ class ThemeModeUiContractGuardTest { } @Test - fun commonTheme_mapsSystemToSystemDarkTheme() { + fun commonTheme_mapsSystemToLifecycleSafePlatformDark() { val source = read("shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt") assertTrue( - source.contains("ThemeMode.SYSTEM -> isSystemInDarkTheme()"), - "System theme mode must continue to follow the platform system dark-theme signal.", + source.contains("ThemeMode.SYSTEM -> rememberPlatformSystemDark()"), + "System theme mode must use the lifecycle-safe platform dark signal (Configuration.uiMode on Android) rather than the transient isSystemInDarkTheme().", + ) + assertFalse( + source.contains("isSystemInDarkTheme()"), + "Theme.kt must not directly call isSystemInDarkTheme(); use rememberPlatformSystemDark() for lifecycle-safe resume reconciliation.", ) } diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt new file mode 100644 index 000000000..64ab324d1 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt @@ -0,0 +1,73 @@ +package com.devil.phoenixproject.ui.theme + +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import co.touchlab.kermit.Logger + +private val log = Logger.withTag("PlatformSystemDark") + +/** + * Android-owned lifecycle-safe system appearance source. + * + * Seeds from `Configuration.uiMode` on first composition and refreshes on every + * `ON_RESUME` event so lock/unlock, display-mode changes, and other configuration + * transitions are captured. Logs a mismatch when the Compose + * `isSystemInDarkTheme()` signal disagrees with the Android-owned value — this + * telemetry is the first diagnostic that would have caught issue #677. + */ +@Composable +actual fun rememberPlatformSystemDark(): Boolean { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + fun readUiModeDark(): Boolean { + val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return nightMask == Configuration.UI_MODE_NIGHT_YES + } + + var isDark by remember { mutableStateOf(readUiModeDark()) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + val refreshed = readUiModeDark() + if (refreshed != isDark) { + log.i { "System dark mode changed on resume: $isDark -> $refreshed" } + } + isDark = refreshed + + // Diagnostic: log mismatch between Android-owned value and Compose signal + val composeSignal = try { + // Evaluate the Compose system-dark signal outside of composition + // so we can compare it against our authoritative source. + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + } catch (_: Throwable) { + null + } + if (composeSignal != null && composeSignal != refreshed) { + log.w { + "MISMATCH: Configuration.uiMode says dark=$refreshed " + + "but Compose signal says dark=$composeSignal" + } + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + return isDark +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.kt new file mode 100644 index 000000000..c761746c2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.kt @@ -0,0 +1,17 @@ +package com.devil.phoenixproject.ui.theme + +import androidx.compose.runtime.Composable + +/** + * Returns the platform-owned system dark-mode state, refreshed on lifecycle resume. + * + * On Android this reads `Configuration.uiMode` (the OS-owned source of truth) and + * is refreshed on `ON_RESUME` so lock/unlock and other configuration changes are + * captured without relying on the transient Compose `isSystemInDarkTheme()` signal. + * + * On iOS this delegates to `isSystemInDarkTheme()` which is already lifecycle-stable. + * + * Use this instead of `isSystemInDarkTheme()` when resolving `ThemeMode.SYSTEM`. + */ +@Composable +expect fun rememberPlatformSystemDark(): Boolean diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt index 21e0c5df9..e7e86b48b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.devil.phoenixproject.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -107,7 +106,7 @@ fun VitruvianTheme( content: @Composable () -> Unit, ) { val useDarkColors = when (themeMode) { - ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.SYSTEM -> rememberPlatformSystemDark() ThemeMode.LIGHT -> false ThemeMode.DARK -> true } diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.ios.kt new file mode 100644 index 000000000..c40e4761f --- /dev/null +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.ios.kt @@ -0,0 +1,12 @@ +package com.devil.phoenixproject.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +/** + * iOS: delegate to the platform `isSystemInDarkTheme()` signal. + * iOS does not have the same transient-signal issue as Android's Compose bridge; + * the SwiftUI/UIViewController lifecycle keeps the appearance stable. + */ +@Composable +actual fun rememberPlatformSystemDark(): Boolean = isSystemInDarkTheme() From 705d0d07e6ddb8a62ad26d1e21d6e95f84d0d7a8 Mon Sep 17 00:00:00 2001 From: Phoenix Worker Date: Mon, 3 Aug 2026 19:30:25 -0400 Subject: [PATCH 2/3] fix: capture Compose signal during composition for mismatch diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous composeSignal re-read Configuration.uiMode (same expression as readUiModeDark()), so the mismatch comparison was permanently false. Now capture isSystemInDarkTheme() during composition and compare against the refreshed Configuration value on ON_RESUME. This makes the drift detector actually functional — if Compose and Configuration disagree, it logs a warning instead of silently succeeding. Addresses review comments from Codex (P2) and Kilo (critical) on PR #691. --- .../ui/theme/PlatformSystemDark.android.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt index 64ab324d1..a17beb4b9 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt @@ -37,6 +37,10 @@ actual fun rememberPlatformSystemDark(): Boolean { var isDark by remember { mutableStateOf(readUiModeDark()) } + // Capture the Compose system-dark signal during composition so we can compare + // it against the authoritative Configuration.uiMode value on resume. + val composeSignal = isSystemInDarkTheme() + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { @@ -46,19 +50,14 @@ actual fun rememberPlatformSystemDark(): Boolean { } isDark = refreshed - // Diagnostic: log mismatch between Android-owned value and Compose signal - val composeSignal = try { - // Evaluate the Compose system-dark signal outside of composition - // so we can compare it against our authoritative source. - (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == - Configuration.UI_MODE_NIGHT_YES - } catch (_: Throwable) { - null - } - if (composeSignal != null && composeSignal != refreshed) { + // Diagnostic: log mismatch between Android-owned value and Compose signal. + // composeSignal is captured during composition; refreshed comes from + // Configuration.uiMode on this resume event. A drift between them means + // the Compose ambient and the OS night-mode flag disagree. + if (composeSignal != refreshed) { log.w { "MISMATCH: Configuration.uiMode says dark=$refreshed " + - "but Compose signal says dark=$composeSignal" + "but Compose isSystemInDarkTheme() says dark=$composeSignal" } } } From 59c67661e8f0037a48b015e4eff0261e47693a05 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 3 Aug 2026 19:55:36 -0400 Subject: [PATCH 3/3] fix(theme): keep resume diagnostic signal current --- .../theme/ThemeModeUiContractGuardTest.kt | 18 ++++++++++++++++++ .../ui/theme/PlatformSystemDark.android.kt | 17 +++++++++-------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt index 82b6bfc7e..fa5b335b6 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt @@ -75,4 +75,22 @@ class ThemeModeUiContractGuardTest { ) } + @Test + fun androidSystemDarkDiagnostic_readsTheLatestComposeSignalOnResume() { + val source = read("shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt") + + assertTrue( + source.contains("Lifecycle.Event.ON_RESUME") && source.contains("isDark = refreshed"), + "The Android-owned theme source must reconcile its state from Configuration.uiMode on every lifecycle resume so lock/unlock can repair a stale system appearance.", + ) + assertTrue( + source.contains("val currentComposeSignal by rememberUpdatedState(composeSignal)"), + "The lifecycle observer must bridge a recomposed isSystemInDarkTheme() value with rememberUpdatedState so ON_RESUME does not compare Configuration.uiMode with the first composition's stale Compose signal.", + ) + assertTrue( + source.contains("currentComposeSignal != refreshed"), + "The resume mismatch diagnostic must compare Configuration.uiMode with the current Compose signal rather than the observer's initial captured value.", + ) + } + } diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt index a17beb4b9..ea21e042a 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle @@ -37,9 +38,10 @@ actual fun rememberPlatformSystemDark(): Boolean { var isDark by remember { mutableStateOf(readUiModeDark()) } - // Capture the Compose system-dark signal during composition so we can compare - // it against the authoritative Configuration.uiMode value on resume. + // Capture the Compose system-dark signal during composition and bridge its latest + // value into the long-lived lifecycle observer for each resume diagnostic. val composeSignal = isSystemInDarkTheme() + val currentComposeSignal by rememberUpdatedState(composeSignal) DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -50,14 +52,13 @@ actual fun rememberPlatformSystemDark(): Boolean { } isDark = refreshed - // Diagnostic: log mismatch between Android-owned value and Compose signal. - // composeSignal is captured during composition; refreshed comes from - // Configuration.uiMode on this resume event. A drift between them means - // the Compose ambient and the OS night-mode flag disagree. - if (composeSignal != refreshed) { + // Diagnostic: log mismatch between Android-owned value and the latest + // recomposed Compose signal. `rememberUpdatedState` keeps this observer + // current without re-registering it on every recomposition. + if (currentComposeSignal != refreshed) { log.w { "MISMATCH: Configuration.uiMode says dark=$refreshed " + - "but Compose isSystemInDarkTheme() says dark=$composeSignal" + "but Compose isSystemInDarkTheme() says dark=$currentComposeSignal" } } }