diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 25f5ba21cb..7ce9b27bd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -205,7 +205,7 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_BOTTOM_BAR_ITEMS] = JsonMapper.toJson(sharedSettings.bottomBarItems) + preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems) preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab @@ -226,7 +226,19 @@ class UiSharedPreferences( } } - private fun decodeBottomBarItems(raw: String): List? { + /** + * Persists "follow the defaults" as a blank sentinel instead of the concrete default list. + * + * A user who resets the bottom bar (or who never customized it) should track whatever + * [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would + * pin them to today's default, so a future version that changes the default would never reach + * them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the + * current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to + * the new default. Any genuinely customized bar is still stored as JSON. + */ + internal fun encodeBottomBarItems(items: List): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items) + + internal fun decodeBottomBarItems(raw: String): List? { if (raw.isBlank()) return DefaultBottomBarEntries // Current format: a JSON list of BottomBarEntry (built-ins + favorites). runCatching { return JsonMapper.fromJson>(raw) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt new file mode 100644 index 0000000000..2ea2f21a23 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks the "reset to defaults migrates automatically" behavior. + * + * Resetting the bottom bar (or never customizing it) is persisted as a blank sentinel rather than the + * concrete default list, so that a user on the defaults tracks whatever [DefaultBottomBarEntries] the + * *installed* app version ships. If a future version changes the default, that blank value resolves to + * the new default on load — the user is migrated instead of being pinned to the old default. + */ +class BottomBarPersistenceTest { + @Test + fun defaultsAreStoredAsBlankSentinel() { + assertEquals("", UiSharedPreferences.encodeBottomBarItems(DefaultBottomBarEntries)) + } + + @Test + fun blankSentinelDecodesToCurrentDefaults() { + // The blank sentinel resolves to whatever DefaultBottomBarEntries this build ships. Because it + // returns the current constant (not a value frozen at reset time), a future version that changes + // the default automatically migrates every user who is on the defaults. + assertEquals(DefaultBottomBarEntries, UiSharedPreferences.decodeBottomBarItems("")) + } + + @Test + fun customizedBarIsStoredVerbatimAndRoundTrips() { + val custom = + listOf( + BottomBarEntry.BuiltIn(NavBarItem.HOME), + BottomBarEntry.Favorite("url:https://example.com"), + ) + val encoded = UiSharedPreferences.encodeBottomBarItems(custom) + assertEquals(custom, UiSharedPreferences.decodeBottomBarItems(encoded)) + } +}