fix(widget): Persist theme settings across device reboot

Closes #549
This commit is contained in:
darken
2026-04-25 20:39:40 +02:00
committed by Matthias Urhahn
parent a40599c1dc
commit 97f294e5f7
8 changed files with 253 additions and 62 deletions
@@ -52,7 +52,11 @@ class AncGlanceWidget : GlanceAppWidget() {
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
@@ -80,10 +84,9 @@ class AncGlanceWidget : GlanceAppWidget() {
val heightDp = LocalSize.current.height
val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val theme = WidgetTheme.fromBundle(
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
)
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
val profileId = config.profileId
val theme = config.theme
val isPro = upgradeInfo?.isPro ?: initialIsPro
@@ -53,7 +53,7 @@ class AncModeActionCallback : ActionCallback {
val ep = EntryPointAccessors.fromApplication(context, AncModeCallbackEntryPoint::class.java)
val profileId = ep.widgetSettings().getWidgetProfile(widgetId)
val profileId = ep.widgetSettings().getWidgetConfig(widgetId).profileId
if (profileId == null) {
log(TAG, ERROR) { "onAction: no profile for widgetId=$widgetId" }
return
@@ -52,7 +52,11 @@ class BatteryGlanceWidget : GlanceAppWidget() {
appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
initialIsPro = ep.upgradeRepo().isPro()
initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
ep.widgetSettings().migrateLegacyConfigIfNeeded(
appWidgetId,
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId),
)
initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId
cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) }
} catch (e: Exception) {
log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" }
@@ -82,10 +86,9 @@ class BatteryGlanceWidget : GlanceAppWidget() {
val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt()))
val state = try {
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
val theme = WidgetTheme.fromBundle(
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
)
val config = ep.widgetSettings().getWidgetConfig(appWidgetId)
val profileId = config.profileId
val theme = config.theme
val isPro = upgradeInfo?.isPro ?: initialIsPro
@@ -0,0 +1,10 @@
package eu.darken.capod.main.ui.widget
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.serialization.Serializable
@Serializable
data class WidgetConfig(
val profileId: ProfileId? = null,
val theme: WidgetTheme = WidgetTheme(),
)
@@ -47,19 +47,21 @@ class WidgetConfigurationViewModel @Inject constructor(
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID" }
} else {
widgetSettings.migrateLegacyConfigIfNeeded(widgetId, appWidgetManager.getAppWidgetOptions(widgetId))
}
}
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
private val initialTheme: WidgetTheme = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
private val initialConfig: WidgetConfig = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetConfig()
widgetSettings.getWidgetConfig(widgetId)
}
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
private val selectedProfile = MutableStateFlow(initialConfig.profileId)
private val currentTheme = MutableStateFlow(initialTheme)
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialConfig.theme) == null)
private val currentTheme = MutableStateFlow(initialConfig.theme)
private val visibleProfiles = deviceProfilesRepo.profiles.map { profiles ->
if (isAncWidget) profiles.filter { it.model.features.hasAncControl } else profiles
@@ -152,18 +154,16 @@ class WidgetConfigurationViewModel @Inject constructor(
}
fun confirmSelection() {
val selectedProfile = selectedProfile.value
if (selectedProfile != null) {
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG, INFO) { "confirmSelection: invalid widget ID, skipping save" }
return
}
// Save theme to AppWidgetOptions bundle
val theme = currentTheme.value
log(TAG, INFO) { "confirmSelection: saving theme=$theme" }
val options = appWidgetManager.getAppWidgetOptions(widgetId)
theme.toBundle(options)
appWidgetManager.updateAppWidgetOptions(widgetId, options)
val config = WidgetConfig(
profileId = selectedProfile.value,
theme = currentTheme.value,
)
log(TAG, INFO) { "confirmSelection(widgetId=$widgetId, config=$config)" }
widgetSettings.saveWidgetConfig(widgetId, config)
}
companion object {
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.widget
import android.content.Context
import android.os.Bundle
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
@@ -11,15 +12,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.common.serialization.SerializationCapod
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WidgetSettings @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
@SerializationCapod private val json: Json,
) {
private val Context.dataStore by preferencesDataStore(
@@ -29,28 +34,76 @@ class WidgetSettings @Inject constructor(
private val dataStore: DataStore<Preferences> get() = context.dataStore
fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) {
log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" }
runBlocking {
dataStore.edit { it[stringPreferencesKey(getWidgetProfileKey(widgetId))] = profileId }
fun getWidgetConfig(widgetId: Int): WidgetConfig {
val raw = runBlocking { dataStore.data.first()[configKey(widgetId)] }
if (raw == null) {
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = absent → default" }
return WidgetConfig()
}
return try {
json.decodeFromString<WidgetConfig>(raw).also {
log(TAG, VERBOSE) { "getWidgetConfig(widgetId=$widgetId) = $it" }
}
} catch (e: SerializationException) {
log(TAG) { "getWidgetConfig(widgetId=$widgetId): malformed JSON, returning default ($e)" }
WidgetConfig()
}
}
fun getWidgetProfile(widgetId: Int): ProfileId? = runBlocking {
dataStore.data.first()[stringPreferencesKey(getWidgetProfileKey(widgetId))]
fun saveWidgetConfig(widgetId: Int, config: WidgetConfig) {
log(TAG, VERBOSE) { "saveWidgetConfig(widgetId=$widgetId, config=$config)" }
val encoded = json.encodeToString(config)
runBlocking {
dataStore.edit { prefs ->
prefs[configKey(widgetId)] = encoded
prefs.remove(legacyProfileKey(widgetId))
}
}
}
/**
* One-shot migration from the legacy storage layout (separate `widget_profile_<id>` DataStore key
* + per-widget theme in `AppWidgetManager.getAppWidgetOptions()`) to the unified
* `widget_config_<id>` JSON. Marker-guarded inside the same `dataStore.edit {}` block so
* concurrent saves can't be overwritten.
*
* Pre-reboot the legacy options Bundle still carries the user's theme; post-reboot it does not
* (Android only persists the standard `OPTION_APPWIDGET_*` keys).
*/
fun migrateLegacyConfigIfNeeded(widgetId: Int, legacyOptions: Bundle) {
val legacyTheme = WidgetTheme.fromLegacyBundleOrNull(legacyOptions)
runBlocking {
dataStore.edit { prefs ->
if (prefs[configKey(widgetId)] != null) return@edit
val legacyProfileId = prefs[legacyProfileKey(widgetId)]
if (legacyProfileId == null && legacyTheme == null) return@edit
val migrated = WidgetConfig(
profileId = legacyProfileId,
theme = legacyTheme ?: WidgetTheme(),
)
log(TAG) { "migrateLegacyConfigIfNeeded(widgetId=$widgetId): writing $migrated" }
prefs[configKey(widgetId)] = json.encodeToString(migrated)
prefs.remove(legacyProfileKey(widgetId))
}
}
}
fun removeWidget(widgetId: Int) {
log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" }
runBlocking {
dataStore.edit { it.remove(stringPreferencesKey(getWidgetProfileKey(widgetId))) }
dataStore.edit { prefs ->
prefs.remove(configKey(widgetId))
prefs.remove(legacyProfileKey(widgetId))
}
}
}
private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId"
private fun configKey(widgetId: Int) = stringPreferencesKey("$WIDGET_CONFIG_PREFIX$widgetId")
private fun legacyProfileKey(widgetId: Int) = stringPreferencesKey("$LEGACY_WIDGET_PROFILE_PREFIX$widgetId")
companion object {
private const val WIDGET_PROFILE_PREFIX = "widget_profile_"
private const val WIDGET_CONFIG_PREFIX = "widget_config_"
private const val LEGACY_WIDGET_PROFILE_PREFIX = "widget_profile_"
private val TAG = logTag("Widget", "Settings")
}
}
@@ -4,7 +4,9 @@ import android.graphics.Color
import android.os.Bundle
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import kotlinx.serialization.Serializable
@Serializable
data class WidgetTheme(
@ColorInt val backgroundColor: Int? = null,
@ColorInt val foregroundColor: Int? = null,
@@ -36,7 +38,18 @@ data class WidgetTheme(
val DEFAULT = WidgetTheme()
fun fromBundle(bundle: Bundle): WidgetTheme {
/**
* Reads a [WidgetTheme] from the legacy `AppWidgetManager.getAppWidgetOptions()` Bundle.
* Returns `null` if the Bundle holds none of the legacy keys — distinguishing "system reset
* the Bundle to system-only keys" from "user picked Material You + 255".
*/
fun fromLegacyBundleOrNull(bundle: Bundle): WidgetTheme? {
val hasLegacyKey = bundle.containsKey(KEY_THEME_MODE)
|| bundle.containsKey(KEY_BG_ALPHA)
|| bundle.containsKey(KEY_SHOW_LABEL)
|| bundle.containsKey(KEY_CUSTOM_BG)
|| bundle.containsKey(KEY_CUSTOM_FG)
if (!hasLegacyKey) return null
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
@@ -71,25 +84,4 @@ data class WidgetTheme(
}
}
fun toBundle(bundle: Bundle) {
if (backgroundColor == null && foregroundColor == null) {
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
bundle.remove(KEY_CUSTOM_BG)
bundle.remove(KEY_CUSTOM_FG)
} else {
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
if (backgroundColor != null) {
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
} else {
bundle.remove(KEY_CUSTOM_BG)
}
if (foregroundColor != null) {
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
} else {
bundle.remove(KEY_CUSTOM_FG)
}
}
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
}
}
@@ -0,0 +1,130 @@
package eu.darken.capod.main.ui.widget
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class WidgetConfigSerializationTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Test
fun `default config round-trip`() {
val original = WidgetConfig()
val encoded = json.encodeToString(WidgetConfig.serializer(), original)
val decoded = json.decodeFromString(WidgetConfig.serializer(), encoded)
decoded shouldBe original
}
@Test
fun `dark preset with full transparency round-trip`() {
val original = WidgetConfig(
profileId = "profile-123",
theme = WidgetTheme(
backgroundColor = WidgetTheme.Preset.CLASSIC_DARK.presetBg,
foregroundColor = WidgetTheme.Preset.CLASSIC_DARK.presetFg,
backgroundAlpha = 0,
showDeviceLabel = false,
),
)
val encoded = json.encodeToString(WidgetConfig.serializer(), original)
val decoded = json.decodeFromString(WidgetConfig.serializer(), encoded)
decoded shouldBe original
decoded.theme.backgroundAlpha shouldBe 0
decoded.theme.showDeviceLabel shouldBe false
}
@Test
fun `material you theme has null colors`() {
val original = WidgetConfig(
profileId = "profile-x",
theme = WidgetTheme(
backgroundColor = null,
foregroundColor = null,
backgroundAlpha = 128,
showDeviceLabel = true,
),
)
val encoded = json.encodeToString(WidgetConfig.serializer(), original)
val decoded = json.decodeFromString(WidgetConfig.serializer(), encoded)
decoded.theme.backgroundColor shouldBe null
decoded.theme.foregroundColor shouldBe null
}
@Test
fun `unknown future fields are ignored`() {
val futureJson = """
{
"profileId": "p1",
"theme": {
"backgroundAlpha": 200,
"showDeviceLabel": true,
"futureField": "ignored"
},
"futureTopLevel": 42
}
""".trimIndent()
val decoded = json.decodeFromString(WidgetConfig.serializer(), futureJson)
decoded.profileId shouldBe "p1"
decoded.theme.backgroundAlpha shouldBe 200
decoded.theme.showDeviceLabel shouldBe true
}
@Test
fun `missing fields fall back to defaults`() {
val sparseJson = """{"profileId":"p2","theme":{}}"""
val decoded = json.decodeFromString(WidgetConfig.serializer(), sparseJson)
decoded.profileId shouldBe "p2"
decoded.theme shouldBe WidgetTheme()
decoded.theme.backgroundAlpha shouldBe 255
decoded.theme.showDeviceLabel shouldBe true
}
@Test
fun `empty json decodes to default config`() {
val decoded = json.decodeFromString(WidgetConfig.serializer(), "{}")
decoded.profileId shouldBe null
decoded.theme shouldBe WidgetTheme()
}
@Test
fun `profile-only config decodes`() {
val encoded = json.encodeToString(
WidgetConfig.serializer(),
WidgetConfig(profileId = "only-profile"),
)
val decoded = json.decodeFromString(WidgetConfig.serializer(), encoded)
decoded.profileId shouldBe "only-profile"
decoded.theme shouldBe WidgetTheme()
}
@Test
fun `theme matches preset after round-trip`() {
val original = WidgetConfig(
theme = WidgetTheme(
backgroundColor = WidgetTheme.Preset.BLUE.presetBg,
foregroundColor = WidgetTheme.Preset.BLUE.presetFg,
),
)
val encoded = json.encodeToString(WidgetConfig.serializer(), original)
val decoded = json.decodeFromString(WidgetConfig.serializer(), encoded)
WidgetTheme.matchPreset(decoded.theme).shouldNotBeNull() shouldBe WidgetTheme.Preset.BLUE
}
}