diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt similarity index 100% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletLaunchRegistry.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilter.kt index 5cbf0c3fa5..b64a43b469 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilter.kt @@ -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()}") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilterSerialNameTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilterSerialNameTest.kt new file mode 100644 index 0000000000..abd6c73694 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/TopFilterSerialNameTest.kt @@ -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 = + 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") + } + } +} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt index 1f5a4c80b4..d8e20aec43 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt @@ -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,