mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user