fix(model): keep TopFilter's pre-move serial names so saved prefs survive

The move to commons changed every subclass's kotlinx default serial name
(the FQN), which is the polymorphic type discriminator JsonMapper writes
into the per-account DEFAULT_*_FOLLOW_LIST preferences. Decode failures
are swallowed by parseTopFilterOrDefault, so without this every user's
~30 saved tab selections silently reset on upgrade. @SerialName pins the
old names; TopFilterSerialNameTest pins them (and legacy-JSON decoding)
on JVM and iOS.

Also annotate the nativeMain Address actual @Serializable to match the
jvm/android actuals: @Contextual properties only get the plugin's
compile-time fallback on targets whose actual is serializable, so
encoding an address-carrying TopFilter threw on iOS.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
This commit is contained in:
Claude
2026-09-01 01:22:49 +00:00
parent 36edbb146e
commit cb9bb9d4b4
4 changed files with 133 additions and 1 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.topNavFeeds
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -33,6 +34,7 @@ sealed class TopFilter(
}
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Global")
object Global : TopFilter(" Global ")
/**
@@ -43,18 +45,23 @@ sealed class TopFilter(
* [Global] shows every event that p-tags the user instead.
*/
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Selected")
object Selected : TopFilter(" Selected ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.AllFollows")
object AllFollows : TopFilter(" All Follows ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.AllUserFollows")
object AllUserFollows : TopFilter(" All User Follows ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.DefaultFollows")
object DefaultFollows : TopFilter(" Main User Follows ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.AroundMe")
object AroundMe : TopFilter(" Around Me ")
/**
@@ -63,52 +70,64 @@ sealed class TopFilter(
* [Geohash] instead — it is never persisted or dispatched to a feed flow.
*/
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.TeleportPicker")
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Mine")
object Mine : TopFilter(" Mine ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.PeopleList")
class PeopleList(
@Contextual override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.MuteList")
class MuteList(
@Contextual override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Community")
class Community(
@Contextual override val address: Address,
) : TopFilter("Community/${address.toValue()}"),
AddressableTopFilter
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Hashtag")
class Hashtag(
val tag: String,
) : TopFilter("Hashtag/$tag")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Geohash")
class Geohash(
val tag: String,
) : TopFilter("Geohash/$tag")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.Relay")
class Relay(
val url: String,
) : TopFilter("Relay/$url")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.FavoriteAlgoFeed")
class FavoriteAlgoFeed(
@Contextual val address: Address,
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.AllFavoriteAlgoFeeds")
object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
@Serializable
@SerialName("com.vitorpamplona.amethyst.model.TopFilter.InterestSet")
class InterestSet(
@Contextual val address: Address,
) : TopFilter("InterestSet/${address.toValue()}")
@@ -0,0 +1,105 @@
/*
* 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.commons.model.topNavFeeds
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Pins every [TopFilter] subclass to the serial name it had before the class
* moved from `com.vitorpamplona.amethyst.model` to commons. These names are
* the polymorphic `type` discriminator [JsonMapper] writes into per-account
* preferences (LocalPreferences' DEFAULT_*_FOLLOW_LIST keys); if one changes,
* every user's saved tab selections silently reset to defaults on upgrade,
* because the decode failure is swallowed by parseTopFilterOrDefault.
*/
class TopFilterSerialNameTest {
private val oldPrefix = "com.vitorpamplona.amethyst.model.TopFilter"
private val address = Address(30000, "e28ffe100522bc27e01f0c7c1a0e42722f7d5b5a2ba1fb066bde54a2c53e91a5", "follows")
private val expected: Map<String, TopFilter> =
mapOf(
"Global" to TopFilter.Global,
"Selected" to TopFilter.Selected,
"AllFollows" to TopFilter.AllFollows,
"AllUserFollows" to TopFilter.AllUserFollows,
"DefaultFollows" to TopFilter.DefaultFollows,
"AroundMe" to TopFilter.AroundMe,
"TeleportPicker" to TopFilter.TeleportPicker,
"Mine" to TopFilter.Mine,
"PeopleList" to TopFilter.PeopleList(address),
"MuteList" to TopFilter.MuteList(address),
"Community" to TopFilter.Community(address),
"Hashtag" to TopFilter.Hashtag("nostr"),
"Geohash" to TopFilter.Geohash("u4pruyd"),
"Relay" to TopFilter.Relay("wss://relay.example.com"),
"FavoriteAlgoFeed" to TopFilter.FavoriteAlgoFeed(address),
"AllFavoriteAlgoFeeds" to TopFilter.AllFavoriteAlgoFeeds,
"InterestSet" to TopFilter.InterestSet(address),
)
@Test
fun everySubclassKeepsItsPreMoveSerialName() {
expected.forEach { (simpleName, value) ->
val encoded = JsonMapper.jsonInstance.encodeToString(TopFilter.serializer(), value)
val type =
JsonMapper.jsonInstance
.parseToJsonElement(encoded)
.jsonObject["type"]
?.jsonPrimitive
?.content
assertEquals(
"$oldPrefix.$simpleName",
type,
"TopFilter.$simpleName must keep the pre-move serial name or saved prefs reset",
)
}
}
@Test
fun decodesPrefsWrittenBeforeTheMove() {
// Literal JSON as written by the pre-move app: the base class's `code`
// constructor property is a serialized field alongside the discriminator.
val legacyHashtag = """{"type":"$oldPrefix.Hashtag","code":"Hashtag/nostr","tag":"nostr"}"""
val decodedTag = JsonMapper.jsonInstance.decodeFromString(TopFilter.serializer(), legacyHashtag)
assertEquals("Hashtag/nostr", decodedTag.code)
// The address is stored in the same shape main wrote (Address's own fields).
val legacyPeopleList =
"""{"type":"$oldPrefix.PeopleList","code":"${address.toValue()}","address":{"kind":30000,""" +
""""pubKeyHex":"${address.pubKeyHex}","dTag":"follows"}}"""
val decodedList = JsonMapper.jsonInstance.decodeFromString(TopFilter.serializer(), legacyPeopleList)
assertEquals(address.toValue(), decodedList.code)
}
@Test
fun roundTripsEverySubclass() {
expected.forEach { (simpleName, value) ->
val encoded = JsonMapper.jsonInstance.encodeToString(TopFilter.serializer(), value)
val decoded = JsonMapper.jsonInstance.decodeFromString(TopFilter.serializer(), encoded)
assertEquals(value.code, decoded.code, "TopFilter.$simpleName must round-trip")
}
}
}
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.quartz.nip01Core.core
import kotlinx.serialization.Serializable
// @Serializable matches the jvm/android actuals: common code marks Address
// properties @Contextual, and the plugin's compile-time fallback only exists
// on targets whose actual is itself serializable — without it, encoding any
// address-carrying type throws "Serializer for class 'Address' is not found"
// at runtime on native.
@Serializable
actual data class Address actual constructor(
actual val kind: Kind,
actual val pubKeyHex: HexKey,