diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0abbc5f37d..bb04ed0b55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.defaults.Constants -import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars @@ -384,12 +383,16 @@ class Account( // doubles as the attribution pubkey for ExplainedFilter.accountPubKeys. override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex - override fun indexRelays(): Set = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList } + // No ifEmpty here on purpose: an empty kind:10086 is the user asking for no indexers, and + // IndexerRelayListState already substitutes the defaults for the only case we may override — + // never having seen the event. Re-substituting here would undo that choice. + override fun indexRelays(): Set = indexerRelayList.flow.value override fun outboxHomeRelays(): Set = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value - // searchRelayList.flow already applies the DefaultSearchRelayList fallback internally - // (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here. + // searchRelayList.flow applies DefaultSearchRelayList internally when no kind:10007 has ever + // been seen (SearchRelayListState.normalizeSearchRelayListWithBackup); an empty published list + // stays empty. No ifEmpty here either way. override fun searchRelays(): Set = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet() override fun searchOnlyRelays(): Set = searchRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 1491bee591..050240d188 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -58,7 +58,11 @@ class IndexerRelayListState( fun indexListEvent(note: Note) = note.event as? IndexerRelayListEvent ?: settings.backupIndexRelayList - suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList + suspend fun normalizeIndexerRelayListWithBackup(note: Note): Set { + val event = indexListEvent(note) ?: return DefaultIndexerRelayList + // Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet". + return decryptionCache.relays(event) + } suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() @@ -74,11 +78,11 @@ class IndexerRelayListState( fun normalizeIndexerRelayListPrecached(note: Note): Set = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList /** - * The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup] - * substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the - * one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read - * this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff - * what the user actually configured. + * The account's indexer relays. [normalizeIndexerRelayListWithBackup] substitutes + * [DefaultIndexerRelayList] when there is no kind:10086 at all — but **not** when the one we + * have decodes to zero relays, which is the user saying "no indexers" and is honored. Callers + * assembling metadata / relay-list REQs must therefore tolerate an empty set; use + * [flowNoDefaults] to show or diff what the user actually configured. * * Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same * reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index eb3714dc5c..ddffacd0bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -58,7 +58,11 @@ class SearchRelayListState( fun searchListEvent(note: Note) = note.event as? SearchRelayListEvent ?: settings.backupSearchRelayList - suspend fun normalizeSearchRelayListWithBackup(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList + suspend fun normalizeSearchRelayListWithBackup(note: Note): Set { + val event = searchListEvent(note) ?: return DefaultSearchRelayList + // Fully decrypted here, so empty means the user listed nothing — not "not decrypted yet". + return decryptionCache.relays(event) + } suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() @@ -75,15 +79,16 @@ class SearchRelayListState( fun normalizeSearchRelayListPrecached(note: Note): Set = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList /** - * The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup] - * substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the - * one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can + * The account's search relays. [normalizeSearchRelayListWithBackup] substitutes + * [DefaultSearchRelayList] when there is no kind:10007 at all — but **not** when the one we + * have decodes to zero relays, which is the user saying "no search relays" and is honored. + * Callers assembling NIP-50 REQs must tolerate an empty set, and can * rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the * user actually configured. * * Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means * the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed - * left a window where `.value` contradicted the "never empty" contract above and search + * left a window where `.value` reported nothing before the event had been read at all, so search * silently queried nothing. That window is unbounded for a NIP-46 signer whose list has * private entries, since the first emission waits on a remote decrypt. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt index fec41fa053..1440fd2ab0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.nip65RelayList import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.defaults.relayListOrDefaultsWhenUnknown import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -58,9 +59,9 @@ class Nip65RelayListState( fun nip65Event(note: Note) = note.event as? AdvertisedRelayListEvent ?: settings.backupNIP65RelayList - fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: Constants.eventFinderRelays + fun normalizeNIP65WriteRelayListWithBackup(note: Note): Set = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.eventFinderRelays) { it.writeRelaysNorm()?.toSet() } - fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set = nip65Event(note)?.readRelaysNorm()?.toSet() ?: Constants.bootstrapInbox + fun normalizeNIP65ReadRelayListWithBackup(note: Note): Set = relayListOrDefaultsWhenUnknown(nip65Event(note), Constants.bootstrapInbox) { it.readRelaysNorm()?.toSet() } fun normalizeNIP65WriteRelayListNoDefaults(note: Note): Set = nip65Event(note)?.writeRelaysNorm()?.toSet() ?: emptySet() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaults.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaults.kt new file mode 100644 index 0000000000..5bb8f47eb1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaults.kt @@ -0,0 +1,52 @@ +/* + * 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.defaults + +/** + * Substitute app defaults only when we have **never seen** the user's list — never when they + * published an empty one. + * + * There are three states, and collapsing the last two is how the app ends up overriding an explicit + * choice: + * + * | we have | effective list | + * |---|---| + * | no event | [defaults] — we do not know what they want | + * | an event, empty list | **empty** — they told us: nothing | + * | an event with relays | those relays | + * + * Written as one named primitive because the rule was open-coded at four call sites and three of + * them got it wrong the same way: `event?.relays()?.ifEmpty { null } ?: DEFAULTS` reads naturally + * but folds "published nothing" into "published nothing we know of", so a kind:10002 carrying only + * write relays silently acquired a default *inbox* list. + * + * [read] may return null — several event accessors end in `.ifEmpty { null }` — and null from a + * present event means the same thing as an empty set: the user listed nothing. + * + * **Not for partially-resolved sources.** A reader that can only see *already decrypted* private + * tags returns empty both for "the user listed nothing" and for "we have not decrypted it yet", + * which this cannot distinguish; those callers legitimately want defaults until the decrypt lands. + */ +inline fun relayListOrDefaultsWhenUnknown( + event: E?, + defaults: Set, + read: (E) -> Set?, +): Set = if (event == null) defaults else read(event) ?: emptySet() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaultsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaultsTest.kt new file mode 100644 index 0000000000..0cde1ddaee --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/defaults/RelayListDefaultsTest.kt @@ -0,0 +1,69 @@ +/* + * 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.defaults + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RelayListDefaultsTest { + private val defaults = setOf("wss://default.one", "wss://default.two") + + /** No event: we genuinely do not know what the user wants, so the app's defaults stand in. */ + @Test + fun `absent event yields the defaults`() { + assertEquals(defaults, relayListOrDefaultsWhenUnknown(null, defaults) { setOf("wss://ignored") }) + } + + /** + * The case every open-coded copy of this rule got wrong: a published-but-empty list is the user + * saying "nothing", and must not acquire the defaults. + */ + @Test + fun `present event with an empty list stays empty`() { + assertEquals(emptySet(), relayListOrDefaultsWhenUnknown("event", defaults) { emptySet() }) + } + + /** + * Same, via null: several event accessors end in `.ifEmpty { null }`, so null from a *present* + * event means the user listed nothing — not that the event is missing. + */ + @Test + fun `present event whose reader returns null stays empty`() { + assertEquals(emptySet(), relayListOrDefaultsWhenUnknown("event", defaults) { null }) + } + + @Test + fun `present event with relays yields those relays`() { + val mine = setOf("wss://mine.example") + assertEquals(mine, relayListOrDefaultsWhenUnknown("event", defaults) { mine }) + } + + /** The reader must not even be consulted when there is no event to read. */ + @Test + fun `reader is not invoked for an absent event`() { + var invoked = false + relayListOrDefaultsWhenUnknown(null, defaults) { + invoked = true + emptySet() + } + assertEquals(false, invoked) + } +}