From 0d7f2ce838592dd23a94058907c711def87c2fec Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 11:29:46 -0500 Subject: [PATCH 1/9] Adds support for Trust Provider lists --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/model/AccountSettings.kt | 12 ++ .../amethyst/model/LocalCache.kt | 8 + .../TrustProviderListDecryptionCache.kt | 36 ++++ .../TrustProviderListState.kt | 98 ++++++++++ .../list/TagArrayBuilderExt.kt | 37 ++++ .../trustedAssertions/list/TagArrayExt.kt | 26 +++ .../list/TrustProviderListEvent.kt | 171 ++++++++++++++++++ .../list/tags/ServiceProviderTag.kt | 88 +++++++++ .../quartz/utils/EventFactory.kt | 2 + .../trustedAssertions/ServiceParser.kt | 65 +++++++ 12 files changed, 553 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 38c81a7ee..910991d39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper @@ -110,6 +111,7 @@ private object PrefKeys { const val LATEST_HASHTAG_LIST = "latestHashtagList" const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" + const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later @@ -354,6 +356,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList) putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) + putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) @@ -482,6 +485,7 @@ object LocalPreferences { val latestHashtagList = parseEventOrNull(PrefKeys.LATEST_HASHTAG_LIST) val latestGeohashList = parseEventOrNull(PrefKeys.LATEST_GEOHASH_LIST) val latestEphemeralList = parseEventOrNull(PrefKeys.LATEST_EPHEMERAL_LIST) + val latestTrustProviderList = parseEventOrNull(PrefKeys.LATEST_TRUST_PROVIDER_LIST) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) @@ -525,6 +529,7 @@ object LocalPreferences { backupHashtagList = latestHashtagList, backupGeohashList = latestGeohashList, backupEphemeralChatList = latestEphemeralList, + backupTrustProviderList = latestTrustProviderList, lastReadPerRoute = MutableStateFlow(lastReadPerRoute), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), pendingAttestations = MutableStateFlow(pendingAttestations), 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 e25bc0e08..2b1707920 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -92,6 +92,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState +import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader @@ -283,6 +285,9 @@ class Account( val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) + val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) + val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index c1bcc246e..a654dc436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -138,6 +139,7 @@ class AccountSettings( var backupHashtagList: HashtagListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, + var backupTrustProviderList: TrustProviderListEvent? = null, val lastReadPerRoute: MutableStateFlow>> = MutableStateFlow(mapOf()), var hasDonatedInVersion: MutableStateFlow> = MutableStateFlow(setOf()), val pendingAttestations: MutableStateFlow> = MutableStateFlow>(mapOf()), @@ -437,6 +439,16 @@ class AccountSettings( } } + fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { + if (trustProviderList == null || trustProviderList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupTrustProviderList?.id != trustProviderList.id) { + backupTrustProviderList = trustProviderList + saveAccountSettings() + } + } + fun updateMuteList(newMuteList: MuteListEvent?) { if (newMuteList == null || newMuteList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3a2085460..a8c901eff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -964,6 +965,12 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( + event: TrustProviderListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: ProxyRelayListEvent, relay: NormalizedRelayUrl?, @@ -2848,6 +2855,7 @@ object LocalCache : ILocalCache { is TorrentEvent -> consume(event, relay, wasVerified) is TorrentCommentEvent -> consume(event, relay, wasVerified) is TrustedRelayListEvent -> consume(event, relay, wasVerified) + is TrustProviderListEvent -> consume(event, relay, wasVerified) is VideoHorizontalEvent -> consume(event, relay, wasVerified) is VideoNormalEvent -> consume(event, relay, wasVerified) is VideoVerticalEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt new file mode 100644 index 000000000..6a4321978 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt @@ -0,0 +1,36 @@ +/** + * 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.trustedAssertions + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache + +class TrustProviderListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedServiceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviders() + + suspend fun serviceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviders() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt new file mode 100644 index 000000000..5e97b53fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -0,0 +1,98 @@ +/** + * 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.trustedAssertions + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class TrustProviderListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: TrustProviderListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note it self + val trustProviderListNote = cache.getOrCreateAddressableNote(getTrustProviderListAddress()) + + fun getTrustProviderListAddress() = TrustProviderListEvent.createAddress(signer.pubKey) + + fun getTrustProviderListFlow(): StateFlow = trustProviderListNote.flow().metadata.stateFlow + + fun getTrustProviderList(): TrustProviderListEvent? = trustProviderListNote.event as? TrustProviderListEvent + + suspend fun trustProviderListWithBackup(note: Note): Set { + val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList + return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveTrustProviderList: StateFlow> = + getTrustProviderListFlow() + .transformLatest { noteState -> + emit(trustProviderListWithBackup(noteState.note)) + }.onStart { + emit(trustProviderListWithBackup(trustProviderListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + init { + settings.backupTrustProviderList?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "TrustProviderList Collector Start") + getTrustProviderListFlow().collect { noteState -> + Log.d("AccountRegisterObservers", "TrustProviderList List for ${signer.pubKey}") + (noteState.note.event as? TrustProviderListEvent)?.let { + settings.updateTrustProviderListTo(it) + } + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt new file mode 100644 index 000000000..1442fb5fa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/** + * 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.quartz.experimental.trustedAssertions.list + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceType +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun TagArrayBuilder.serviceProvider( + service: ServiceType, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl, +) = addUnique(ServiceProviderTag.assemble(service, pubkey, relayUrl)) + +fun TagArrayBuilder.serviceProvider(provider: ServiceProviderTag) = addUnique(provider.toTagArray()) + +fun TagArrayBuilder.serviceProviders(providers: List) = addAll(providers.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt new file mode 100644 index 000000000..5f44f5ea4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -0,0 +1,26 @@ +/** + * 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.quartz.experimental.trustedAssertions.list + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.serviceProviders() = mapNotNull(ServiceProviderTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt new file mode 100644 index 000000000..50a6278b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TrustProviderListEvent.kt @@ -0,0 +1,171 @@ +/** + * 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.quartz.experimental.trustedAssertions.list + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.removeParsing +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TrustProviderListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun serviceProviders() = tags.serviceProviders() + + companion object { + const val KIND = 10040 + const val ALT = "Trusted Service Providers" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + provider: ServiceProviderTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent = + if (isPrivate) { + create( + publicProviders = emptyList(), + privateProviders = listOf(provider), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicProviders = listOf(provider), + privateProviders = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: TrustProviderListEvent, + provider: ServiceProviderTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(provider.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(provider.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: TrustProviderListEvent, + provider: ServiceProviderTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.removeParsing(ServiceProviderTag::parse, provider), + tags = earlierVersion.tags.removeParsing(ServiceProviderTag::parse, provider), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): TrustProviderListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicProviders: List = emptyList(), + privateProviders: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = signer.sign(build(publicProviders, privateProviders, signer, createdAt)) + + suspend fun build( + publicProviders: List = emptyList(), + privateProviders: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateProviders.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + serviceProviders(publicProviders) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt new file mode 100644 index 000000000..f83bdec73 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/tags/ServiceProviderTag.kt @@ -0,0 +1,88 @@ +/** + * 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.quartz.experimental.trustedAssertions.list.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +data class ServiceProviderTag( + val service: ServiceType, + val pubkey: HexKey, + val relayUrl: NormalizedRelayUrl, +) { + fun toTagArray() = assemble(service, pubkey, relayUrl) + + companion object { + fun parse(tag: Array): ServiceProviderTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0].isNotEmpty()) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val service = ServiceType.parse(tag[0]) ?: return null + val relay = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + + return ServiceProviderTag(service, tag[1], relay) + } + + fun assemble( + serviceType: String, + pubkey: HexKey, + relayUrl: String, + ) = arrayOf(serviceType, pubkey, relayUrl) + + fun assemble( + service: ServiceType, + pubkey: HexKey, + relayUrl: NormalizedRelayUrl, + ) = assemble(service.toValue(), pubkey, relayUrl.url) + + fun assemble(id: ServiceProviderTag) = arrayOf(id.service, id.pubkey, id.relayUrl) + } +} + +data class ServiceType( + val kind: Int, + val type: String, +) { + fun toValue() = assemble(kind, type) + + companion object { + fun assemble( + kind: Int, + type: String, + ) = "$kind:$type" + + fun parse(serviceType: String): ServiceType? { + val (kindStr, type) = serviceType.split(":", limit = 2) + val kind = kindStr.toIntOrNull() ?: return null + return ServiceType(kind, type) + } + + fun isOfKind( + serviceType: String, + kind: String, + ) = serviceType.startsWith(kind) && serviceType[kind.length] == ':' + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 5b6fee5a8..08ba4bd55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -292,6 +293,7 @@ class EventFactory { TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + TrustProviderListEvent.KIND -> TrustProviderListEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt new file mode 100644 index 000000000..f7f27c015 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt @@ -0,0 +1,65 @@ +/** + * 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.quartz.experimental.trustedAssertions + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals + +class ServiceParser { + val brainstorm = + TrustProviderListEvent( + id = "d74ce2e62a152e787001f81b51f7f18b69816cbafc064e38af99f1ee55895424", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1762635473, + tags = + arrayOf( + arrayOf("30382:rank", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:followers", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_influence", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_average", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_confidence", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedGrapeRank_input", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:personalizedPageRank", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedFollowersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedMutersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:verifiedReportersCount", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + arrayOf("30382:hops", "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", "wss://nip85.brainstorm.world"), + ), + content = "", + sig = "8947d7b83b9483d241aba1b3af09c5b95b4b053d3c9e4832e1b39eb805ba33a156ea62150e99416a38ff07026f8f99f6a0bbdedb928413ba404c8b9631415c21", + ) + + @Test() + fun parseService() { + assertEquals( + ServiceProviderTag( + ServiceType(30382, "rank"), + "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", + RelayUrlNormalizer.normalize("wss://nip85.brainstorm.world"), + ), + brainstorm.services()[0], + ) + } +} From 2bff99834002e966ff32ac95426de69952bab9ae Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 15:18:31 -0500 Subject: [PATCH 2/9] Moves from List to Set of Trust Provider --- .../trustedAssertions/TrustProviderListDecryptionCache.kt | 5 +++++ .../model/trustedAssertions/TrustProviderListState.kt | 2 +- .../experimental/trustedAssertions/list/TagArrayExt.kt | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt index 6a4321978..c35dfbaac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListDecryptionCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.trustedAssertions import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviderSet import com.vitorpamplona.quartz.experimental.trustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache @@ -32,5 +33,9 @@ class TrustProviderListDecryptionCache( fun cachedServiceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviders() + fun cachedServiceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagListPrecached(event).serviceProviderSet() + suspend fun serviceProviders(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviders() + + suspend fun serviceProviderSet(event: TrustProviderListEvent) = cachedPrivateLists.mergeTagList(event).serviceProviderSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt index 5e97b53fe..ec79f5a4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -59,7 +59,7 @@ class TrustProviderListState( suspend fun trustProviderListWithBackup(note: Note): Set { val event = note.event as? TrustProviderListEvent ?: settings.backupTrustProviderList - return event?.let { decryptionCache.roomSet(it) } ?: emptySet() + return event?.let { decryptionCache.serviceProviderSet(it) } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt index 5f44f5ea4..de6eec972 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/list/TagArrayExt.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.quartz.experimental.trustedAssertions.list +import com.vitorpamplona.quartz.experimental.ephemChat.list.tags.RoomIdTag.Companion.parse import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip01Core.core.TagArray fun TagArray.serviceProviders() = mapNotNull(ServiceProviderTag::parse) + +fun TagArray.serviceProviderSet() = mapNotNullTo(mutableSetOf(), ServiceProviderTag::parse) From c5fae65f2dd2146adc8b914c51017fa753f1624c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 15:31:02 -0500 Subject: [PATCH 3/9] Moves temporaryAccount handler to lazy loading --- .../service/relayClient/authCommand/model/AuthCoordinator.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 53edb8d97..8f3d02e32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -37,7 +37,9 @@ class AuthCoordinator( scope: CoroutineScope, ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } - private val tempAccount = NostrSignerSync() + private val tempAccount by lazy { + NostrSignerSync() + } val receiver = RelayAuthenticator( From 1f3bd7614eb01e4bcd7618035bcd7ecfc5baca0e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:37:48 -0500 Subject: [PATCH 4/9] Moves account loading to the IO thread --- .../ui/screen/AccountStateViewModel.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index abc12cdbc..c96851b95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -65,7 +65,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -85,17 +84,14 @@ class AccountStateViewModel : ViewModel() { fun loginWithDefaultAccountIfLoggedOff() { // pulls account from storage. if (_accountContent.value !is AccountState.LoggedIn) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { loginWithDefaultAccount() } } } private suspend fun loginWithDefaultAccount(route: Route? = null) { - val accountSettings = - withContext(Dispatchers.IO) { - LocalPreferences.loadAccountConfigFromEncryptedStorage() - } + val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage() if (accountSettings != null) { startUI(accountSettings, route) @@ -135,7 +131,7 @@ class AccountStateViewModel : ViewModel() { throw Exception("Invalid key while trying to login with external signer") } - val account = + val accountSettings = if (loginWithExternalSigner) { AccountSettings( keyPair = KeyPair(pubKey = pubKeyParsed), @@ -164,18 +160,18 @@ class AccountStateViewModel : ViewModel() { ) } - LocalPreferences.setDefaultAccount(account) + LocalPreferences.setDefaultAccount(accountSettings) - startUI(account) + startUI(accountSettings) } - @OptIn(FlowPreview::class) - suspend fun startUI( + fun startUI( accountSettings: AccountSettings, route: Route? = null, - ) = withContext(Dispatchers.Main) { + ) { + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) _accountContent.update { - AccountState.LoggedIn(Amethyst.instance.accountsCache.loadAccount(accountSettings), route) + AccountState.LoggedIn(account, route) } } From 50f91e8b911962ce72888febdb4aba8c47ae4eb1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:37:56 -0500 Subject: [PATCH 5/9] Updates dependencies --- gradle/libs.versions.toml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5313db7e..ff80d0e70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanistAdaptive = "0.37.3" -activityCompose = "1.11.0" +activityCompose = "1.12.0" agp = "8.13.1" android-compileSdk = "36" android-minSdk = "26" @@ -13,11 +13,11 @@ benchmark = "1.4.1" benchmarkJunit4 = "1.4.1" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.11.00" +composeBom = "2025.11.01" coreKtx = "1.17.0" -datastore = "1.1.7" +datastore = "1.2.0" espressoCore = "3.7.0" -firebaseBom = "34.5.0" +firebaseBom = "34.6.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" @@ -32,35 +32,35 @@ kotlinxSerializationPlugin = "2.2.21" languageId = "17.0.6" lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" -lifecycleRuntimeKtx = "2.9.4" +lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" media3 = "1.8.0" mockk = "1.14.6" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" -okhttp = "5.3.0" +okhttp = "5.3.2" runner = "1.7.0" rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.21.0" securityCryptoKtx = "1.1.0" -spotless = "8.0.0" +spotless = "8.1.0" torAndroid = "0.4.8.19" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.3.4" +vico-charts = "2.3.6" zelory = "3.0.1" zoomable = "2.9.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" -windowCoreAndroid = "1.5.0" +windowCoreAndroid = "1.5.1" androidxCamera = "1.5.1" androidxCollection = "1.5.0" -kotlinStdlib = "2.2.20" +kotlinStdlib = "2.2.21" kotlinTest = "2.2.20" core = "1.7.0" -mavenPublish = "0.34.0" +mavenPublish = "0.35.0" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } From ad879fcf6337c3e3e25bcd2525fba2a658868471 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:42:07 -0500 Subject: [PATCH 6/9] Fix test --- .../quartz/experimental/trustedAssertions/ServiceParser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt index f7f27c015..506ce14ff 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedAssertions/ServiceParser.kt @@ -59,7 +59,7 @@ class ServiceParser { "a49e1bdd16cb0d720cd04c4c4cd9e04c21ea2292121fbbe99f03f4a6c1fd2a98", RelayUrlNormalizer.normalize("wss://nip85.brainstorm.world"), ), - brainstorm.services()[0], + brainstorm.serviceProviders()[0], ) } } From 25094be07ced1cacfcb43307d4b2fae7a8b6d75e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:43:32 -0500 Subject: [PATCH 7/9] Puts flow creation inside a remember --- .../amethyst/ui/note/RelayListBox.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt index 881675ffe..6fe9311f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayListBox.kt @@ -123,12 +123,17 @@ fun WatchAndRenderRelay( accountViewModel: AccountViewModel, nav: INav, ) { - val relay by baseNote - .flow() - .relays.stateFlow - .mapNotNull { - it.note.relays.getOrNull(relayIndex) - }.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) + val flow = + remember(baseNote, relayIndex) { + baseNote + .flow() + .relays.stateFlow + .mapNotNull { + it.note.relays.getOrNull(relayIndex) + } + } + + val relay by flow.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex)) CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) { if (it != null) { From 382de9ba03f11743cd53a7d74d3a5024388c0ebc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 16:51:21 -0500 Subject: [PATCH 8/9] Fixing new remember rules --- .../amethyst/ui/note/types/Highlight.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index d6b55afac..f20b58ed8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -113,7 +112,7 @@ fun DisplayHighlightPreview() { makeItShort = false, canPreview = true, quotesLeft = 3, - backgroundColor = mutableStateOf(Color.White), + backgroundColor = remember { mutableStateOf(Color.White) }, accountViewModel = mockAccountViewModel(), nav = EmptyNav(), ) @@ -137,7 +136,7 @@ fun DisplayHighlightPreviewNewLine() { makeItShort = false, canPreview = true, quotesLeft = 3, - backgroundColor = mutableStateOf(Color.White), + backgroundColor = remember { mutableStateOf(Color.White) }, accountViewModel = mockAccountViewModel(), nav = EmptyNav(), ) @@ -239,7 +238,7 @@ private fun DisplayQuoteAuthor( accountViewModel: AccountViewModel, nav: INav, ) { - var userBase by remember { mutableStateOf(authorHex?.let { accountViewModel.getUserIfExists(it) }) } + var userBase by remember { mutableStateOf(authorHex?.let { accountViewModel.getUserIfExists(it) }) } if (userBase == null && authorHex != null) { LaunchedEffect(authorHex) { @@ -248,7 +247,7 @@ private fun DisplayQuoteAuthor( } var addressable by remember { - mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it) }) + mutableStateOf(postAddress?.let { accountViewModel.getAddressableNoteIfExists(it) }) } if (addressable == null && postAddress != null) { @@ -264,7 +263,7 @@ private fun DisplayQuoteAuthor( } var version by remember { - mutableStateOf(postVersion?.let { accountViewModel.getNoteIfExists(it.eventId) }) + mutableStateOf(postVersion?.let { accountViewModel.getNoteIfExists(it.eventId) }) } if (version == null && postVersion != null) { @@ -324,7 +323,7 @@ fun DisplayEntryForNote( ) { val noteState by observeNote(note, accountViewModel) - val author = userBase ?: noteState?.note?.author + val author = userBase ?: noteState.note.author if (author != null) { RenderUserAsClickableText(author, null, accountViewModel, nav) @@ -361,7 +360,7 @@ fun DisplayEntryForAUrl( remember { try { URL(url) - } catch (e: Exception) { + } catch (_: Exception) { Log.w("Note Compose", "Invalid URI: $url") null } From d12a8ba1c1e964ce79e2d9a5f96a96b1d776689b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 21 Nov 2025 17:16:08 -0500 Subject: [PATCH 9/9] Revert runTest because the wait to get results back fails in the function --- .../relay/client/accessories/NostrClientSendAndWaitExt.kt | 2 +- .../quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index a13807031..c83062890 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -103,7 +103,7 @@ suspend fun INostrClient.sendAndWaitForResponse( val currentResult = receivedResults[result.relay] // do not override a successful result. if (currentResult == null || !currentResult) { - receivedResults.put(result.relay, result.success) + receivedResults[result.relay] = result.success } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index 479e8658c..b9966c105 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -30,14 +30,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals class NostrClientSendAndWaitTest : BaseNostrClientTest() { @Test fun testSendAndWaitForResponse() = - runTest { + runBlocking { val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -61,6 +61,6 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { appScope.cancel() assertEquals(true, resultDamus) - assertEquals(false, resultNos) + assertEquals(true, resultNos) } }