Skip to content

fix: use Android-owned Configuration.uiMode for SYSTEM dark mode (#677) - #691

Merged
9thLevelSoftware merged 3 commits into
mainfrom
fix/dark-mode-system-theme-issue-677
Aug 4, 2026
Merged

fix: use Android-owned Configuration.uiMode for SYSTEM dark mode (#677)#691
9thLevelSoftware merged 3 commits into
mainfrom
fix/dark-mode-system-theme-issue-677

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Summary

Fixes the sporadic light-mode surface appearance when Android night mode is active and the user has selected ThemeMode.SYSTEM. The root cause (per GPT-5.6 Terra RCA) is that Theme.kt delegated the SYSTEM-mode dark/light decision to the transient Compose isSystemInDarkTheme() signal with no app-owned lifecycle reconciliation. A stale or false Compose signal deterministically selected the light dynamic/static branch.

Root Cause

In Theme.kt:110, ThemeMode.SYSTEM -> isSystemInDarkTheme() depends on a transient Compose signal. Phoenix had:

  • No app-owned Configuration.uiMode state
  • No ON_RESUME reconciliation after lock/unlock or configuration changes
  • No mismatch telemetry between the Compose signal and the Android-owned value

PR #678 correctly removed uiMode from configChanges in the manifest, but the underlying signal boundary was never addressed.

Fix

Introduces rememberPlatformSystemDark() — an Android-owned lifecycle-safe system appearance source:

  1. PlatformSystemDark.kt (commonMain) — expect declaration for the lifecycle-safe platform dark signal
  2. PlatformSystemDark.android.kt (androidMain) — Reads Configuration.uiMode & UI_MODE_NIGHT_MASK, registers LifecycleEventObserver to refresh on ON_RESUME, logs Compose-vs-Configuration mismatches
  3. PlatformSystemDark.ios.kt (iosMain) — Delegates to isSystemInDarkTheme() (iOS does not have the transient-signal issue)
  4. Theme.ktThemeMode.SYSTEM -> rememberPlatformSystemDark() replaces isSystemInDarkTheme()
  5. ThemeModeUiContractGuardTest.kt — Updated contract test to assert the new lifecycle-safe source

Non-Goals (per RCA)

  • No theme-system rewrite
  • No Material You removal
  • No speculative clamp expansion
  • Permission-gated roots (BlePermissionHandler, OptionalPermissionsHandler) have their own isSystemInDarkTheme() calls — outside this fix scope

Acceptance Criteria

  • SYSTEM + dynamic colors renders dark normal-root bg/surface when Android night mode is dark after foreground/resume
  • DARK remains dark and LIGHT remains light across foreground/resume
  • Existing dark Material You clamp contract preserved
  • Pixel lock/unlock diagnostic trace captured before issue closure (requires device testing)

RCA Contract

Fixes #677

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
Copilot AI review requested due to automatic review settings August 3, 2026 22:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cc1d4f94de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +54
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare against the actual Compose dark signal

When the sporadic signal divergence recurs, this diagnostic can never report it: composeSignal rereads the same context.resources.configuration.uiMode expression used by readUiModeDark(), immediately after refreshed was obtained, rather than evaluating isSystemInDarkTheme(). The values are therefore necessarily identical unless the configuration changes between the two reads, defeating the mismatch telemetry this change explicitly introduces; capture the Compose signal during composition and compare that value, or remove the ineffective diagnostic.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in commit 705d0d0: the composeSignal now captures isSystemInDarkTheme() during composition (the Compose ambient) rather than re-reading Configuration.uiMode in the observer. The mismatch detector now actually works — if Compose and Configuration disagree on resume, a warning is logged.

isDark = refreshed

// Diagnostic: log mismatch between Android-owned value and Compose signal
val composeSignal = try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔥 The Roast: This composeSignal is the cockroach motel of telemetry — looks alive, does nothing. You literally read Configuration.uiMode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES on line 53, which is byte-for-byte the same expression as readUiModeDark() already evaluated into refreshed on line 43. Then on line 58 you compare composeSignal != refreshed. That inequality is permanently false. The PR description proudly advertises "logs Compose-vs-Configuration mismatches" as a key piece of the fix — this logs them straight into /dev/null. The try { ... } catch (_: Throwable) is the cherry on top: it wraps a bitwise AND on a non-null Int that cannot throw, defending against ghosts.

🩹 The Fix: Delete lines 49–63 entirely (the composeSignal block and the surrounding mismatch log). Also delete the unused import androidx.compose.foundation.isSystemInDarkTheme on line 4. The refreshed != isDark log on line 44 already covers the operationally meaningful case (actual uiMode flip captured on resume). If you genuinely want a Compose-vs-Configuration drift detector, capture the Compose signal during composition (val composeSignal = isSystemInDarkTheme() in the composable body) and compare it against refreshed in the observer — but only do this if you have a concrete failure mode that needs it, not because the PR description promised it.

📏 Severity: critical


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in commit 705d0d0: replaced the dead composeSignal block with a val composeSignal = isSystemInDarkTheme() captured during composition. The observer now compares this Compose ambient against the refreshed Configuration.uiMode value. The try-catch is removed since isSystemInDarkTheme() is a pure read. The isSystemInDarkTheme import is now actually used.

@kilo-code-bot

kilo-code-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: No Issues Found | Recommendation: Merge

Oh look, the followup actually finishes the job. I had my "stale closure on first composition" bit loaded and they went and bridged it with rememberUpdatedState like a senior engineer. Rude.

🏆 Best part: rememberUpdatedState(composeSignal) on line 44 is the right tool. Not derivedStateOf, not a LaunchedEffect key, not a manual re-register — the canonical Compose idiom for "I have a value that recomposes but an observer that doesn't." The DisposableEffect(lifecycleOwner) key stays stable (matching App.kt:82 / IntegrationsScreen.kt:100), and the observer always reads the latest recomposed signal without re-registering itself on every recomposition. Whoever wrote this read the Compose source, not just the docs.

💀 Worst part: The mismatch diagnostic on line 58 is now technically correct (compares Compose's LocalConfiguration-sourced signal vs raw Resources.Configuration.uiMode) but will only fire in a very specific failure mode — namely Compose's ambient being out of sync with the raw Configuration. In a healthy app that's never. Still, the value of the detector is that if a future Compose regression breaks that invariant, this logs before the user sees a wrong theme. The cost is 8 lines and a W log. I'll allow it.

📊 Overall: Like the rare third act that doesn't undo the good will of the second — rememberUpdatedState was the only remaining loose thread, and it's tied off cleanly. The new contract-guard test on androidSystemDarkDiagnostic_readsTheLatestComposeSignalOnResume follows the existing string-content pattern in the file, so the test brittleness is consistent, not novel. Ponytail: nothing to delete that isn't promised behavior.

Ponytail: Lean already. Ship.

Files Reviewed (2 files, incremental)
  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt — 0 issues (CodeX P2 stale-closure followup fully addressed: rememberUpdatedState bridges composeSignal into the long-lived LifecycleEventObserver; diagnostic on line 58 now reads the latest recomposed value, not the first composition's snapshot)
  • shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt — 0 issues (new androidSystemDarkDiagnostic_readsTheLatestComposeSignalOnResume test follows the existing string-content contract-guard pattern in this file)
  • Unchanged in incremental diff (reviewed previously): Theme.kt, PlatformSystemDark.kt, PlatformSystemDark.ios.kt

🤖 Generated with Kilo Code

Previous Review Summaries (2 snapshots, latest commit 705d0d0)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 705d0d0)

Verdict: No Issues Found | Recommendation: Merge

Oh wait, the fix actually fixes the previous critical issue. I need to sit down. I had my whole "tautology in a trench coat" bit prepared and they went and made composeSignal capture isSystemInDarkTheme() during composition like a normal telemetry source. Rude.

🏆 Best part: Closing over composeSignal captured during composition (line 42) instead of re-reading Configuration.uiMode inside the observer. That's the actual Compose ambient, not a knockoff. The mismatch detector now compares two genuinely distinct signals, which is the entire point of having it. Whoever wrote the isSystemInDarkTheme() import on line 4 knew what they were importing it for this time.

💀 Worst part: Honestly nothing. The comment on lines 53–56 now correctly describes what the code does (composeSignal captured during composition vs refreshed from Configuration.uiMode on resume), no more ghost-defending try { ... } catch (_: Throwable) around a bitwise AND.

📊 Overall: Like the sequel that actually listens to the critics — same bones, fixed the scene everyone walked out of the theater for. DisposableEffect(lifecycleOwner) shape still matches App.kt:82 / IntegrationsScreen.kt:100, the contract-guard test was already updated at the prior SHA, and iosMain stays correctly trivial. Ponytail pass: nothing to cut that isn't PR-promised behavior.

Ponytail: Lean already. Ship.

Files Reviewed (1 file, incremental)
  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt — 0 issues (previous critical fully resolved; composeSignal now captures the real Compose ambient, try/catch decoration is gone, mismatch detector compares genuinely distinct signals)
  • Unchanged in incremental diff (reviewed previously): Theme.kt, PlatformSystemDark.kt, PlatformSystemDark.ios.kt, ThemeModeUiContractGuardTest.kt

🤖 Generated with Kilo Code

Previous review (commit cc1d4f9)

Verdict: Request changes | Recommendation: Fix the dead diagnostic before merge

Overview

Severity Count
🚨 critical 1
⚠️ warning 0
💡 suggestion 0
🤏 nitpick 0
Issue Details (click to expand)
File Line Roast
shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt 50 The "Compose-vs-Configuration mismatch" diagnostic re-computes the same Configuration.uiMode & UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES expression that refreshed already holds, then compares them for inequality. The warning will never fire — it's a tautology in a trench coat.

Verdict

Request changes. The lifecycle reconciliation itself (Configuration.uiMode seed + ON_RESUME DisposableEffect refresh) is correct and addresses the actual bug. The blocker is that the PR description and the new test name both advertise a "Compose-vs-Configuration mismatch" diagnostic that the implementation physically cannot produce — composeSignal is recomputed from the same source as refreshed, so the inequality is permanently false. Either make the diagnostic real or stop claiming it exists.

Correctness / Safety Findings

  • critical: shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt:L50composeSignal is recomputed from context.resources.configuration.uiMode on line 53, identical to refreshed on line 43. The composeSignal != refreshed branch on line 58 is unreachable; the warning never logs. Required fix: delete lines 49–63 (and the now-unused isSystemInDarkTheme import on line 4), or capture the real Compose signal during composition and compare against refreshed.

Scope Concern (Not Blocking, But Worth Naming)

The PR description explicitly scopes out BlePermissionHandler.android.kt:186 and OptionalPermissionsHandler.android.kt:140, both of which still call isSystemInDarkTheme() directly inside PermissionScreenTheme wrappers. They exhibit the same transient-signal shape the fix addresses in Theme.kt. If a user hits the permission gate under SYSTEM mode with Android night mode active, those screens can still render light surfaces. Recommend a follow-up PR — the contract guard test on Theme.kt correctly doesn't cover them, so a regression here would be silent.

Ponytail Review

  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt:L49: delete — the composeSignal mismatch block is a no-op tautology. The refreshed != isDark log on L44 already captures the real change. Replace with nothing.
  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt:L4: delete — import androidx.compose.foundation.isSystemInDarkTheme becomes unused once the diagnostic is removed.
  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt:L55: delete — try { ... } catch (_: Throwable) wraps a bitwise AND on a non-null Int that cannot throw. Pure defensive decoration.

The iosMain actual (PlatformSystemDark.ios.kt) is a one-line delegation and the commonMain expect declaration is appropriately minimal. The contract guard test follows the existing string-content pattern in the file — fine as-is.

Ponytail net: -16 lines.

Suggested Minimal Patch

Single-file deletion in shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt:

  1. Delete the import on line 4: import androidx.compose.foundation.isSystemInDarkTheme
  2. Delete lines 49–63 (the // Diagnostic: comment, the val composeSignal = try { ... } block, and the if (composeSignal != null && composeSignal != refreshed) { log.w { ... } } block).

That's it. No other files need to change.

Final Merge Guidance

Do not merge until the dead mismatch diagnostic is removed or made real. The actual bug fix (lifecycle-safe Configuration.uiMode reading) is solid; the marketing of a phantom diagnostic is what's blocking. After deletion, this is a clean, minimal, well-scoped fix.


🏆 Best part: The DisposableEffect(lifecycleOwner) { addObserver / removeObserver } shape matches the existing App.kt:82 and IntegrationsScreen.kt:100 patterns. Whoever wrote this read the room — the codebase already had the lifecycle-observer muscle memory, and the fix uses it. Nice.

💀 Worst part: A diagnostic whose only two values are bitwise-equal, shipped with a test name that asserts it's there, and described in the PR body as the headline telemetry. It's the "we have logs at home" of telemetry.

📊 Overall: A correct fix wearing a costume of a feature it doesn't have. Strip the costume and it's shippable.

Files Reviewed (5 files)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/Theme.kt — 0 issues (the actual swap to rememberPlatformSystemDark() is correct)
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.kt — 0 issues (clean expect declaration)
  • shared/src/androidMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.android.kt — 1 critical + 3 Ponytail deletions (~16 lines)
  • shared/src/iosMain/kotlin/com/devil/phoenixproject/ui/theme/PlatformSystemDark.ios.kt — 0 issues (correctly trivial)
  • shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/theme/ThemeModeUiContractGuardTest.kt — 0 issues (follows existing contract-guard pattern)

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 37K · Output: 5.9K · Cached: 133.8K

Review guidance: REVIEW.md from base branch main

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 705d0d07e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@9thLevelSoftware
9thLevelSoftware merged commit 0759806 into main Aug 4, 2026
10 checks passed
@9thLevelSoftware
9thLevelSoftware deleted the fix/dark-mode-system-theme-issue-677 branch August 4, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dark mode color scheme sporadically switches to light with Material You colors

2 participants