From d46db3b9bfb145b0e0ae4d16012553235d0dd732 Mon Sep 17 00:00:00 2001 From: everts Date: Thu, 4 Dec 2025 21:24:00 -0600 Subject: [PATCH] Fix splash screen theme not respecting user preference on Android 12+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #633 On Android 12 and above, the splash screen was ignoring the user’s saved theme preference and instead defaulting to the system theme. This happened because the splash screen is rendered before Application.onCreate() finishes executing. At that point, the system’s UiModeManager hadn't been updated with the user's preferred theme, so it showed the wrong one. Changes: - Introduced initializeNightModeFromPreferences() in UiExtensions.kt. This method forces the correct theme mode synchronously during Application.onCreate(), ensuring it’s in place before the splash screen ever appears. - Added observeNightModePreferences() to actively listen for changes to the theme setting. This keeps UiModeManager updated in real time so future cold starts use the right theme immediately. - On Android 12+ (API 31+), now using UiModeManager.setApplicationNightMode() to set the theme properly. - For older versions, the implementation falls back to AppCompatDelegate.setDefaultNightMode() for compatibility. - Added a toDarkThemeConfig() extension function to translate the DataStore proto into the theme model used by the UI. - Included the AppCompat dependency in the app module to support these changes. Why the Observer Matters? Without it, any theme change made by the user wouldn't notify UiModeManager right away. That would mean the next time the app is launched cold, the splash screen would still use whatever theme was cached before—not the one the user just selected. This fix prevents that. Testing: - Confirmed that the splash screen reflects the saved theme preference, even on a fresh app start. - Checked all theme configurations: Light, Dark, and Follow System. - Verified behavior on both Android 12+ and Android 11 devices to ensure backward compatibility. --- app/build.gradle.kts | 1 + .../apps/nowinandroid/NiaApplication.kt | 18 ++++ .../apps/nowinandroid/util/UiExtensions.kt | 93 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f0253943..d4379372d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,6 +99,7 @@ dependencies { implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.viewModel.navigation3) implementation(libs.androidx.profileinstaller) diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt index 4975e5d65..bc2a8b4da 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaApplication.kt @@ -20,11 +20,17 @@ import android.app.Application import android.content.pm.ApplicationInfo import android.os.StrictMode import android.os.StrictMode.ThreadPolicy.Builder +import androidx.datastore.core.DataStore import coil.ImageLoader import coil.ImageLoaderFactory +import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences +import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope import com.google.samples.apps.nowinandroid.sync.initializers.Sync import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger +import com.google.samples.apps.nowinandroid.util.initializeNightModeFromPreferences +import com.google.samples.apps.nowinandroid.util.observeNightModePreferences import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope import javax.inject.Inject /** @@ -35,12 +41,24 @@ class NiaApplication : Application(), ImageLoaderFactory { @Inject lateinit var imageLoader: dagger.Lazy + @Inject + lateinit var userPrefsDataStore: DataStore + @Inject lateinit var profileVerifierLogger: ProfileVerifierLogger + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope + override fun onCreate() { super.onCreate() + // Initialize dark mode from user prefs + initializeNightModeFromPreferences(userPrefsDataStore) + + observeNightModePreferences(userPrefsDataStore, applicationScope) + setStrictModePolicy() // Initialize Sync; the system responsible for keeping data in the app up to date. diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt index 20d55ab4c..b3220732a 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt @@ -16,13 +16,28 @@ package com.google.samples.apps.nowinandroid.util +import android.app.UiModeManager +import android.content.Context import android.content.res.Configuration +import android.os.Build import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate import androidx.core.util.Consumer +import androidx.datastore.core.DataStore +import com.google.samples.apps.nowinandroid.core.datastore.DarkThemeConfigProto +import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** * Convenience wrapper for dark mode checking @@ -47,3 +62,81 @@ fun ComponentActivity.isSystemInDarkTheme() = callbackFlow { } .distinctUntilChanged() .conflate() + +/** + * Converts [DarkThemeConfig] to AppCompat night mode constant. + */ +fun DarkThemeConfig.toNightMode(): Int = when (this) { + DarkThemeConfig.FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + DarkThemeConfig.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + DarkThemeConfig.DARK -> AppCompatDelegate.MODE_NIGHT_YES +} + +/** + * Maps a [DarkThemeConfig] value to the corresponding night mode setting + * used by UiModeManager on Android 12 (API level 31) and above. + */ +@RequiresApi(Build.VERSION_CODES.S) +fun DarkThemeConfig.toUiNightMode(): Int = when(this) { + DarkThemeConfig.FOLLOW_SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + DarkThemeConfig.LIGHT -> UiModeManager.MODE_NIGHT_NO + DarkThemeConfig.DARK -> UiModeManager.MODE_NIGHT_YES +} + +/** + * Applies this [DarkThemeConfig] as default night mode. + */ +fun DarkThemeConfig.applyAsDefaultNightMode(context: Context) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + uiModeManager.setApplicationNightMode(toUiNightMode()) + } else { + AppCompatDelegate.setDefaultNightMode(toNightMode()) + } +} + +/** + * Converts stored proto data into a DarkThemeConfig object. + */ +fun DarkThemeConfigProto.toDarkThemeConfig(): DarkThemeConfig = when (this) { + DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfig.LIGHT + DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK + else -> DarkThemeConfig.FOLLOW_SYSTEM +} + +/** + * Sets night mode from user prefs. Call in Application.onCreate() to show correct splash theme. + */ +fun Context.initializeNightModeFromPreferences(userPrefsDataStore: DataStore) { + runBlocking { + runCatching { + val darkThemeConfig = userPrefsDataStore.data + .first() + .darkThemeConfig + .toDarkThemeConfig() + + darkThemeConfig.applyAsDefaultNightMode(this@initializeNightModeFromPreferences) + }.onFailure { + DarkThemeConfig.FOLLOW_SYSTEM.applyAsDefaultNightMode(this@initializeNightModeFromPreferences) + } + } +} + +/** + * Observe theme changes and updates UiModeManager to prevent a bug where the first cold start + * uses the previous theme, while later starts use the correct one. + */ +fun Context.observeNightModePreferences( + userPrefsDataStore: DataStore, + scope: CoroutineScope, +) { + scope.launch { + userPrefsDataStore.data + .map { it.darkThemeConfig.toDarkThemeConfig() } + .distinctUntilChanged() + .drop(1) // Skip first emission (already handled by initializeNightModeFromPreferences) + .collect { darkThemeConfig -> + darkThemeConfig.applyAsDefaultNightMode(this@observeNightModePreferences) + } + } +} \ No newline at end of file