diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt index a6a87fc0..4aa63247 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt index 03924771..1c8a344a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt index 1b3f662e..43a1018b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfig.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfig.kt new file mode 100644 index 00000000..fe8f206b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfig.kt @@ -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(), +) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt index bb1084da..b6dabd07 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt index f8b0f550..59c187e9 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt @@ -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 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(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_` DataStore key + * + per-widget theme in `AppWidgetManager.getAppWidgetOptions()`) to the unified + * `widget_config_` 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") } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt index f9720b7a..b9d2716e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetTheme.kt @@ -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) - } } diff --git a/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigSerializationTest.kt b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigSerializationTest.kt new file mode 100644 index 00000000..60dce46b --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetConfigSerializationTest.kt @@ -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 + } +}