From e45b4b11dca781cddb11302aa66130b7c00ea07d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 18 Oct 2025 18:10:45 -0400 Subject: [PATCH] Restructuring Relay systems to: - Maintain order of incoming messages for relay listeners - Defers all processing of incoming messages to coroutines via channels, freeing OkHttp's thread as soon as possible. - Simplifies the main relay class by using attached listener modules for each function of the relay client. - Migrate defaultOnConnect calls to become listener based and moved to NostrClients - Treat counts as query only, not subscriptions. - Coordinates REQs so that if an update is required to be sent but the server has not finished processing events, waits for it to finish and sends it later as soon as EOSE or Close arrives - Correctly sustain the local state of each Req. - Creates an Account follow list per Relay state that only includes shared relays as a better source of functioning relays - Changes UserLoading features in a tentative to make them faster since they are used by all functions in the app. - Correctly marks EOSE for filters that are aligned with the Req State from NostrClient - Avoid subsequent REQ updates before EOSE or CLOSE calls. - Refactoring RelayClient listener to be not dependent of which module is active for a relay client. - Refactors authenticators to do complete operation as a module - Breaks down Relay Client modules (Auth, Reqs, Counts, Event submissions) in the Relay Pool class. - Creates listeners just for special REQ situations - Move statistics to outside the base relay class as a listener - Move logs to outside the base relay class as a listener - Better structures a Standalone Relay client - More appropriately communicate errors to the listeners - Remove relay state on listeners --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../vitorpamplona/amethyst/model/Account.kt | 15 +- .../amethyst/model/LocalCache.kt | 5 +- .../FollowListReusedOutboxOrProxyRelays.kt | 135 +++++++ .../playerPool/CustomMediaSourceFactory.kt | 3 +- .../authCommand/model/AuthCoordinator.kt | 35 +- .../eoseManagers/BaseEoseManager.kt | 5 +- .../eoseManagers/PerUniqueIdEoseManager.kt | 29 +- .../PerUserAndFollowListEoseManager.kt | 39 +- .../eoseManagers/PerUserEoseManager.kt | 41 ++- .../eoseManagers/SingleSubEoseManager.kt | 39 +- .../SingleSubNoEoseCacheEoseManager.kt | 34 +- .../RelaySubscriptionsCoordinator.kt | 6 +- .../account/AccountFilterAssembler.kt | 11 + .../AccountFollowsLoaderSubAssembler.kt | 248 +++++++++++++ .../follows/FilterFindFollowMetadataForKey.kt | 187 ++++++++++ .../watchers/EventWatcherSubAssembler.kt | 4 +- .../user/UserFinderFilterAssembler.kt | 9 +- .../user/loaders/FilterUserMetadataForKey.kt | 52 +-- .../user/loaders/UserLoaderSubAssembler.kt | 73 ---- .../loaders/UserOutboxFinderSubAssembler.kt | 125 +++++++ .../user/watchers/FilterUserMetadataForKey.kt | 81 +++-- .../user/watchers/UserReportsSubAssembler.kt | 4 +- .../user/watchers/UserWatcherSubAssembler.kt | 131 ++++--- .../subassemblies/FilterByAuthor.kt | 37 +- .../speedLogger/RelaySpeedLogger.kt | 16 +- .../vitorpamplona/amethyst/ui/MainActivity.kt | 1 + .../ui/screen/loggedIn/AccountViewModel.kt | 51 +-- .../nip01Core/relay/client/INostrClient.kt | 31 +- .../nip01Core/relay/client/NostrClient.kt | 332 ++++++------------ .../relay/client/NostrClientSubscription.kt | 21 +- .../client/accessories/EventCollector.kt | 14 +- .../accessories/NostrClientSendAndWaitExt.kt | 38 +- .../NostrClientSingleDownloadExt.kt | 22 +- .../client/accessories/RelayAuthenticator.kt | 57 --- .../RelayInsertConfirmationCollector.kt | 13 +- .../relay/client/accessories/RelayLogger.kt | 75 +++- .../relay/client/accessories/RelayNotifier.kt | 14 +- .../relay/client/auth/RelayAuthStatus.kt | 80 +++++ .../relay/client/auth/RelayAuthenticator.kt | 111 ++++++ .../relay/client/counts/CountQueryState.kt | 69 ++++ .../relay/client/counts/CountQueryStatus.kt | 27 ++ .../client/counts/RelayActiveCountStates.kt | 86 +++++ .../client/listeners/IRelayClientListener.kt | 111 ++---- .../listeners/RedirectRelayClientListener.kt | 87 ++--- .../relay/client/pool/FiltersChanged.kt | 83 +++++ .../nip01Core/relay/client/pool/PoolCounts.kt | 216 ++++++++++++ .../relay/client/pool/PoolEventOutbox.kt | 159 ++++++--- .../client/pool/PoolEventOutboxRepository.kt | 99 ------ .../relay/client/pool/PoolEventOutboxState.kt | 92 +++++ .../relay/client/pool/PoolRequests.kt | 278 +++++++++++++++ .../client/pool/PoolSubscriptionRepository.kt | 84 ----- .../nip01Core/relay/client/pool/RelayPool.kt | 171 +++------ .../IRequestListener.kt} | 51 +-- .../client/reqs/RelayActiveRequestStates.kt | 88 +++++ .../relay/client/reqs/ReqSubStatus.kt | 28 ++ .../client/reqs/RequestSubscriptionState.kt | 77 ++++ .../relay/client/reqs/stats/RelayReqStats.kt | 42 +-- .../stats/ReqStatsRepository.kt} | 12 +- .../relay/client/single/IRelayClient.kt | 24 +- .../client/single/basic/BasicRelayClient.kt | 321 +++-------------- .../relay/client/single/simple/OutboxCache.kt | 90 ----- .../standalone/StandaloneRelayClient.kt | 136 +++++++ .../nip01Core/relay/client/stats/RelayStat.kt | 1 + .../relay/client/stats/RelayStats.kt | 108 ++++-- .../client/subscriptions/Subscription.kt | 18 +- .../subscriptions/SubscriptionController.kt | 60 +--- .../relay/commands/toRelay/CountCmd.kt | 4 +- .../commands/toRelay/CommandDeserializer.kt | 4 +- .../commands/toRelay/CommandSerializer.kt | 2 +- .../relay/NostrClientManualSubTest.kt | 33 +- .../relay/NostrClientRepeatSubTest.kt | 72 ++-- 72 files changed, 3211 insertions(+), 1753 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/{single/simple/SimpleRelayClient.kt => reqs/IRequestListener.kt} (55%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt (68%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/{subscriptions/SubscriptionStats.kt => reqs/stats/ReqStatsRepository.kt} (84%) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index fde96059e..241ffd9a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -25,7 +25,6 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier @@ -60,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache @@ -199,10 +200,12 @@ class AppModules( val relayStats = RelayStats(client) + val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null + val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null // Coordinates all subscriptions for the Nostr Client - val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationDefaultScope) + val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, authCoordinator.receiver, applicationDefaultScope) // keeps all accounts live val accountsCache = 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 f888022cd..851b58367 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayStat import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays +import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListReusedOutboxOrProxyRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState @@ -126,7 +127,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -309,6 +309,10 @@ class Account( // Follows Relays val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) + + // only follow relays that are declared in more than one user. + val followSharedOutboxesOrProxy = FollowListReusedOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) + val followPlusAllMineWithIndex = MergedFollowPlusMineWithIndexRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, scope) val followPlusAllMineWithSearch = MergedFollowPlusMineWithSearchRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, searchRelayList, scope) val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope) @@ -1547,13 +1551,10 @@ class Account( } } - suspend fun sendAuthEvent( - relay: IRelayClient, + suspend fun createAuthEvent( + relay: NormalizedRelayUrl, challenge: String, - ) { - val auth = RelayAuthEvent.create(relay.url, challenge, signer) - client.sendIfExists(auth, relay.url) - } + ): RelayAuthEvent = RelayAuthEvent.create(relay, challenge, signer) suspend fun hideWord(word: String) { sendMyPublicAndPrivateOutbox(muteList.hideWord(word)) 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 2aa6ecf86..13714ae39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -2638,7 +2639,7 @@ object LocalCache : ILocalCache { if (isDebug) { Log.d("LocalCache", "Updating ${relay.url.url} with a Deletion Event ${event.id} ${deletionEvent.id} because of ${event.toJson()} with ${deletionEvent.toJson()}") } - relay.send(deletionEvent) + relay.sendIfConnected(EventCmd(deletionEvent)) note.addRelay(relay.url) } } @@ -2656,7 +2657,7 @@ object LocalCache : ILocalCache { Log.d("LocalCache", "Updating ${relay.url.url} with a new version of ${event.kind} ${event.id} to ${existingEvent.id}") } - relay.send(existingEvent) + relay.sendIfConnected(EventCmd(existingEvent)) // only send once. note.addRelay(relay.url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt new file mode 100644 index 000000000..78d5beec0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListReusedOutboxOrProxyRelays.kt @@ -0,0 +1,135 @@ +/** + * 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.nip02FollowLists + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState +import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +class FollowListReusedOutboxOrProxyRelays( + kind3Follows: FollowListState, + blockedRelayList: BlockedRelayListState, + proxyRelayList: ProxyRelayListState, + val cache: LocalCache, + scope: CoroutineScope, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val outboxRelayFlow: StateFlow> = + kind3Follows.flow + .transformLatest { follows -> + emitAll( + OutboxRelayLoader(true).toAuthorsPerRelayFlow(follows.authors, cache) { authorsPerRelay -> + authorsPerRelay + .mapNotNull { + if (it.value.size > 1) { + it.key + } else { + null + } + }.toSet() + }, + ) + }.onStart { + emit( + OutboxRelayLoader(true).authorsPerRelaySnapshot(kind3Follows.flow.value.authors, cache) { authorsPerRelay -> + authorsPerRelay + .mapNotNull { + if (it.value.size > 1) { + it.key + } else { + null + } + }.toSet() + }, + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val outboxRelayMinusBlockedFlow: StateFlow> = + combine(outboxRelayFlow, blockedRelayList.flow) { followList, blockedRelays -> + followList.minus(blockedRelays) + }.onStart { + emit(outboxRelayFlow.value.minus(blockedRelayList.flow.value.toSet())) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + proxyRelayList.flow + .flatMapLatest { proxyRelays -> + if (proxyRelays.isEmpty()) { + outboxRelayMinusBlockedFlow + } else { + MutableStateFlow(proxyRelays) + } + }.onStart { + emit( + proxyRelayList.flow.value.ifEmpty { + outboxRelayMinusBlockedFlow.value + }, + ) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowSet: StateFlow> = + flow + .map { relayList -> + relayList.mapTo(mutableSetOf()) { it.url } + }.onStart { + emit(flow.value.mapTo(mutableSetOf()) { it.url }) + }.flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index 3051ab552..a57ce9dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -40,8 +40,7 @@ class CustomMediaSourceFactory( ) : MediaSource.Factory { private var cachingFactory: MediaSource.Factory = DefaultMediaSourceFactory( - Amethyst.Companion.instance.videoCache - .get(okHttpClient), + Amethyst.instance.videoCache.get(okHttpClient), ) private var nonCachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient)) 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 8cbeef972..53edb8d97 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 @@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -36,15 +37,35 @@ class AuthCoordinator( scope: CoroutineScope, ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } + private val tempAccount = NostrSignerSync() val receiver = - RelayAuthenticator(client, scope) { challenge, relay -> - authWithAccounts.distinct().forEach { - if (it.isWriteable()) { - it.sendAuthEvent(relay, challenge) + RelayAuthenticator( + client, + scope, + signWithAllLoggedInUsers = { authTemplate -> + val results = + authWithAccounts.distinct().mapNotNull { + if (it.signer.isWriteable()) { + try { + it.signer.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + null + } + } else { + null + } + } + + // Always auth, even with random keys + if (!results.isEmpty()) { + results + } else { + listOf(tempAccount.sign(authTemplate)) } - } - } + }, + ) fun destroy() { receiver.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt index 21c789a93..d59b82205 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -50,7 +50,7 @@ abstract class BaseEoseManager( fun getSubscription(subId: String) = orchestrator.getSub(subId) - fun requestNewSubscription(onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null) = orchestrator.requestNewSubscription(newSubscriptionId(), onEOSE) + fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubscriptionId(), listener) fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) @@ -68,7 +68,6 @@ abstract class BaseEoseManager( override fun destroy() { bundler.cancel() - orchestrator.destroy() if (isDebug) { Log.d(logTag, "Destroy, Unsubscribe") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 70be7984b..4c14a918d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -22,11 +22,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.service.relays.EOSEByKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * This query type creates a new relay subscription for every SubID.id() @@ -53,6 +57,7 @@ abstract class PerUniqueIdEoseManager( key: T, relayUrl: NormalizedRelayUrl, time: Long, + filters: List? = null, ) { latestEOSEs.newEose(id(key), relayUrl, time) if (invalidateAfterEose) { @@ -61,9 +66,27 @@ abstract class PerUniqueIdEoseManager( } open fun newSub(key: T): Subscription = - requestNewSubscription { time, relayUrl -> - newEose(key, relayUrl, time) - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: U, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index e696f351f..ff2b8411a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -23,11 +23,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * This query type creates a new relay subscription for every logged-in @@ -54,15 +58,36 @@ abstract class PerUserAndFollowListEoseManager( key: T, relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), list(key), relay, time) + filters: List? = null, + ) { + latestEOSEs.newEose(user(key), list(key), relay, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } open fun newSub(key: T): Subscription = - requestNewSubscription { time, relayUrl -> - newEose(key, relayUrl, time) - if (invalidateAfterEose) { - invalidateFilters() - } - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index 4cc4438c3..3b5583f38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -23,11 +23,15 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * This query type creates a new relay subscription for every distinct @@ -48,19 +52,40 @@ abstract class PerUserEoseManager( fun since(key: T) = latestEOSEs.since(user(key)) - fun newEose( + open fun newEose( key: T, relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(user(key), relay, time) + filters: List? = null, + ) { + latestEOSEs.newEose(user(key), relay, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } open fun newSub(key: T): Subscription = - requestNewSubscription { time, relayUrl -> - newEose(key, relayUrl, time) - if (invalidateAfterEose) { - invalidateFilters() - } - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) open fun endSub( key: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt index d633d0d53..795af8564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubEoseManager.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.service.relays.EOSERelayList import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * This query type creates only ONE relay subscription. It filters duplicates @@ -52,15 +56,36 @@ abstract class SingleSubEoseManager( open fun newEose( relay: NormalizedRelayUrl, time: Long, - ) = latestEOSEs.newEose(relay, time) + filters: List? = null, + ) { + latestEOSEs.newEose(relay, time) + if (invalidateAfterEose) { + invalidateFilters() + } + } val sub = - requestNewSubscription { time, relayUrl -> - newEose(relayUrl, time) - if (invalidateAfterEose) { - invalidateFilters() - } - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(relay, TimeUtils.now(), forFilters) + } + } + }, + ) override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt index c07a30266..2a93ccfd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/SingleSubNoEoseCacheEoseManager.kt @@ -20,9 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** * This query type creates only ONE relay subscription and does not @@ -35,11 +39,31 @@ abstract class SingleSubNoEoseCacheEoseManager( val invalidateAfterEose: Boolean = false, ) : BaseEoseManager(client, allKeys) { val sub = - requestNewSubscription { time, relayUrl -> - if (invalidateAfterEose) { - invalidateFilters() - } - } + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (invalidateAfterEose) { + invalidateFilters() + } + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + if (invalidateAfterEose) { + invalidateFilters() + } + } + } + }, + ) override fun updateSubscriptions(keys: Set) { val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 27651ce0a..17a8d2dd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -40,15 +40,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProf import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import kotlinx.coroutines.CoroutineScope class RelaySubscriptionsCoordinator( cache: LocalCache, client: INostrClient, + authenticator: IAuthStatus, scope: CoroutineScope, ) { // main one: notifications, dms and account settings - val account = AccountFilterAssembler(client) + val account = AccountFilterAssembler(client, cache, authenticator, scope) // always running, feed assemblers. val home = HomeFilterAssembler(client) @@ -60,7 +62,7 @@ class RelaySubscriptionsCoordinator( // they are active when looking at events, users, channels. val channelFinder = ChannelFinderFilterAssemblyGroup(client) val eventFinder = EventFinderFilterAssembler(client) - val userFinder = UserFinderFilterAssembler(client) + val userFinder = UserFinderFilterAssembler(client, cache) // active when searching or tagging users. val search = SearchFilterAssembler(client, scope, cache) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 562eb7ca8..c68b32def 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.AccountFollowsLoaderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager @@ -29,6 +32,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59Gi import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to logged-in accounts. class AccountQueryState( @@ -42,11 +48,16 @@ class AccountQueryState( */ class AccountFilterAssembler( client: INostrClient, + cache: LocalCache, + authenticator: IAuthStatus, + scope: CoroutineScope, ) : ComposeSubscriptionManager() { val group = listOf( AccountMetadataEoseManager(client, ::allKeys), + AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, ::allKeys), AccountGiftWrapsEoseManager(client, ::allKeys), + AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt new file mode 100644 index 000000000..448528c29 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -0,0 +1,248 @@ +/** + * 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.service.relayClient.reqCommand.account.follows + +import com.vitorpamplona.amethyst.isDebug +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch +import kotlin.collections.forEach +import kotlin.collections.ifEmpty + +/** + * This downloads the outbox events for all Follows of all users. + * It needs to be super fast on startup. + */ +class AccountFollowsLoaderSubAssembler( + val client: INostrClient, + val cache: LocalCache, + val scope: CoroutineScope, + val authStatus: IAuthStatus, + val allKeys: () -> Set, +) : IEoseManager { + private val logTag = "AccountFollowsLoaderSubAssembler" + private val orchestrator = SubscriptionController(client) + + private val cannotConnectRelays = mutableSetOf() + + // Refreshes observers in batches of 500ms + private val bundler = BundledUpdate(500, Dispatchers.Default) + + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + val hasTried: EOSEAccountFast = EOSEAccountFast(2000) + + // updates all filters + override fun invalidateFilters(ignoreIfDoing: Boolean) { + bundler.invalidate(ignoreIfDoing) { + updateSubscriptions(allKeys()) + orchestrator.updateRelays() + } + } + + override fun destroy() { + orchestrator.dismissSubscription(sub.id) + bundler.cancel() + } + + fun newEose( + time: Long, + relayUrl: NormalizedRelayUrl, + filters: List?, + ) { + filters?.forEach { filter -> + filter.authors?.forEach { pubkey -> + cache.getUserIfExists(pubkey)?.let { user -> + hasTried.newEose(user, relayUrl, time) + } + } + } + + invalidateFilters(true) + } + + val sub = + orchestrator.requestNewSubscription( + if (isDebug) logTag + newSubId() else newSubId(), + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(TimeUtils.now(), relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(TimeUtils.now(), relay, forFilters) + } + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // If the relay doesn't want to provide data (and it is not because of auth), cancel REQ + if ( + !message.startsWith("auth") || authStatus.hasFinishedAuthentication(relay) + ) { + newEose(TimeUtils.now(), relay, forFilters) + } + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + cannotConnectRelays.add(relay) + } + }, + ) + + fun updateFilterForAllAccounts(accounts: Collection): List? { + val noOutboxList = mutableSetOf() + + val indexRelays = mutableSetOf() + val homeRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val commonRelays = mutableSetOf() + + accounts.forEach { key -> + key.kind3FollowList.userList.value.forEach { user -> + if (user.authorRelayList() == null) { + noOutboxList.add(user) + } + } + + indexRelays.addAll( + key.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList }, + ) + + homeRelays.addAll(key.nip65RelayList.allFlowNoDefaults.value) + homeRelays.addAll(key.privateStorageRelayList.flow.value) + homeRelays.addAll(key.localRelayList.flow.value) + + searchRelays.addAll(key.trustedRelayList.flow.value) + searchRelays.addAll( + key.searchRelayList.flow.value + .ifEmpty { DefaultSearchRelayList }, + ) + + // uses followShared to ignore personal relays when finding users. + commonRelays.addAll(key.followSharedOutboxesOrProxy.flow.value - cannotConnectRelays) + } + + if (noOutboxList.isEmpty()) return null + + val perRelay = pickRelaysToLoadUsers(noOutboxList, indexRelays, homeRelays, searchRelays, commonRelays, hasTried) + + hasTried.removeEveryoneBut(noOutboxList) + + return perRelay.mapNotNull { (relay, users) -> + if (users.isNotEmpty()) { + RelayBasedFilter( + relay = relay, + filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()), + ) + } else { + null + } + } + } + + fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account } + + val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values) + sub.updateFilters(allFilters?.groupByRelay()) + + // adds new subscriptions + uniqueSubscribedAccounts.forEach { + if (it.value.userProfile() !in accountUpdatesJobMap.keys) { + newWatcher(it.value.userProfile(), it.value.kind3FollowList.userList) + } + } + + // removes accounts that are not being subscribed anymore. + accountUpdatesJobMap.forEach { + if (it.key !in uniqueSubscribedAccounts.keys) { + endWatcher(it.key) + } + } + } + + private val accountUpdatesJobMap = mutableMapOf() + + @OptIn(FlowPreview::class) + fun newWatcher( + user: User, + followList: Flow>, + ) { + accountUpdatesJobMap[user]?.cancel() + accountUpdatesJobMap[user] = + scope.launch(Dispatchers.Default) { + followList.sample(1000).collectLatest { + invalidateFilters(true) + } + } + } + + fun endWatcher(key: User) { + accountUpdatesJobMap[key]?.cancel() + accountUpdatesJobMap.remove(key) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt new file mode 100644 index 000000000..07a35693c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -0,0 +1,187 @@ +/** + * 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.service.relayClient.reqCommand.account.follows + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +val MetadataOnlyKinds = listOf(MetadataEvent.KIND) +val OutboxOnlyKinds = listOf(AdvertisedRelayListEvent.KIND) +val BothKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) + +fun filterFindFollowMetadataForKey( + loadMetadata: Set, + loadOutbox: Set, + connectedRelays: Set, + indexRelays: Set, + searchRelays: Set, + allRelays: Set, + hasTried: EOSEAccountFast, +): List { + val both = loadMetadata.intersect(loadOutbox) + val onlyMetadata = loadMetadata - both + val onlyOutbox = loadOutbox - both + + val perRelayKeysBoth = pickRelaysToLoadUsers(both, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) + val perRelayKeysOnlyMetadata = pickRelaysToLoadUsers(onlyMetadata, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) + val perRelayKeysOnlyOutbox = pickRelaysToLoadUsers(onlyOutbox, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) + + return perRelayKeysBoth.mapNotNull { + val sortedUsers = it.value.sorted() + if (sortedUsers.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(kinds = BothKinds, authors = sortedUsers), + ) + } else { + null + } + } + + perRelayKeysOnlyMetadata.mapNotNull { + val sortedUsers = it.value.sorted() + if (sortedUsers.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(kinds = MetadataOnlyKinds, authors = sortedUsers), + ) + } else { + null + } + } + + perRelayKeysOnlyOutbox.mapNotNull { + val sortedUsers = it.value.sorted() + if (sortedUsers.isNotEmpty()) { + RelayBasedFilter( + relay = it.key, + filter = Filter(kinds = OutboxOnlyKinds, authors = sortedUsers), + ) + } else { + null + } + } +} + +fun pickRelaysToLoadUsers( + users: Set, + indexRelays: Set, + homeRelays: Set, + searchRelays: Set, + commonRelays: Set, + hasTried: EOSEAccountFast, +): Map> = + mapOfSet { + users.forEachIndexed { idx, key -> + val tried = hasTried.since(key)?.keys ?: emptySet() + + val outbox = key.authorRelayList()?.writeRelaysNorm() + + if (outbox != null && outbox.isNotEmpty()) { + // If there is a home, get from it. + + // if it tried all outbox relays, stop. + // the UserWatch will take over from here. + val leftToTry = (outbox - tried) + leftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + // if not, tries hints first. + val hints = key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + + val leftToTryOnHints = hints - tried + + leftToTryOnHints.forEach { + add(it, key.pubkeyHex) + } + + // if there are only a few hints, broadens the search + if (leftToTryOnHints.size < 3) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val indexRelaysLeftToTry = + (indexRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val homeRelaysLeftToTry = + (homeRelays - tried).sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + } + + // picks one at random to avoid overloading these relays + if (users.size > 300) { + if (indexRelaysLeftToTry.size >= 2) { + add(indexRelaysLeftToTry[0], key.pubkeyHex) + add(indexRelaysLeftToTry[1], key.pubkeyHex) + } else if (indexRelaysLeftToTry.size == 1) { + add(indexRelaysLeftToTry.first(), key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } else { + indexRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + homeRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + + if (indexRelaysLeftToTry.size < 2) { + val searchRelaysLeftToTry = searchRelays - tried + + searchRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + + if (searchRelaysLeftToTry.size < 2) { + // This creates a pre-deterministic order of the array such that + // if this function is called twice, it returns the same arrays + // which gets ignored by the relay client if we send it twice + val allRelaysLeftToTry = + (commonRelays - tried) + .sortedBy { relay -> + key.pubkeyHex.hashCode() xor relay.url.hashCode() + }.take(100) + + allRelaysLeftToTry.forEach { + add(it, key.pubkeyHex) + } + } + } + } + } + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index d1760b322..edf756ce4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class EventWatcherSubAssembler( @@ -41,11 +42,12 @@ class EventWatcherSubAssembler( override fun newEose( relay: NormalizedRelayUrl, time: Long, + filters: List?, ) { lastNotesOnFilter.forEach { latestEOSEs.newEose(it, relay, time) } - super.newEose(relay, time) + super.newEose(relay, time, filters) } override fun updateFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 947975270..2b22aeabe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserLoaderSubAssembler +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -36,11 +38,12 @@ class UserFinderQueryState( class UserFinderFilterAssembler( client: INostrClient, + cache: LocalCache, ) : ComposeSubscriptionManager() { val group = listOf( - UserLoaderSubAssembler(client, ::allKeys), - UserWatcherSubAssembler(client, ::allKeys), + UserOutboxFinderSubAssembler(client, cache, ::allKeys), + UserWatcherSubAssembler(client, cache, ::allKeys), UserReportsSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index b20cc7dcc..594252524 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -20,59 +20,33 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.utils.mapOfSet -val MetadataAndRelayListKinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - ) +val RelayListKinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND) -fun filterFindUserMetadataForKey( - author: HexKey, - indexRelays: Set, - defaultRelays: Set, -): List = - LocalCache.checkGetOrCreateUser(author)?.let { - filterFindUserMetadataForKey(setOf(it), indexRelays, defaultRelays) - } ?: emptyList() - -fun filterFindUserMetadataForKey( +fun findFindUserOutBox( authors: Set, + connectedRelays: Set, indexRelays: Set, - defaultRelays: Set, + searchRelays: Set, + allRelays: Set, + hasTried: EOSEAccountFast, ): List { - val perRelayKeys = - mapOfSet { - authors.forEach { key -> - val relays = - key.authorRelayList()?.writeRelaysNorm() - ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays).ifEmpty { null } - ?: defaultRelays.toList() + val perRelayKeysBoth = pickRelaysToLoadUsers(authors, connectedRelays, indexRelays, searchRelays, allRelays, hasTried) - relays.forEach { - add(it, key.pubkeyHex) - } - } - } - - return perRelayKeys.mapNotNull { - if (it.value.isNotEmpty()) { + return perRelayKeysBoth.mapNotNull { + val sortedUsers = it.value.sorted() + if (sortedUsers.isNotEmpty()) { RelayBasedFilter( relay = it.key, - filter = - Filter( - kinds = MetadataAndRelayListKinds, - authors = it.value.sorted(), - ), + filter = Filter(kinds = RelayListKinds, authors = sortedUsers), ) } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt deleted file mode 100644 index 04057d91a..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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.service.relayClient.reqCommand.user.loaders - -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl - -class UserLoaderSubAssembler( - client: INostrClient, - allKeys: () -> Set, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { - override fun updateFilter(keys: List): List? { - val firstTimers = mutableSetOf() - - keys.forEach { - if (it.user.latestMetadata == null) { - firstTimers.add(it.user) - } else { - null - } - } - - val indexRelays = mutableSetOf() - val defaultRelays = mutableSetOf() - - keys.mapTo(mutableSetOf()) { it.account }.forEach { - indexRelays.addAll( - it.indexerRelayList.flow.value - .ifEmpty { DefaultIndexerRelayList }, - ) - defaultRelays.addAll(it.followPlusAllMineWithSearch.flow.value) - - it.kind3FollowList.flow.value.authors.forEach { - val user = LocalCache.getOrCreateUser(it) - if (user.latestMetadata == null) { - firstTimers.add(user) - } else { - null - } - } - } - - if (firstTimers.isEmpty()) return null - - return filterFindUserMetadataForKey(firstTimers, indexRelays, defaultRelays) - } - - override fun distinct(key: UserFinderQueryState) = key.user -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt new file mode 100644 index 000000000..dd2a54b79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -0,0 +1,125 @@ +/** + * 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.service.relayClient.reqCommand.user.loaders + +import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.collections.ifEmpty + +class UserOutboxFinderSubAssembler( + client: INostrClient, + val cache: LocalCache, + allKeys: () -> Set, +) : BaseEoseManager(client, allKeys) { + /** + * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc + * and reports, but only from trusted accounts (follows of all logged in users). + */ + var hasTried: EOSEAccountFast = EOSEAccountFast(200) + + fun newEose( + relay: NormalizedRelayUrl, + time: Long, + filters: List? = null, + ) = { + filters?.forEach { + it.authors?.forEach { + cache.getUserIfExists(it)?.let { user -> + hasTried.newEose(user, relay, time) + } + } + } + invalidateFilters() + } + + val sub = + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + }, + ) + + override fun updateSubscriptions(keys: Set) { + val uniqueSubscribedAccounts = keys.distinctBy { it.user } + val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null } + sub.updateFilters(newFilters?.groupByRelay()) + } + + fun updateFilter(keys: List): List? { + val noOutboxList = mutableSetOf() + + keys.forEach { + if (it.user.authorRelayList() == null) { + noOutboxList.add(it.user) + } + } + + if (noOutboxList.isEmpty()) return null + + val indexRelays = mutableSetOf() + val searchRelays = mutableSetOf() + val allRelays = mutableSetOf() + + keys.mapTo(mutableSetOf()) { it.account }.forEach { acc -> + // broadens the search as it goes. + indexRelays.addAll( + acc.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList }, + ) + indexRelays.addAll(acc.proxyRelayList.flow.value) + + searchRelays.addAll(acc.nip65RelayList.allFlowNoDefaults.value) + searchRelays.addAll(acc.privateStorageRelayList.flow.value) + searchRelays.addAll(acc.localRelayList.flow.value) + searchRelays.addAll(acc.searchRelayList.flow.value) + searchRelays.addAll(acc.trustedRelayList.flow.value) + + allRelays.addAll(acc.followOutboxesOrProxy.flow.value) + } + allRelays.addAll(Constants.eventFinderRelays) + + return findFindUserOutBox( + noOutboxList, + client.relayStatusFlow().value.connected, + indexRelays, + searchRelays, + allRelays, + hasTried, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index f688f6a3b..ec296eeca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -21,15 +21,20 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.forEach +import kotlin.collections.plus val UserMetadataForKeyKinds = listOf( @@ -41,30 +46,60 @@ val UserMetadataForKeyKinds = ) fun filterUserMetadataForKey( - authors: Set, - since: SincePerRelayMap?, + authors: Set, + indexRelays: Set, + since: EOSEAccountFast, ): List { - val relays = - authors - .map { - val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) - val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) + val perRelayUsers = + mapOfSet { + authors.forEach { key -> + val relays = + key.outboxRelays() + ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays) - authorHomeRelayEvent?.writeRelaysNorm() - ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } - ?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay) - }.flatten() - .toSet() + relays.forEach { + add(it, key) + } + } + } - return relays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = UserMetadataForKeyKinds, - authors = authors.toList(), - since = since?.get(it)?.time, + return perRelayUsers + .map { (relay, users) -> + val firstTimers = mutableSetOf() + val updates = mutableSetOf() + + var minimumTime: Long = Long.MAX_VALUE + + users.forEach { user -> + val time = since.since(user)?.get(relay)?.time + if (time == null) { + firstTimers.add(user.pubkeyHex) + } else { + updates.add(user.pubkeyHex) + if (time < minimumTime) { + minimumTime = time + } + } + } + + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserMetadataForKeyKinds, + authors = firstTimers.sorted(), + ), ), - ) - } + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserMetadataForKeyKinds, + authors = updates.sorted(), + since = minimumTime, + ), + ), + ) + }.flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 6b0c40e62..0ef0244ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet @@ -46,11 +47,12 @@ class UserReportsSubAssembler( override fun newEose( relay: NormalizedRelayUrl, time: Long, + filters: List?, ) { lastUsersOnFilter.forEach { latestEOSEs.newEose(it, relay, time) } - super.newEose(relay, time) + super.newEose(relay, time, filters) } override fun updateFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 759d76768..d3dd7e145 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -20,94 +20,87 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.ammolite.relays.filters.MutableTime +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils class UserWatcherSubAssembler( client: INostrClient, + val cache: LocalCache, allKeys: () -> Set, -) : SingleSubEoseManager(client, allKeys) { - var lastUsersOnFilter: Set = emptySet() - +) : BaseEoseManager(client, allKeys) { /** * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc * and reports, but only from trusted accounts (follows of all logged in users). */ - var latestEOSEs: EOSEAccountFast = EOSEAccountFast(2000) + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(200) - override fun newEose( + fun newEose( relay: NormalizedRelayUrl, time: Long, - ) { - lastUsersOnFilter.forEach { - latestEOSEs.newEose(it, relay, time) - } - super.newEose(relay, time) - } - - override fun updateFilter( - keys: List, - since: SincePerRelayMap?, - ): List? { - if (keys.isEmpty()) return null - - lastUsersOnFilter = - keys.mapNotNullTo(mutableSetOf()) { - if (it.user.latestMetadata != null) it.user else null - } - - if (lastUsersOnFilter.isEmpty()) return null - - return groupByRelayPresence(lastUsersOnFilter, latestEOSEs) - .map { group -> - val groupIds = group.map { it.pubkeyHex }.toSet() - if (groupIds.isNotEmpty()) { - val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs) - filterUserMetadataForKey(groupIds, minEOSEs) - } else { - emptyList() - } - }.flatten() - } - - fun groupByRelayPresence( - users: Iterable, - eoseCache: EOSEAccountFast, - ): Collection> = - users - .groupBy { eoseCache.since(it)?.keys?.hashCode() } - .values - .map { - // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again - it.sortedBy { it.pubkeyHex } - } - - fun findMinimumEOSEsForUsers( - users: List, - eoseCache: EOSEAccountFast, - ): SincePerRelayMap { - val minLatestEOSEs = mutableMapOf() - - users.forEach { - eoseCache.since(it)?.forEach { - val minEose = minLatestEOSEs[it.key] - if (minEose == null) { - minLatestEOSEs.put(it.key, it.value.copy()) - } else { - minEose.updateIfOlder(it.value.time) + filters: List? = null, + ) = { + filters?.forEach { + it.authors?.forEach { + cache.getUserIfExists(it)?.let { user -> + latestEOSEs.newEose(user, relay, time) } } } - - return minLatestEOSEs } - override fun distinct(key: UserFinderQueryState) = key.user + val sub = + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(relay, TimeUtils.now(), forFilters) + } + } + }, + ) + + override fun updateSubscriptions(keys: Set) { + val users = keys.mapTo(mutableSetOf()) { it.user } + + if (users.isEmpty()) { + sub.updateFilters(null) + return + } + + // assembles all index relays from all accounts + val indexRelays = mutableSetOf() + keys.mapTo(mutableSetOf()) { it.account }.forEach { + indexRelays.addAll( + it.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList }, + ) + } + + val newFilters = filterUserMetadataForKey(users, indexRelays, latestEOSEs).ifEmpty { null } + + sub.updateFilters(newFilters?.groupByRelay()) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index 916bfa09c..c453f70f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -20,12 +20,45 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.filterFindUserMetadataForKey +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.ifEmpty +import kotlin.collections.plus + +val MetadataKindList = listOf(MetadataEvent.KIND) fun filterByAuthor( pubKey: HexKey, indexRelays: Set, defaultRelays: Set, -) = filterFindUserMetadataForKey(pubKey, indexRelays, defaultRelays) +) = LocalCache.checkGetOrCreateUser(pubKey)?.let { key -> + val perRelay = + mapOfSet { + val relays = + key.authorRelayList()?.writeRelaysNorm() + ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays).ifEmpty { null } + ?: defaultRelays.toList() + + relays.forEach { + add(it, key.pubkeyHex) + } + } + + val myUser = listOf(pubKey) + + perRelay.map { + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = MetadataKindList, + authors = myUser, + ), + ) + } +} ?: emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index c4397535b..8883fe5d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -20,10 +20,11 @@ */ package com.vitorpamplona.amethyst.service.relayClient.speedLogger -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.utils.Log /** @@ -40,15 +41,14 @@ class RelaySpeedLogger( private val clientListener = object : IRelayClientListener { - /** A new message was received */ - override fun onEvent( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + msgStr: String, + msg: Message, ) { - current.increment(event.kind, subId, relay.url, event.countMemory()) + if (msg is EventMessage) { + current.increment(msg.event.kind, msg.subId, relay.url, msg.event.countMemory()) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index ba3cb4d3e..26b4398f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -99,6 +99,7 @@ class MainActivity : AppCompatActivity() { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { debugState(this@MainActivity) + Amethyst.instance.relayReqStats?.printStats() } super.onPause() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 46f93d204..6040d63ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.UiSettingsFlow @@ -99,6 +98,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -130,7 +130,6 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -204,20 +203,20 @@ class AccountViewModel( } else { MutableStateFlow(null) } - }.flatMapLatest { + }.flatMapLatest { loadedFeedState -> val flows = - it?.list?.mapNotNull { chat -> + loadedFeedState?.list?.mapNotNull { chat -> (chat.event as? ChatroomKeyable)?.let { event -> val room = event.chatroomKey(account.signer.pubKey) - account.settings.getLastReadFlow("Room/${room.hashCode()}").map { - (chat.event?.createdAt ?: 0) > it + account.settings.getLastReadFlow("Room/${room.hashCode()}").map { lastReadAt -> + (chat.event?.createdAt ?: 0) > lastReadAt } } } if (!flows.isNullOrEmpty()) { - combine(flows) { - it.any { it } + combine(flows) { newItems -> + newItems.any { it } } } else { MutableStateFlow(false) @@ -240,8 +239,8 @@ class AccountViewModel( } else { MutableStateFlow(null) } - }.map { - it?.list?.firstOrNull { it.event != null && it.event !is GenericRepostEvent && it.event !is RepostEvent }?.createdAt() + }.map { loadedFeedState -> + loadedFeedState?.list?.firstOrNull { it.event != null && it.event !is GenericRepostEvent && it.event !is RepostEvent }?.createdAt() }, ) { lastRead, newestItemCreatedAt -> emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) @@ -712,8 +711,6 @@ class AccountViewModel( fun cachedDecrypt(note: Note): String? = account.cachedDecryptContent(note) - fun cachedDecrypt(event: Event?): String? = account.cachedDecryptContent(event) - fun decrypt( note: Note, onReady: (String) -> Unit, @@ -725,17 +722,17 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { try { action() - } catch (e: SignerExceptions.ReadOnlyException) { + } catch (_: SignerExceptions.ReadOnlyException) { toastManager.toast( R.string.read_only_user, R.string.login_with_a_private_key_to_be_able_to_sign_events, ) - } catch (e: SignerExceptions.UnauthorizedDecryptionException) { + } catch (_: SignerExceptions.UnauthorizedDecryptionException) { toastManager.toast( R.string.unauthorized_exception, R.string.unauthorized_exception_description, ) - } catch (e: SignerExceptions.SignerNotFoundException) { + } catch (_: SignerExceptions.SignerNotFoundException) { toastManager.toast( R.string.signer_not_found_exception, R.string.signer_not_found_exception_description, @@ -938,7 +935,7 @@ class AccountViewModel( fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key) - override suspend fun getOrCreateUser(key: HexKey): User = LocalCache.getOrCreateUser(key) + override suspend fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex) fun checkGetOrCreateUser( key: HexKey, @@ -951,7 +948,7 @@ class AccountViewModel( fun checkGetOrCreateNote(key: HexKey): Note? = LocalCache.checkGetOrCreateNote(key) - override suspend fun getOrCreateNote(key: HexKey): Note = LocalCache.getOrCreateNote(key) + override suspend fun getOrCreateNote(hex: HexKey): Note = LocalCache.getOrCreateNote(hex) fun checkGetOrCreateNote( key: HexKey, @@ -1004,13 +1001,6 @@ class AccountViewModel( fun checkGetOrCreateEphemeralChatChannel(key: RoomId): EphemeralChatChannel? = LocalCache.getOrCreateEphemeralChannel(key) - fun checkGetOrCreateChannel( - key: HexKey, - onResult: (Channel?) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreatePublicChatChannel(key)) } - } - fun getPublicChatChannelIfExists(hex: HexKey) = LocalCache.getPublicChatChannelIfExists(hex) fun getEphemeralChatChannelIfExists(key: RoomId) = LocalCache.getEphemeralChatChannelIfExists(key) @@ -1444,13 +1434,6 @@ class AccountViewModel( onSent() } - fun getRelayListFor(user: User): AdvertisedRelayListEvent? = (getRelayListNoteFor(user)?.event as? AdvertisedRelayListEvent?) - - fun getRelayListNoteFor(user: User): AddressableNote? = - LocalCache.getAddressableNoteIfExists( - AdvertisedRelayListEvent.createAddressTag(user.pubkeyHex), - ) - fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( @@ -1657,6 +1640,7 @@ fun mockAccountViewModel(): AccountViewModel { ) val client = EmptyNostrClient + val authenticator = EmptyIAuthStatus val nwcFilters = NWCPaymentFilterAssembler(client) @@ -1677,7 +1661,7 @@ fun mockAccountViewModel(): AccountViewModel { settings = uiState, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), - dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope), + dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), ).also { mockedCache = it } @@ -1705,6 +1689,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { ) val client = EmptyNostrClient + val authenticator = EmptyIAuthStatus val nwcFilters = NWCPaymentFilterAssembler(client) @@ -1725,7 +1710,7 @@ fun mockVitorAccountViewModel(): AccountViewModel { settings = uiState, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)), httpClientBuilder = EmptyRoleBasedHttpClientBuilder(), - dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope), + dataSources = RelaySubscriptionsCoordinator(LocalCache, client, authenticator, scope), ).also { vitorCache = it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 6e86149b5..41ac7b89a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -43,22 +45,25 @@ interface INostrClient { fun isActive(): Boolean + /** + * Sends all current filters, events, etc to the relay. + * This is called every time the relay connects + * and when auth is successful + */ + fun renewFilters(relay: IRelayClient) + fun openReqSubscription( subId: String = newSubId(), filters: Map>, + listener: IRequestListener? = null, ) - fun openCountSubscription( + fun queryCount( subId: String = newSubId(), filters: Map>, ) - fun close(subscriptionId: String) - - fun sendIfExists( - event: Event, - connectedRelay: NormalizedRelayUrl, - ) + fun close(subId: String) fun send( event: Event, @@ -88,22 +93,20 @@ object EmptyNostrClient : INostrClient { override fun isActive() = false + override fun renewFilters(relay: IRelayClient) { } + override fun openReqSubscription( subId: String, filters: Map>, + listener: IRequestListener?, ) { } - override fun openCountSubscription( + override fun queryCount( subId: String, filters: Map>, ) { } - override fun close(subscriptionId: String) { } - - override fun sendIfExists( - event: Event, - connectedRelay: NormalizedRelayUrl, - ) { } + override fun close(subId: String) { } override fun send( event: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 2db300300..1c818969f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -23,18 +23,20 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolCounts +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -77,17 +79,18 @@ class NostrClient( private val scope: CoroutineScope, ) : INostrClient, IRelayClientListener { - private val relayPool: RelayPool = RelayPool(this, ::buildRelay) - private val activeRequests: PoolSubscriptionRepository = PoolSubscriptionRepository() - private val activeCounts: PoolSubscriptionRepository = PoolSubscriptionRepository() - private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository() + private val relayPool: RelayPool = RelayPool(websocketBuilder, this) + + private val activeRequests: PoolRequests = PoolRequests() + private val activeCounts: PoolCounts = PoolCounts() + private val eventOutbox: PoolEventOutbox = PoolEventOutbox() private var listeners = setOf() // controls the state of the client in such a way that if it is active // new filters will be sent to the relays and a potential reconnect can // be triggered. - // STARTS active + // Default: STARTS active private var isActive = true /** @@ -97,7 +100,7 @@ class NostrClient( @OptIn(FlowPreview::class) private val allRelays = combine( - activeRequests.relays, + activeRequests.desiredRelays, activeCounts.relays, eventOutbox.relays, ) { reqs, counts, outbox -> @@ -109,25 +112,9 @@ class NostrClient( .stateIn( scope, SharingStarted.Eagerly, - activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value, + activeRequests.desiredRelays.value + activeCounts.relays.value + eventOutbox.relays.value, ) - fun buildRelay(relay: NormalizedRelayUrl): IRelayClient = - BasicRelayClient( - url = relay, - socketBuilder = websocketBuilder, - listener = relayPool, - stats = RelayStats.get(relay), - ) { liveRelay -> - if (isActive) { - scope.launch(Dispatchers.Default) { - activeRequests.forEachSub(relay, liveRelay::sendRequest) - activeCounts.forEachSub(relay, liveRelay::sendCount) - eventOutbox.forEachUnsentEvent(relay, liveRelay::send) - } - } - } - // Reconnects all relays that may have disconnected override fun connect() { isActive = true @@ -172,134 +159,42 @@ class NostrClient( refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays)) } - fun needsToResendRequest( - oldFilters: List, - newFilters: List, - ): Boolean { - if (oldFilters.size != newFilters.size) return true - - oldFilters.forEachIndexed { index, oldFilter -> - val newFilter = newFilters.getOrNull(index) ?: return true - - return needsToResendRequest(oldFilter, newFilter) - } - return false - } - - /** - * Checks if the filter has changed, with a special case for when the since changes due to new - * EOSE times. - */ - fun needsToResendRequest( - oldFilter: Filter, - newFilter: Filter, - ): Boolean { - // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. - // fast check - if (oldFilter.authors?.size != newFilter.authors?.size || - oldFilter.ids?.size != newFilter.ids?.size || - oldFilter.tags?.size != newFilter.tags?.size || - oldFilter.kinds?.size != newFilter.kinds?.size || - oldFilter.limit != newFilter.limit || - oldFilter.search?.length != newFilter.search?.length || - oldFilter.until != newFilter.until - ) { - return true - } - - // deep check - if (oldFilter.ids != newFilter.ids || - oldFilter.authors != newFilter.authors || - oldFilter.tags != newFilter.tags || - oldFilter.kinds != newFilter.kinds || - oldFilter.search != newFilter.search - ) { - return true - } - - if (oldFilter.since != null) { - if (newFilter.since == null) { - // went was checking the future only and now wants everything - return true - } else if (oldFilter.since > newFilter.since) { - // went backwards in time, forces update - return true - } - } - - return false - } - override fun openReqSubscription( subId: String, filters: Map>, + listener: IRequestListener?, ) { - val oldFilters = activeRequests.getSubscriptionFiltersOrNull(subId) ?: emptyMap() - activeRequests.addOrUpdate(subId, filters) + val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) if (isActive) { - val allRelays = filters.keys + oldFilters.keys - - allRelays.forEach { relay -> - val oldFilters = oldFilters[relay] - val newFilters = filters[relay] - - if (newFilters.isNullOrEmpty()) { - // some relays are not in this sub anymore. Stop their subscriptions - if (!oldFilters.isNullOrEmpty()) { - // only update if the old filters are not already closed. - relayPool.close(relay, subId) - } - } else if (oldFilters.isNullOrEmpty()) { - // new relays were added. Start a new sub in them - relayPool.sendRequest(relay, subId, newFilters) - } else if (needsToResendRequest(oldFilters, newFilters)) { - // filters were changed enough (not only an update in since) to warn a new update - relayPool.sendRequest(relay, subId, newFilters) + activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> + if (cmd is CloseCmd || cmd is AuthCmd) { + relayPool.sendIfConnected(relay, cmd) } else { - // makes sure the relay wakes up if it was disconnected by the server - // upon connection, the relay will run the default Sync and update all - // filters, including this one. - relayPool.connectIfDisconnected(relay) + relayPool.sendOrConnectAndSync(relay, cmd) } } // wakes up all the other relays + // makes sure the relay wakes up if it was disconnected by the server + // upon connection, the relay will run the default Sync and update all + // filters, including this one. reconnect(true) } } - override fun openCountSubscription( + override fun queryCount( subId: String, filters: Map>, ) { - val oldFilters = activeCounts.getSubscriptionFiltersOrNull(subId) ?: emptyMap() - activeCounts.addOrUpdate(subId, filters) + val relaysToUpdate = activeCounts.addOrUpdate(subId, filters) if (isActive) { - val allRelays = filters.keys + oldFilters.keys - - allRelays.forEach { relay -> - val oldFilters = oldFilters[relay] - val newFilters = filters[relay] - - if (newFilters.isNullOrEmpty()) { - // some relays are not in this sub anymore. Stop their subscriptions - if (!oldFilters.isNullOrEmpty()) { - // only update if the old filters are not already closed. - relayPool.close(relay, subId) - } - } else if (oldFilters.isNullOrEmpty()) { - // new relays were added. Start a new sub in them - relayPool.sendCount(relay, subId, newFilters) - } else if (needsToResendRequest(oldFilters, newFilters)) { - // filters were changed enough (not only an update in since) to warn a new update - relayPool.sendCount(relay, subId, newFilters) + activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> + if (cmd is CloseCmd || cmd is AuthCmd) { + relayPool.sendIfConnected(relay, cmd) } else { - // makes sure the relay wakes up if it was disconnected by the server - // upon connection, the relay will run the default Sync and update all - // filters, including this one. - relayPool.connectIfDisconnected(relay) + relayPool.sendOrConnectAndSync(relay, cmd) } } @@ -308,123 +203,104 @@ class NostrClient( } } - override fun sendIfExists( - event: Event, - connectedRelay: NormalizedRelayUrl, - ) { - if (isActive) { - relayPool.getRelay(connectedRelay)?.send(event) - - // wakes up all the other relays - reconnect(true) - } - } - override fun send( event: Event, relayList: Set, ) { - eventOutbox.markAsSending(event, relayList) + val relaysToUpdate = eventOutbox.markAsSending(event, relayList) if (isActive) { - relayPool.send(event, relayList) + eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync) // wakes up all the other relays reconnect(true) } } - override fun close(subscriptionId: String) { - activeRequests.remove(subscriptionId) - activeCounts.remove(subscriptionId) - relayPool.close(subscriptionId) + override fun close(subId: String) { + val relaysToUpdateReqs = activeRequests.remove(subId) + val relaysToUpdateCounts = activeCounts.remove(subId) + + if (isActive) { + activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected) + activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected) + } } - override fun onEvent( + override fun renewFilters(relay: IRelayClient) { + if (isActive) { + scope.launch(Dispatchers.Default) { + activeRequests.syncState(relay.url, relay::sendOrConnectAndSync) + activeCounts.syncState(relay.url, relay::sendOrConnectAndSync) + eventOutbox.syncState(relay.url, relay::sendOrConnectAndSync) + } + } + } + + /** + * when a new connection starts, resets the state + */ + override fun onConnecting(relay: IRelayClient) { + activeRequests.onConnecting(relay.url) + listeners.forEach { it.onConnecting(relay) } + } + + /** + * Relay just connected. Use this to send all + * filters and events you need. + */ + override fun onConnected( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + pingMillis: Int, + compressed: Boolean, ) { - listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) } + renewFilters(relay) + listeners.forEach { it.onConnected(relay, pingMillis, compressed) } } - override fun onEOSE( + override fun onSent( relay: IRelayClient, - subId: String, - arrivalTime: Long, - ) { - listeners.forEach { it.onEOSE(relay, subId, arrivalTime) } - } - - override fun onClosed( - relay: IRelayClient, - subId: String, - message: String, - ) { - listeners.forEach { it.onClosed(relay, subId, message) } - } - - override fun onRelayStateChange( - relay: IRelayClient, - type: RelayState, - ) { - listeners.forEach { it.onRelayStateChange(relay, type) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onBeforeSend( - relay: IRelayClient, - event: Event, - ) { - eventOutbox.newTry(event.id, relay.url) - listeners.forEach { it.onBeforeSend(relay, event) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSendResponse( - relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, - ) { - eventOutbox.newResponse(eventId, relay.url, success, message) - listeners.forEach { it.onSendResponse(relay, eventId, success, message) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onAuth( - relay: IRelayClient, - challenge: String, - ) { - listeners.forEach { it.onAuth(relay, challenge) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onNotify( - relay: IRelayClient, - description: String, - ) { - listeners.forEach { it.onNotify(relay, description) } - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onSend( - relay: IRelayClient, - msg: String, + cmdStr: String, + cmd: Command, success: Boolean, ) { - listeners.forEach { it.onSend(relay, msg, success) } + if (success) { + activeRequests.onSent(relay.url, cmd) + activeCounts.onSent(relay.url, cmd) + eventOutbox.onSent(relay.url, cmd) + } + listeners.forEach { it.onSent(relay, cmdStr, cmd, success) } } - @OptIn(DelicateCoroutinesApi::class) - override fun onError( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - error: Error, + msgStr: String, + msg: Message, ) { - listeners.forEach { it.onError(relay, subId, error) } + activeRequests.onIncomingMessage(relay, msg) + activeCounts.onIncomingMessage(relay, msg) + eventOutbox.onIncomingMessage(relay.url, msg) + + listeners.forEach { it.onIncomingMessage(relay, msgStr, msg) } + } + + /** + * Relay just diconnected. + */ + override fun onDisconnected(relay: IRelayClient) { + activeRequests.onDisconnected(relay.url) + listeners.forEach { it.onDisconnected(relay) } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + activeRequests.onCannotConnect(relay.url, errorMessage) + activeCounts.onCannotConnect(relay.url, errorMessage) + eventOutbox.onCannotConnect(relay.url, errorMessage) + + listeners.forEach { it.onCannotConnect(relay, errorMessage) } } override fun subscribe(listener: IRelayClientListener) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt index c186cbe92..ea3519b99 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt @@ -21,8 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.RandomInstance @@ -31,35 +30,31 @@ class NostrClientSubscription( val client: INostrClient, val filter: () -> Map> = { emptyMap() }, val onEvent: (event: Event) -> Unit = {}, -) : IRelayClientListener { +) : IRequestListener { private val subId = RandomInstance.randomChars(10) override fun onEvent( - relay: IRelayClient, - subId: String, event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, ) { - if (this.subId == subId) { - onEvent(event) - } + onEvent(event) } /** * Creates or Updates the filter with relays. This method should be called * everytime the filter changes. */ - fun updateFilter() = client.openReqSubscription(subId, filter()) + fun updateFilter() = client.openReqSubscription(subId, filter(), this) fun closeSubscription() = client.close(subId) fun destroy() { - client.unsubscribe(this) + closeSubscription() } init { - client.subscribe(this) updateFilter() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt index a3a9c63c6..5afc9cf01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/EventCollector.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.utils.Log /** @@ -35,14 +37,14 @@ class EventCollector( ) { private val clientListener = object : IRelayClientListener { - override fun onEvent( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + msgStr: String, + msg: Message, ) { - onEvent(event, relay) + if (msg is EventMessage) { + onEvent(msg.event, relay) + } } } 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 81344f075..17219af0c 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 @@ -23,8 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.DelicateCoroutinesApi @@ -53,36 +54,37 @@ suspend fun INostrClient.sendAndWaitForResponse( val subscription = object : IRelayClientListener { - override fun onError( + override fun onCannotConnect( relay: IRelayClient, - subId: String, - error: Error, + errorMessage: String, ) { if (relay.url in relayList) { resultChannel.trySend(Result(relay.url, false)) - Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} error: $error") + Log.d("sendAndWaitForResponse", "Error from relay ${relay.url}: $errorMessage") } } - override fun onRelayStateChange( - relay: IRelayClient, - type: RelayState, - ) { - if (type == RelayState.DISCONNECTED && relay.url in relayList) { + override fun onDisconnected(relay: IRelayClient) { + if (relay.url in relayList) { resultChannel.trySend(Result(relay.url, false)) - Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url}") + Log.d("sendAndWaitForResponse", "Disconnected from relay ${relay.url}") } } - override fun onSendResponse( + override fun onIncomingMessage( relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, + msgStr: String, + msg: Message, ) { - if (eventId == event.id) { - resultChannel.trySend(Result(relay.url, success)) - Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} message $message success $success") + super.onIncomingMessage(relay, msgStr, msg) + + when (msg) { + is OkMessage -> { + if (msg.eventId == event.id) { + resultChannel.trySend(Result(relay.url, msg.success)) + Log.d("sendAndWaitForResponse", "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}") + } + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt index cfcc7fe00..f1be2aa3b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSingleDownloadExt.kt @@ -22,8 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -71,23 +70,18 @@ suspend fun INostrClient.downloadFirstEvent( val resultChannel = Channel(UNLIMITED) val listener = - object : IRelayClientListener { + object : IRequestListener { override fun onEvent( - relay: IRelayClient, - subId: String, event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, ) { - if (subId == subscriptionId) { - resultChannel.trySend(event) - } + resultChannel.trySend(event) } } - subscribe(listener) - - openReqSubscription(subscriptionId, filters) + openReqSubscription(subscriptionId, filters, listener) val result = withTimeoutOrNull(30000) { @@ -95,7 +89,7 @@ suspend fun INostrClient.downloadFirstEvent( } close(subscriptionId) - unsubscribe(listener) + resultChannel.close() return result diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt deleted file mode 100644 index d7a53b8ad..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayAuthenticator.kt +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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.nip01Core.relay.client.accessories - -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -class RelayAuthenticator( - val client: INostrClient, - val scope: CoroutineScope, - val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit, -) { - private val clientListener = - object : IRelayClientListener { - override fun onAuth( - relay: IRelayClient, - challenge: String, - ) { - scope.launch { - authenticate(challenge, relay) - } - } - } - - init { - Log.d("RelayAuthenticator", "Init, Subscribe") - client.subscribe(clientListener) - } - - fun destroy() { - // makes sure to run - Log.d("RelayAuthenticator", "Destroy, Unsubscribe") - client.unsubscribe(clientListener) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt index edbf77f16..415fae803 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayInsertConfirmationCollector.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.utils.Log /** @@ -35,14 +37,13 @@ class RelayInsertConfirmationCollector( ) { private val clientListener = object : IRelayClientListener { - override fun onSendResponse( + override fun onIncomingMessage( relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, + msgStr: String, + msg: Message, ) { - if (success) { - onRelayReceived(eventId, relay) + if (msg is OkMessage && msg.success) { + onRelayReceived(msg.eventId, relay) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index 8bb97f855..aa96126f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -20,10 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.Log /** @@ -31,27 +41,68 @@ import com.vitorpamplona.quartz.utils.Log */ class RelayLogger( val client: INostrClient, - val notify: (message: String, relay: IRelayClient) -> Unit, + val debugSending: Boolean = false, + val debugReceiving: Boolean = false, ) { + fun logTag(url: NormalizedRelayUrl) = "Relay ${url.displayUrl()}" + private val clientListener = object : IRelayClientListener { - /** A new message was received */ - override fun onEvent( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + msgStr: String, + msg: Message, ) { - Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") + val logTag = logTag(relay.url) + + when (msg) { + is EventMessage -> if (debugReceiving) Log.d(logTag, "Received: $msgStr") + is EoseMessage -> if (debugReceiving) Log.d(logTag, "EOSE: ${msg.subId}") + is NoticeMessage -> Log.w(logTag, "Notice: ${msg.message}") + is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") + is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") + is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") + is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") + } } - override fun onSend( + override fun onSent( relay: IRelayClient, - msg: String, + cmdStr: String, + cmd: Command, success: Boolean, ) { - Log.d("Relay", "Relay send ${relay.url} (${msg.length} chars) $msg") + if (success) { + if (debugSending) { + Log.d(logTag(relay.url), "Sent (${cmdStr.length} chars): $cmdStr") + } + } else { + Log.e(logTag(relay.url), "Failure sending (${cmdStr.length} chars): $cmdStr") + } + } + + override fun onConnecting(relay: IRelayClient) { + Log.d(logTag(relay.url), "Connecting...") + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + Log.d(logTag(relay.url), "OnOpen (ping: ${pingMillis}ms${if (compressed) ", using compression" else ""})") + } + + override fun onDisconnected(relay: IRelayClient) { + Log.d(logTag(relay.url), "Disconnected") + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + super.onCannotConnect(relay, errorMessage) + Log.e(logTag(relay.url), errorMessage) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt index a646d2414..e9425b3a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayNotifier.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage import com.vitorpamplona.quartz.utils.Log /** @@ -33,16 +35,20 @@ class RelayNotifier( val notify: (message: String, relay: IRelayClient) -> Unit, ) { companion object { - val TAG = "RelayNotifier" + const val TAG = "RelayNotifier" } private val clientListener = object : IRelayClientListener { - override fun onNotify( + override fun onIncomingMessage( relay: IRelayClient, - message: String, + msgStr: String, + msg: Message, ) { - notify(message, relay) + super.onIncomingMessage(relay, msgStr, msg) + if (msg is NotifyMessage) { + notify(msg.message, relay) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt new file mode 100644 index 000000000..64cf00d10 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -0,0 +1,80 @@ +/** + * 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.nip01Core.relay.client.auth + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent + +class RelayAuthStatus { + // Keeps track of auth responses to update the relay with all filters + // after the authentication happen + private val authResponseWatcher: MutableMap = mutableMapOf() + + // Avoids sending multiple replies for each auth. + private val uniqueAuthChallengesSent: MutableSet = mutableSetOf() + + enum class AuthEventReceiptStatus { + AUTHENTICATING, + AUTHENTICATED, + NOT_AUTHENTICATED, + } + + data class ChallengePair( + val user: HexKey, + val challenge: String, + ) + + fun saveAuthSubmission(authEvent: RelayAuthEvent): Boolean { + val challenge = authEvent.challenge() + if (challenge == null) return false + + val challengePair = ChallengePair(authEvent.pubKey, challenge) + + // only send replies to new challenges to avoid infinite loop: + return if (challengePair !in uniqueAuthChallengesSent) { + authResponseWatcher[authEvent.id] = AuthEventReceiptStatus.AUTHENTICATING + uniqueAuthChallengesSent.add(challengePair) + true + } else { + false + } + } + + fun checkAuthResults( + eventId: HexKey, + success: Boolean, + ): Boolean = + if (authResponseWatcher.containsKey(eventId)) { + val wasAlreadyAuthenticated = authResponseWatcher[eventId] + + if (success) { + authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED) + } else { + authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED) + } + + wasAlreadyAuthenticated != AuthEventReceiptStatus.AUTHENTICATED && success + } else { + false + } + + fun hasFinishedAllAuths() = authResponseWatcher.all { it.value != AuthEventReceiptStatus.AUTHENTICATING } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt new file mode 100644 index 000000000..0574ef147 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -0,0 +1,111 @@ +/** + * 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.nip01Core.relay.client.auth + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface IAuthStatus { + fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean +} + +object EmptyIAuthStatus : IAuthStatus { + override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = true +} + +class RelayAuthenticator( + val client: INostrClient, + val scope: CoroutineScope, + val signWithAllLoggedInUsers: suspend (EventTemplate) -> List, +) : IAuthStatus { + private val authStatus = mutableMapOf() + + private val clientListener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is AuthMessage -> authenticate(relay, msg) + is OkMessage -> checkAuthResults(relay, msg) + } + } + + override fun onConnecting(relay: IRelayClient) { + authStatus.put(relay.url, RelayAuthStatus()) + } + + override fun onDisconnected(relay: IRelayClient) { + authStatus.remove(relay.url) + } + } + + private fun authenticate( + relay: IRelayClient, + msg: AuthMessage, + ) { + scope.launch { + val ev = RelayAuthEvent.build(relay.url, msg.challenge) + signWithAllLoggedInUsers(ev).forEach { authEvent -> + // only send replies to new challenges to avoid infinite loop: + if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) { + relay.sendIfConnected(AuthCmd(authEvent)) + } + } + } + } + + private fun checkAuthResults( + relay: IRelayClient, + msg: OkMessage, + ) { + // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. + if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) { + client.renewFilters(relay) + } + } + + override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false + + init { + Log.d("RelayAuthenticator", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelayAuthenticator", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.kt new file mode 100644 index 000000000..e03634e23 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryState.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.quartz.nip01Core.relay.client.counts + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class CountQueryState { + // Logs the state of each channel to: + // 1. inform when an event is received as live + // 2. to block COUNTs being sent before finished (receiving an COUNT or Closed) + // + // If 2 happens, the relay might send multiple COUNTs in sequence + // for the same sub and we won't know which COUNT was it for. + private val queryStates = mutableMapOf() + private val filterStates = mutableMapOf>() + + fun currentFilters() = filterStates + + fun currentFilters(reference: T) = filterStates[reference] + + fun currentState(reference: T) = queryStates[reference] + + fun onCountReply(reference: T) { + queryStates.put(reference, CountQueryStatus.RECEIVED) + } + + fun onClosed(reference: T) { + queryStates.put(reference, CountQueryStatus.CLOSED) + } + + fun onQuery( + reference: T, + filters: List, + ) { + queryStates.put(reference, CountQueryStatus.SENT) + filterStates.put(reference, filters) + } + + fun onCloseQuery(reference: T) { + queryStates.put(reference, CountQueryStatus.CLOSED) + } + + fun connecting(reference: T) { + queryStates.remove(reference) + filterStates.remove(reference) + } + + fun disconnected(reference: T) { + queryStates.remove(reference) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt new file mode 100644 index 000000000..49789b5f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/CountQueryStatus.kt @@ -0,0 +1,27 @@ +/** + * 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.nip01Core.relay.client.counts + +enum class CountQueryStatus { + SENT, + RECEIVED, + CLOSED, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt new file mode 100644 index 000000000..f24cea6e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/counts/RelayActiveCountStates.kt @@ -0,0 +1,86 @@ +/** + * 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.nip01Core.relay.client.counts + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +class RelayActiveCountStates( + val client: INostrClient, +) { + private var queryStates = mutableMapOf>() + + fun subGetOrCreate(relay: NormalizedRelayUrl): CountQueryState = queryStates[relay] ?: CountQueryState().also { queryStates.put(relay, it) } + + private val clientListener = + object : IRelayClientListener { + override fun onConnecting(relay: IRelayClient) { + queryStates.put(relay.url, CountQueryState()) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is CountMessage -> subGetOrCreate(relay.url).onCountReply(msg.queryId) + is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId) + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + when (cmd) { + is ReqCmd -> subGetOrCreate(relay.url).onQuery(cmd.subId, cmd.filters) + is CloseCmd -> subGetOrCreate(relay.url).onCloseQuery(cmd.subId) + } + } + + override fun onDisconnected(relay: IRelayClient) { + queryStates.remove(relay.url) + } + } + + init { + Log.d("RelaySubStateMachine", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelaySubStateMachine", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt index 364776fe5..a74419013 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/IRelayClientListener.kt @@ -20,118 +20,55 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.listeners -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient - -enum class RelayState { - // Websocket connected - CONNECTED, - - // Websocket disconnecting - DISCONNECTING, - - // Websocket disconnected - DISCONNECTED, -} +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command interface IRelayClientListener { + fun onConnecting(relay: IRelayClient) {} + /** - * New Event arrives from the relay. + * Relay just connected. Use this to send all + * filters and events you need. */ - fun onEvent( + fun onConnected( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + pingMillis: Int, + compressed: Boolean, ) {} /** - * New EOSE command arrives for a subscription + * Triggers after the event has been sent. + * Success means that the event was successfully sent, not + * that it received receipt confirmation from the relay */ - fun onEOSE( + fun onSent( relay: IRelayClient, - subId: String, - arrivalTime: Long, + cmdStr: String, + cmd: Command, + success: Boolean, ) {} /** * New error */ - fun onError( + fun onIncomingMessage( relay: IRelayClient, - subId: String, - error: Error, + msgStr: String, + msg: Message, ) {} /** - * Relay is requesting authentication with the given challenge. + * Relay just diconnected. */ - fun onAuth( - relay: IRelayClient, - challenge: String, - ) {} + fun onDisconnected(relay: IRelayClient) {} /** - * called after the relay receives the OK from an Auth message + * The url is invalid or the server is unreachable. */ - fun onAuthed( + fun onCannotConnect( relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, - ) {} - - /** - * RelayState changes - */ - fun onRelayStateChange( - relay: IRelayClient, - type: RelayState, - ) {} - - /** - * NOTIFY command has arrived. - */ - fun onNotify( - relay: IRelayClient, - description: String, - ) {} - - /** - * Relay closed the subscription - */ - fun onClosed( - relay: IRelayClient, - subId: String, - message: String, - ) {} - - /** - * Triggers this before sending the event. - */ - fun onBeforeSend( - relay: IRelayClient, - event: Event, - ) {} - - /** - * Triggers after the event has been sent. - */ - fun onSend( - relay: IRelayClient, - msg: String, - success: Boolean, - ) {} - - /** - * Relay accepted or rejected the event - */ - fun onSendResponse( - relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, + errorMessage: String, ) {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt index 75c14b13c..5e42d4307 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RedirectRelayClientListener.kt @@ -20,75 +20,50 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.listeners -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command open class RedirectRelayClientListener( val listener: IRelayClientListener, ) : IRelayClientListener { - override fun onEvent( - relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) + override fun onConnecting(relay: IRelayClient) { + listener.onConnecting(relay) + } - override fun onEOSE( + override fun onConnected( relay: IRelayClient, - subId: String, - arrivalTime: Long, - ) = listener.onEOSE(relay, subId, arrivalTime) + pingMillis: Int, + compressed: Boolean, + ) { + listener.onConnected(relay, pingMillis, compressed) + } - override fun onError( + override fun onSent( relay: IRelayClient, - subId: String, - error: Error, - ) = listener.onError(relay, subId, error) - - override fun onAuth( - relay: IRelayClient, - challenge: String, - ) = listener.onAuth(relay, challenge) - - override fun onAuthed( - relay: IRelayClient, - eventId: String, + cmdStr: String, + cmd: Command, success: Boolean, - message: String, - ) = listener.onAuthed(relay, eventId, success, message) + ) { + listener.onSent(relay, cmdStr, cmd, success) + } - override fun onRelayStateChange( + override fun onIncomingMessage( relay: IRelayClient, - type: RelayState, - ) = listener.onRelayStateChange(relay, type) + msgStr: String, + msg: Message, + ) { + listener.onIncomingMessage(relay, msgStr, msg) + } - override fun onNotify( - relay: IRelayClient, - description: String, - ) = listener.onNotify(relay, description) + override fun onDisconnected(relay: IRelayClient) { + listener.onDisconnected(relay) + } - override fun onClosed( + override fun onCannotConnect( relay: IRelayClient, - subId: String, - message: String, - ) = listener.onClosed(relay, subId, message) - - override fun onBeforeSend( - relay: IRelayClient, - event: Event, - ) = listener.onBeforeSend(relay, event) - - override fun onSend( - relay: IRelayClient, - msg: String, - success: Boolean, - ) = listener.onSend(relay, msg, success) - - override fun onSendResponse( - relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, - ) = listener.onSendResponse(relay, eventId, success, message) + errorMessage: String, + ) { + listener.onCannotConnect(relay, errorMessage) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt new file mode 100644 index 000000000..723aca519 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/FiltersChanged.kt @@ -0,0 +1,83 @@ +/** + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +object FiltersChanged { + fun needsToResendRequest( + oldFilters: List, + newFilters: List, + ): Boolean { + if (oldFilters.size != newFilters.size) return true + + oldFilters.forEachIndexed { index, oldFilter -> + val newFilter = newFilters.getOrNull(index) ?: return true + + return needsToResendRequest(oldFilter, newFilter) + } + return false + } + + /** + * Checks if the filter has changed, with a special case for when the since changes due to new + * EOSE times. + */ + fun needsToResendRequest( + oldFilter: Filter, + newFilter: Filter, + ): Boolean { + // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. + // fast check + if (oldFilter.authors?.size != newFilter.authors?.size || + oldFilter.ids?.size != newFilter.ids?.size || + oldFilter.tags?.size != newFilter.tags?.size || + oldFilter.kinds?.size != newFilter.kinds?.size || + oldFilter.limit != newFilter.limit || + oldFilter.search?.length != newFilter.search?.length || + oldFilter.until != newFilter.until + ) { + return true + } + + // deep check + if (oldFilter.ids != newFilter.ids || + oldFilter.authors != newFilter.authors || + oldFilter.tags != newFilter.tags || + oldFilter.kinds != newFilter.kinds || + oldFilter.search != newFilter.search + ) { + return true + } + + if (oldFilter.since != null) { + if (newFilter.since == null) { + // went was checking the future only and now wants everything + return true + } else if (oldFilter.since > newFilter.since) { + // went backwards in time, forces update + return true + } + } + + return false + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt new file mode 100644 index 000000000..81d7789b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -0,0 +1,216 @@ +/** + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryState +import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryStatus +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.collections.plus + +class PoolCounts { + private var queries = LargeCache>>() + val relays = MutableStateFlow(setOf()) + + private val relayState = LargeCache>() + + fun subState(subId: String): CountQueryState = relayState.getOrCreate(subId) { CountQueryState() } + + private fun updateRelays() { + val myRelays = mutableSetOf() + queries.forEach { queryId, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (relays.value != myRelays) { + relays.tryEmit(myRelays) + } + } + + fun activeFiltersFor(url: NormalizedRelayUrl): Map> { + val myRelays = mutableMapOf>() + queries.forEach { sub, perRelayFilters -> + val filters = perRelayFilters.get(url) + if (filters != null) { + myRelays.put(sub, filters) + } + } + return myRelays + } + + /** + * Adds a new query to the pool, and returns the relays that need to be updated. + */ + fun addOrUpdate( + subscriptionId: String, + filters: Map>, + ): Set { + val oldRelays = queries.get(subscriptionId)?.keys ?: emptySet() + queries.put(subscriptionId, filters) + updateRelays() + return oldRelays + filters.keys + } + + /** + * Removes the query from the pool, and returns the relays that need to be updated. + */ + fun remove(queryId: String): Set = + if (queries.containsKey(queryId)) { + val oldRelays = queries.get(queryId)?.keys ?: emptySet() + queries.remove(queryId) + updateRelays() + oldRelays + } else { + emptySet() + } + + fun getSubscriptionFiltersOrNull(queryId: String): Map>? = queries.get(queryId) + + // -------------------------- + // State management functions + // -------------------------- + fun onConnecting(url: NormalizedRelayUrl) { + relayState.forEach { subId, state -> + state.connecting(url) + } + } + + suspend fun syncState( + relay: NormalizedRelayUrl, + sync: (Command) -> Unit, + ) { + queries.forEach { subId, filters -> + val filters = filters[relay] + if (!filters.isNullOrEmpty()) { + sync(CountCmd(subId, filters)) + } else { + null + } + } + } + + fun onSent( + relay: NormalizedRelayUrl, + cmd: Command, + ) { + when (cmd) { + is CountCmd -> subState(cmd.queryId).onQuery(relay, cmd.filters) + is CloseCmd -> subState(cmd.subId).onCloseQuery(relay) + } + } + + fun onIncomingMessage( + relay: IRelayClient, + msg: Message, + ) { + when (msg) { + is CountMessage -> { + subState(msg.queryId).onCountReply(relay.url) + sendToRelayIfChanged(msg.queryId, relay.url) { cmd -> + relay.sendOrConnectAndSync(cmd) + } + } + is ClosedMessage -> { + subState(msg.subId).onClosed(relay.url) + sendToRelayIfChanged(msg.subId, relay.url) { cmd -> + relay.sendOrConnectAndSync(cmd) + } + } + } + } + + fun onDisconnected(url: NormalizedRelayUrl) { + relayState.forEach { subId, state -> + state.disconnected(url) + } + } + + fun sendToRelayIfChanged( + queryId: String, + relaysToUpdate: Set, + sync: (NormalizedRelayUrl, Command) -> Unit, + ) { + relaysToUpdate.forEach { relay -> + val currentState = relayState.get(queryId)?.currentState(relay) + + if (currentState == CountQueryStatus.SENT) { + // sending multiple REQs triggers multiple EOSEs back and we then don't know which + // one is which. + } else { + sendToRelayIfChanged(queryId, relay) { cmd -> + sync(relay, cmd) + } + } + } + } + + fun sendToRelayIfChanged( + queryId: String, + relay: NormalizedRelayUrl, + sync: (Command) -> Unit, + ) { + val oldFilters = relayState.get(queryId)?.currentFilters(relay) + val newFilters = queries.get(queryId)?.get(relay) + sendToRelayIfChanged(queryId, oldFilters, newFilters, sync) + } + + fun sendToRelayIfChanged( + queryId: String, + oldFilters: List?, + newFilters: List?, + sync: (Command) -> Unit, + ) { + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + if (!oldFilters.isNullOrEmpty()) { + // only update if the old filters are not already closed. + sync(CloseCmd(queryId)) + } + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + sync(CountCmd(queryId, newFilters)) + } else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + sync(CountCmd(queryId, newFilters)) + } else { + // They are the same don't do anything. + } + } + + /** + * If cannot connect, closes subs + */ + fun onCannotConnect( + relay: NormalizedRelayUrl, + errorMessage: String, + ) { + // mark as impossible to get count from this relay + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt index cef3107a3..bea12865f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutbox.kt @@ -21,70 +21,131 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow -class PoolEventOutbox( - val event: Event, - var relays: Set, -) { - private val tries = mutableMapOf() +class PoolEventOutbox { + private var eventOutbox = mapOf() + val relays = MutableStateFlow(setOf()) - fun updateRelays(newRelays: Set) { - relays = newRelays - } + fun updateRelays() { + val myRelays = mutableSetOf() + eventOutbox.values.forEach { + myRelays.addAll(it.relaysLeft()) + } - fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false - - fun isDone() = relays.all { isDone(it) } - - fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } - - fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) - - fun forEachUnsentEvent( - url: NormalizedRelayUrl, - run: (url: Event) -> Unit, - ) = if (isSupposedToGo(url)) run(event) else null - - fun newTry(url: NormalizedRelayUrl) { - val currentTries = tries[url] - if (currentTries != null) { - currentTries.tries.add(TimeUtils.now()) - } else { - tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + if (relays.value != myRelays) { + relays.tryEmit(myRelays) } } + fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { + val myEvents = mutableSetOf() + eventOutbox.forEach { (eventId, outboxCache) -> + if (url in outboxCache.relays) { + myEvents.add(eventId) + } + } + return myEvents + } + + fun markAsSending( + event: Event, + relays: Set, + ): Set { + val currentOutbox = eventOutbox[event.id] + if (currentOutbox == null) { + eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(event, relays)) + } else { + currentOutbox.updateRelays(relays) + } + updateRelays() + return eventOutbox[event.id]?.remainingRelays() ?: emptySet() + } + + fun newTry( + id: HexKey, + url: NormalizedRelayUrl, + ) { + eventOutbox[id]?.newTry(url) + } + fun newResponse( + id: HexKey, url: NormalizedRelayUrl, success: Boolean, message: String, ) { - val currentTries = tries[url] - if (currentTries != null) { - currentTries.responses.add(Response(success, message)) - } else { - tries.put( - url, - Tries( - mutableListOf(TimeUtils.now() - 1), - mutableListOf(Response(success, message)), - ), - ) + val waiting = eventOutbox[id] + if (waiting != null) { + waiting.newResponse(url, success, message) + clear() } } - // Tries 3 times - class Tries( - val tries: MutableList = mutableListOf(), - val responses: MutableList = mutableListOf(), - ) { - fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + fun clear() { + eventOutbox = eventOutbox.filter { !it.value.isDone() } + updateRelays() } - class Response( - val success: Boolean, - val message: String, - ) + // -------------------------- + // State management functions + // -------------------------- + suspend fun syncState( + relay: NormalizedRelayUrl, + sync: (Command) -> Unit, + ) { + eventOutbox.forEach { + it.value.forEachUnsentEvent(relay) { + sync(EventCmd(it)) + } + } + } + + fun onIncomingMessage( + relay: NormalizedRelayUrl, + msg: Message, + ) { + when (msg) { + is OkMessage -> newResponse(msg.eventId, relay, msg.success, msg.message) + } + } + + fun onSent( + relay: NormalizedRelayUrl, + cmd: Command, + ) { + if (cmd is EventCmd) { + newTry(cmd.event.id, relay) + } + } + + fun sendToRelayIfChanged( + event: Event, + relaysToUpdate: Set, + sync: (NormalizedRelayUrl, Command) -> Unit, + ) { + relaysToUpdate.forEach { relay -> + sync(relay, EventCmd(event)) + } + } + + /** + * If cannot connect, closes subs + */ + fun onCannotConnect( + relay: NormalizedRelayUrl, + errorMessage: String, + ) { + eventOutbox.forEach { + if (relay in it.value.relays) { + newResponse(it.key, relay, false, errorMessage) + } + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt deleted file mode 100644 index 67356b2ed..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxRepository.kt +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 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.nip01Core.relay.client.pool - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import kotlinx.coroutines.flow.MutableStateFlow - -class PoolEventOutboxRepository { - private var eventOutbox = mapOf() - val relays = MutableStateFlow(setOf()) - - fun updateRelays() { - val myRelays = mutableSetOf() - eventOutbox.values.forEach { - myRelays.addAll(it.relaysLeft()) - } - - if (relays.value != myRelays) { - relays.tryEmit(myRelays) - } - } - - fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set { - val myEvents = mutableSetOf() - eventOutbox.forEach { (eventId, outboxCache) -> - if (url in outboxCache.relays) { - myEvents.add(eventId) - } - } - return myEvents - } - - fun markAsSending( - event: Event, - relays: Set, - ) { - val currentOutbox = eventOutbox[event.id] - if (currentOutbox == null) { - eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays)) - } else { - currentOutbox.updateRelays(relays) - } - updateRelays() - } - - fun newTry( - id: HexKey, - url: NormalizedRelayUrl, - ) { - eventOutbox[id]?.newTry(url) - } - - fun newResponse( - id: HexKey, - url: NormalizedRelayUrl, - success: Boolean, - message: String, - ) { - val waiting = eventOutbox[id] - if (waiting != null) { - waiting.newResponse(url, success, message) - clear() - } - } - - fun clear() { - eventOutbox = eventOutbox.filter { !it.value.isDone() } - updateRelays() - } - - fun forEachUnsentEvent( - url: NormalizedRelayUrl, - run: (url: Event) -> Unit, - ) { - eventOutbox.forEach { - it.value.forEachUnsentEvent(url, run) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt new file mode 100644 index 000000000..195c3f909 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -0,0 +1,92 @@ +/** + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +class PoolEventOutboxState( + val event: Event, + var relays: Set, +) { + private val tries = mutableMapOf() + + fun updateRelays(newRelays: Set) { + relays = newRelays + } + + fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false + + fun isDone() = relays.all { isDone(it) } + + fun relaysLeft(): Set = relays.filterTo(mutableSetOf()) { !isDone(it) } + + fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url) + + fun forEachUnsentEvent( + url: NormalizedRelayUrl, + run: (url: Event) -> Unit, + ) = if (isSupposedToGo(url)) run(event) else null + + fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo) + + fun newTry(url: NormalizedRelayUrl) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.tries.add(TimeUtils.now()) + } else { + tries.put(url, Tries(mutableListOf(TimeUtils.now()))) + } + } + + fun newResponse( + url: NormalizedRelayUrl, + success: Boolean, + message: String, + ) { + val currentTries = tries[url] + if (currentTries != null) { + currentTries.responses.add(Response(success, message)) + } else { + tries.put( + url, + Tries( + mutableListOf(TimeUtils.now() - 1), + mutableListOf(Response(success, message)), + ), + ) + } + } + + // Tries 3 times + class Tries( + val tries: MutableList = mutableListOf(), + val responses: MutableList = mutableListOf(), + ) { + fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3 + } + + class Response( + val success: Boolean, + val message: String, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt new file mode 100644 index 000000000..0e505b132 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -0,0 +1,278 @@ +/** + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.ReqSubStatus +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow + +class PoolRequests { + /** + * Desired subs and listeners are changed immediately when the local code requests + */ + private val desiredSubs = LargeCache>>() + private val desiredSubListeners = LargeCache() + val desiredRelays = MutableStateFlow(setOf()) + + /** + * relay states are kept and only removed after everything is processed. + */ + private val relayState = LargeCache>() + + fun subState(subId: String): RequestSubscriptionState = relayState.getOrCreate(subId) { RequestSubscriptionState() } + + private fun updateRelays() { + val myRelays = mutableSetOf() + desiredSubs.forEach { sub, perRelayFilters -> + myRelays.addAll(perRelayFilters.keys) + } + + if (desiredRelays.value != myRelays) { + desiredRelays.tryEmit(myRelays) + } + } + + fun activeFiltersFor(url: NormalizedRelayUrl): Map> { + val myRelays = mutableMapOf>() + desiredSubs.forEach { sub, perRelayFilters -> + val filters = perRelayFilters[url] + if (filters != null) { + myRelays.put(sub, filters) + } + } + return myRelays + } + + /** + * Adds a new filter to the pool, and returns the relays that need to be updated. + */ + fun addOrUpdate( + subId: String, + filters: Map>, + listener: IRequestListener?, + ): Set { + // saves old relays + val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet() + // update filters + desiredSubs.put(subId, filters) + // update listener + if (listener != null) { + desiredSubListeners.put(subId, listener) + } + // create sub state + subState(subId) + // update relays for pool + updateRelays() + // return all affected relays + return oldRelays + filters.keys + } + + /** + * Removes the sub from the pool, and returns the relays that need to be updated. + */ + fun remove(subId: String): Set = + if (desiredSubs.containsKey(subId)) { + // saves old relays + val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet() + // update filters + desiredSubs.remove(subId) + // update listener + desiredSubListeners.remove(subId) + // remove states + relayState.remove(subId) + // update relays for pool + updateRelays() + // return all affected relays + oldRelays + } else { + emptySet() + } + + fun getSubscriptionFiltersOrNull(subId: String): Map>? = desiredSubs.get(subId) + + // -------------------------- + // State management functions + // -------------------------- + fun onConnecting(url: NormalizedRelayUrl) { + relayState.forEach { subId, state -> + state.connecting(url) + } + } + + fun syncState( + relay: NormalizedRelayUrl, + sync: (Command) -> Unit, + ) { + desiredSubs.forEach { subId, filters -> + val filters = filters[relay] + if (!filters.isNullOrEmpty()) { + sync(ReqCmd(subId, filters)) + } else { + null + } + } + } + + fun onSent( + relay: NormalizedRelayUrl, + cmd: Command, + ) { + when (cmd) { + is ReqCmd -> subState(cmd.subId).onOpenReq(relay, cmd.filters) + is CloseCmd -> subState(cmd.subId).onCloseReq(relay) + } + } + + fun onIncomingMessage( + relay: IRelayClient, + msg: Message, + ) { + when (msg) { + is EventMessage -> { + val state = relayState.get(msg.subId) + state?.onNewEvent(relay.url) + desiredSubListeners.get(msg.subId)?.onEvent( + event = msg.event, + isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE, + relay = relay.url, + forFilters = state?.currentFilters(relay.url), + ) + } + is EoseMessage -> { + val state = relayState.get(msg.subId) + state?.onEose(relay.url) + desiredSubListeners.get(msg.subId)?.onEose( + relay = relay.url, + forFilters = state?.currentFilters(relay.url), + ) + + // send a newer version when done + sendToRelayIfChanged(msg.subId, relay.url) { cmd -> + relay.sendOrConnectAndSync(cmd) + } + } + is ClosedMessage -> { + val state = relayState.get(msg.subId) + state?.onClosed(relay.url) + + desiredSubListeners.get(msg.subId)?.onClosed( + message = msg.message, + relay = relay.url, + forFilters = state?.currentFilters(relay.url), + ) + + // send a newer version when done + sendToRelayIfChanged(msg.subId, relay.url) { cmd -> + // don't send a close if just closed + if (cmd !is CloseCmd) { + relay.sendOrConnectAndSync(cmd) + } + } + } + } + } + + fun onDisconnected(url: NormalizedRelayUrl) { + relayState.forEach { subId, state -> + state.disconnected(url) + } + } + + /** + * If cannot connect, closes subs + */ + fun onCannotConnect( + url: NormalizedRelayUrl, + errorMessage: String, + ) { + relayState.forEach { subId, state -> + desiredSubListeners.get(subId)?.onCannotConnect( + message = errorMessage, + relay = url, + forFilters = state.currentFilters(url), + ) + } + } + + fun sendToRelayIfChanged( + subId: String, + relaysToUpdate: Set, + sync: (NormalizedRelayUrl, Command) -> Unit, + ) { + relaysToUpdate.forEach { relay -> + val currentState = relayState.get(subId)?.currentState(relay) + + if (currentState == ReqSubStatus.SENT || currentState == ReqSubStatus.QUERYING_PAST) { + // sending multiple REQs triggers multiple EOSEs back and we then don't know which + // one is which. + } else { + sendToRelayIfChanged(subId, relay) { cmd -> + sync(relay, cmd) + } + } + } + } + + fun sendToRelayIfChanged( + subId: String, + relay: NormalizedRelayUrl, + sync: (Command) -> Unit, + ) { + val oldFilters = relayState.get(subId)?.currentFilters(relay) + val newFilters = desiredSubs.get(subId)?.get(relay) + sendToRelayIfChanged(subId, oldFilters, newFilters, sync) + } + + fun sendToRelayIfChanged( + subId: String, + oldFilters: List?, + newFilters: List?, + sync: (Command) -> Unit, + ) { + if (newFilters.isNullOrEmpty()) { + // some relays are not in this sub anymore. Stop their subscriptions + if (!oldFilters.isNullOrEmpty()) { + // only update if the old filters are not already closed. + sync(CloseCmd(subId)) + } + } else if (oldFilters.isNullOrEmpty()) { + // new relays were added. Start a new sub in them + sync(ReqCmd(subId, newFilters)) + } else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) { + // filters were changed enough (not only an update in since) to warn a new update + sync(ReqCmd(subId, newFilters)) + } else { + // They are the same don't do anything. + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt deleted file mode 100644 index 62b353a12..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolSubscriptionRepository.kt +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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.nip01Core.relay.client.pool - -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.cache.LargeCache -import kotlinx.coroutines.flow.MutableStateFlow - -class PoolSubscriptionRepository { - private var subscriptions = LargeCache>>() - val relays = MutableStateFlow(setOf()) - - fun updateRelays() { - val myRelays = mutableSetOf() - subscriptions.forEach { sub, perRelayFilters -> - myRelays.addAll(perRelayFilters.keys) - } - - if (relays.value != myRelays) { - relays.tryEmit(myRelays) - } - } - - fun activeFiltersFor(url: NormalizedRelayUrl): Map> { - val myRelays = mutableMapOf>() - subscriptions.forEach { sub, perRelayFilters -> - val filters = perRelayFilters.get(url) - if (filters != null) { - myRelays.put(sub, filters) - } - } - return myRelays - } - - fun addOrUpdate( - subscriptionId: String, - filters: Map>, - ) { - subscriptions.put(subscriptionId, filters) - updateRelays() - } - - fun remove(subscriptionId: String) { - if (subscriptions.containsKey(subscriptionId)) { - subscriptions.remove(subscriptionId) - updateRelays() - } - } - - fun forEachSub( - relay: NormalizedRelayUrl, - run: (String, List) -> Unit, - ) { - subscriptions.forEach { subId, filters -> - val filters = filters[relay] - if (!filters.isNullOrEmpty()) { - run(subId, filters) - } else { - null - } - } - } - - fun getSubscriptionFiltersOrNull(subId: String): Map>? = subscriptions.get(subId) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index d91cb08d9..40f6b9c4d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -21,22 +21,19 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = { - throw UnsupportedOperationException("Cannot create new relays") -} - /** * RelayPool manages a collection of Nostr relays, abstracting individual connections and providing * unified methods for sending events, managing subscriptions, and tracking relay states. @@ -59,8 +56,8 @@ val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = { * - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods) */ class RelayPool( + val websocketBuilder: WebsocketBuilder, val listener: IRelayClientListener = EmptyClientListener, - val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation, ) : IRelayClientListener { private val relays = LargeCache() @@ -70,6 +67,13 @@ class RelayPool( fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) + private fun createNewRelay(url: NormalizedRelayUrl) = + BasicRelayClient( + url = url, + socketBuilder = websocketBuilder, + listener = this, + ) + fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) { relays.forEach { url, relay -> if (relay.isConnected()) { @@ -109,71 +113,40 @@ class RelayPool( updateStatus() } - fun sendRequest( + fun sendOrConnectAndSync( relay: NormalizedRelayUrl, - subId: String, - filters: List, - ) { - getOrCreateRelay(relay).sendRequest(subId, filters) - } + cmd: Command, + ) = getOrCreateRelay(relay).sendOrConnectAndSync(cmd) - fun sendRequest( - subId: String, - filters: Map>, - ) { - relays.forEach { url, relay -> - val filters = filters[relay.url] - if (!filters.isNullOrEmpty()) { - relay.sendRequest(subId, filters) - } - } - } - - fun sendCount( + fun sendIfConnected( relay: NormalizedRelayUrl, - subId: String, - filters: List, - ) { - getOrCreateRelay(relay).sendCount(subId, filters) - } + cmd: Command, + ) = getOrCreateRelay(relay).sendIfConnected(cmd) - fun sendCount( - subId: String, - filters: Map>, - ) { - relays.forEach { url, relay -> - val filters = filters[relay.url] - if (!filters.isNullOrEmpty()) { - relay.sendCount(subId, filters) - } - } - } - - fun close(subscriptionId: String) = - relays.forEach { url, relay -> - relay.close(subscriptionId) - } - - fun close( - relay: NormalizedRelayUrl, - subscriptionId: String, - ) = relays.get(relay)?.close(subscriptionId) - - fun send( - signedEvent: Event, + fun sendOrConnectAndSync( list: Set, + cmd: Command, ) { list.forEach { - getOrCreateRelay(it).send(signedEvent) + getOrCreateRelay(it).sendOrConnectAndSync(cmd) + } + } + + fun sendIfConnected( + list: Set, + cmd: Command, + ) { + list.forEach { + getOrCreateRelay(it).sendIfConnected(cmd) } } // -------------------- // Pool Maintenance // -------------------- - fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay) + fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, ::createNewRelay) - fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay) + fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, ::createNewRelay) /** * Updates the pool of relays without disconnecting the existing ones. @@ -244,75 +217,39 @@ class RelayPool( // -------------------- // Listener Interceptor // -------------------- + override fun onConnecting(relay: IRelayClient) = listener.onConnecting(relay) - override fun onEvent( + override fun onConnected( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + pingMillis: Int, + compressed: Boolean, ) { - listener.onEvent(relay, subId, event, arrivalTime, afterEOSE) - } - - override fun onError( - relay: IRelayClient, - subId: String, - error: Error, - ) { - listener.onError(relay, subId, error) updateStatus() + listener.onConnected(relay, pingMillis, compressed) } - override fun onEOSE( - relay: IRelayClient, - subId: String, - arrivalTime: Long, - ) { - listener.onEOSE(relay, subId, arrivalTime) - } - - override fun onRelayStateChange( - relay: IRelayClient, - type: RelayState, - ) { - listener.onRelayStateChange(relay, type) + override fun onDisconnected(relay: IRelayClient) { updateStatus() + listener.onDisconnected(relay) } - override fun onSendResponse( + override fun onIncomingMessage( relay: IRelayClient, - eventId: String, + msgStr: String, + msg: Message, + ) = listener.onIncomingMessage(relay, msgStr, msg) + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) = listener.onCannotConnect(relay, errorMessage) + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, success: Boolean, - message: String, - ) = listener.onSendResponse(relay, eventId, success, message) - - override fun onAuth( - relay: IRelayClient, - challenge: String, - ) = listener.onAuth(relay, challenge) - - override fun onNotify( - relay: IRelayClient, - description: String, - ) = listener.onNotify(relay, description) - - override fun onClosed( - relay: IRelayClient, - subId: String, - message: String, - ) = listener.onClosed(relay, subId, message) - - override fun onSend( - relay: IRelayClient, - msg: String, - success: Boolean, - ) = listener.onSend(relay, msg, success) - - override fun onBeforeSend( - relay: IRelayClient, - event: Event, - ) = listener.onBeforeSend(relay, event) + ) = listener.onSent(relay, cmdStr, cmd, success) // --------------- // STATUS Reports diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt similarity index 55% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt index 6064dbc2a..2acd595ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/SimpleRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt @@ -18,29 +18,34 @@ * 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.nip01Core.relay.client.single.simple +package com.vitorpamplona.quartz.nip01Core.relay.client.reqs -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -/** - * This relay client saves any event that will be sent in an outbox - * waits for auth and sends it again to make sure it is delivered. - */ -class SimpleRelayClient( - url: NormalizedRelayUrl, - socketBuilder: WebsocketBuilder, - listener: IRelayClientListener, - stats: RelayStat = RelayStat(), - defaultOnConnect: (BasicRelayClient) -> Unit = { }, -) : IRelayClient by BasicRelayClient( - url, - socketBuilder, - OutboxCache(listener), - stats, - defaultOnConnect, - ) +interface IRequestListener { + fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + + fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + + fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + + fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) {} +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt new file mode 100644 index 000000000..b27dc1efd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.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.nip01Core.relay.client.reqs + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +class RelayActiveRequestStates( + val client: INostrClient, +) { + private var subStates = mutableMapOf>() + + fun subGetOrCreate(relay: NormalizedRelayUrl): RequestSubscriptionState = subStates[relay] ?: RequestSubscriptionState().also { subStates.put(relay, it) } + + private val clientListener = + object : IRelayClientListener { + override fun onConnecting(relay: IRelayClient) { + subStates.put(relay.url, RequestSubscriptionState()) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is EventMessage -> subGetOrCreate(relay.url).onNewEvent(msg.subId) + is EoseMessage -> subGetOrCreate(relay.url).onEose(msg.subId) + is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId) + } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + when (cmd) { + is ReqCmd -> subGetOrCreate(relay.url).onOpenReq(cmd.subId, cmd.filters) + is CloseCmd -> subGetOrCreate(relay.url).onCloseReq(cmd.subId) + } + } + + override fun onDisconnected(relay: IRelayClient) { + subStates.remove(relay.url) + } + } + + init { + Log.d("RelaySubStateMachine", "Init, Subscribe") + client.subscribe(clientListener) + } + + fun destroy() { + // makes sure to run + Log.d("RelaySubStateMachine", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt new file mode 100644 index 000000000..17fc06d00 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/ReqSubStatus.kt @@ -0,0 +1,28 @@ +/** + * 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.nip01Core.relay.client.reqs + +enum class ReqSubStatus { + SENT, + QUERYING_PAST, + LIVE, + CLOSED, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt new file mode 100644 index 000000000..f2c7b253b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt @@ -0,0 +1,77 @@ +/** + * 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.nip01Core.relay.client.reqs + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class RequestSubscriptionState { + // Logs the state of each channel to: + // 1. inform when an event is received as live + // 2. to block REQs being sent before finished (receiving an EOSE or Closed) + // + // If 2 happens, the relay might send multiple EOSEs in sequence + // for the same sub and we won't know which REQ was it for. + private val subStates = mutableMapOf() + private val filterStates = mutableMapOf>() + + fun currentFilters() = filterStates + + fun currentFilters(reference: T) = filterStates[reference] + + fun currentState(reference: T) = subStates[reference] + + fun onNewEvent(reference: T) { + if (subStates[reference] == ReqSubStatus.SENT) { + subStates.put(reference, ReqSubStatus.QUERYING_PAST) + } + } + + fun onEose(reference: T) { + subStates.put(reference, ReqSubStatus.LIVE) + } + + fun onClosed(reference: T) { + subStates.put(reference, ReqSubStatus.CLOSED) + } + + fun onOpenReq( + reference: T, + filters: List, + ) { + subStates.put(reference, ReqSubStatus.SENT) + filterStates.put(reference, filters) + } + + fun onCloseReq(reference: T) { + subStates.put(reference, ReqSubStatus.CLOSED) + } + + fun connecting(reference: T) { + subStates.remove(reference) + filterStates.remove(reference) + } + + fun disconnected(reference: T) { + // Can't remove filterStates because disconnections happen + // before processing all the events in the channel queue. + subStates.remove(reference) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt index 546c8b0f7..8f0a2e221 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/RelayReqStats.kt @@ -18,54 +18,50 @@ * 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.service.relayClient +package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.utils.Log /** * Listens to NostrClient's onNotify messages from the relay */ -class RelayLogger( +class RelayReqStats( val client: INostrClient, ) { - companion object { - val TAG = RelayLogger::class.java.simpleName - } + private val stats = ReqStatsRepository() private val clientListener = object : IRelayClientListener { - /** A new message was received */ - override fun onEvent( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + msgStr: String, + msg: Message, ) { - Log.d(TAG, "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}") - } - - override fun onSend( - relay: IRelayClient, - msg: String, - success: Boolean, - ) { - Log.d(TAG, "Relay send ${relay.url} (${msg.length} chars) $msg") + super.onIncomingMessage(relay, msgStr, msg) + if (msg is EventMessage) { + stats.add(msg.subId, msg.event.kind) + } } } + fun printStats() = + stats.printCounter { subId, kind, counter -> + Log.d("RelaySubStats", "$subId, kind $kind: $counter") + } + init { - Log.d(TAG, "Init, Subscribe") + Log.d("RelaySubStats", "Init, Subscribe") client.subscribe(clientListener) } fun destroy() { // makes sure to run - Log.d(TAG, "Destroy, Unsubscribe") + Log.d("RelaySubStats", "Destroy, Unsubscribe") client.unsubscribe(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt index 246e2cbb9..881a7c208 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/stats/ReqStatsRepository.kt @@ -18,12 +18,11 @@ * 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.nip01Core.relay.client.subscriptions +package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache -class SubscriptionStats { +class ReqStatsRepository { data class Counter( val subscriptionId: String, val eventKind: Int, @@ -47,12 +46,9 @@ class SubscriptionStats { stats.counter++ } - fun printCounter(tag: String) { + fun printCounter(action: (subId: String, kind: Int, counter: Int) -> Unit) { eventCounter.forEach { _, stats -> - Log.d( - tag, - "Received Events ${stats.subscriptionId} ${stats.eventKind}: ${stats.counter}", - ) + action(stats.subscriptionId, stats.eventKind, stats.counter) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt index dc2f6e268..ebca4725a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/IRelayClient.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.single -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent interface IRelayClient { val url: NormalizedRelayUrl @@ -32,29 +30,13 @@ interface IRelayClient { fun needsToReconnect(): Boolean - fun connectAndRunAfterSync(onConnected: () -> Unit) - fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean = false) fun isConnected(): Boolean - fun sendRequest( - subId: String, - filters: List, - ) + fun sendOrConnectAndSync(cmd: Command) - fun sendCount( - subId: String, - filters: List, - ) - - fun send(event: Event) - - fun sendAuth(signedEvent: RelayAuthEvent) - - fun sendEvent(event: Event) - - fun close(subscriptionId: String) + fun sendIfConnected(cmd: Command) fun disconnect() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 62efdae2d..4dfb17704 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -20,37 +20,18 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_SECS import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import com.vitorpamplona.quartz.utils.bytesUsedInMemory import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.cancellation.CancellationException @@ -62,8 +43,6 @@ import kotlin.coroutines.cancellation.CancellationException * @property url The relay's normalized URL. * @property socketBuilder Provides the WebSocket instance for connection. * @property listener Interface to notify the application of relay events and errors. - * @property stats Tracks operational statistics of the relay connection. - * @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic. * * Reconnection Strategy: * - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_SECS] (500ms). @@ -78,28 +57,25 @@ open class BasicRelayClient( override val url: NormalizedRelayUrl, val socketBuilder: WebsocketBuilder, val listener: IRelayClientListener, - val stats: RelayStat = RelayStat(), - val defaultOnConnect: (BasicRelayClient) -> Unit = { }, ) : IRelayClient { companion object { // minimum wait time to reconnect: 1 second const val DELAY_TO_RECONNECT_IN_SECS = 1 } - private val logTag = "Relay ${url.displayUrl()}" - private var socket: WebSocket? = null + + // True if it has received the onOpen call from the socket. private var isReady: Boolean = false private var usingCompression: Boolean = false + // keeps increasing the delay to connect when errors happen. + // This avoids the constant desire to connect when the server is + // having trouble or offline. private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time. private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS - private var afterEOSEPerSubscription = mutableMapOf() - - private val authResponseWatcher = mutableMapOf() - private val authChallengesSent = mutableSetOf() - + // Makes sure only one socket is open for each url private var connectingMutex = AtomicBoolean(false) fun isConnectionStarted(): Boolean = socket != null @@ -108,19 +84,7 @@ open class BasicRelayClient( override fun needsToReconnect() = socket?.needsReconnect() ?: true - override fun connect() = - connectAndRunOverride { - defaultOnConnect(this) - } - - override fun connectAndRunAfterSync(onConnected: () -> Unit) { - connectAndRunOverride { - defaultOnConnect(this) - onConnected() - } - } - - fun connectAndRunOverride(onConnected: () -> Unit) { + override fun connect() { // If there is a connection, don't wait. if (connectingMutex.exchange(true)) { return @@ -132,52 +96,49 @@ open class BasicRelayClient( return } - Log.d(logTag, "Connecting...") + listener.onConnecting(this) lastConnectTentativeInSeconds = TimeUtils.now() - socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) + socket = socketBuilder.build(url, MyWebsocketListener()) socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e - - Log.w(logTag, "Crash before connecting", e) - stats.newError(e.message ?: "Error trying to connect: $e") - + listener.onCannotConnect(this, "Error when trying to connect: ${e.message}") + listener.onDisconnected(this) + dontTryAgainForALongTime() markConnectionAsClosed() } finally { connectingMutex.store(false) } } - inner class MyWebsocketListener( - val onConnected: () -> Unit, - ) : WebSocketListener { + inner class MyWebsocketListener : WebSocketListener { override fun onOpen( pingMillis: Int, compression: Boolean, ) { - Log.d(logTag, "OnOpen (ping: ${pingMillis}ms${if (compression) ", using compression" else ""})") - - markConnectionAsReady(pingMillis, compression) - - onConnected() - - listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) + markConnectionAsReady(compression) + listener.onConnected(this@BasicRelayClient, pingMillis, compression) } override fun onMessage(text: String) { - consumeIncomingMessage(text, onConnected) + try { + val msg = OptimizedJsonMapper.fromJsonTo(text) + listener.onIncomingMessage(this@BasicRelayClient, text, msg) + } catch (e: Throwable) { + if (e is CancellationException) throw e + // doesn't expose parsing errors to lib users as errors + Log.e("BasicRelayClient", "Failure to parse message from Relay", e) + } } override fun onClosed( code: Int, reason: String, ) { - Log.w(logTag, "OnClosed $reason") - markConnectionAsClosed() - listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) + listener.onDisconnected(this@BasicRelayClient) } override fun onFailure( @@ -190,192 +151,65 @@ open class BasicRelayClient( if (socket == null) { // comes from the disconnect action. - listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) + listener.onDisconnected(this@BasicRelayClient) } else { + socket?.disconnect() + + val msg = t.message + // checks if this is an actual failure. Closing the socket generates an onFailure as well. - if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { - stats.newError(response ?: t.message ?: "onFailure event from server: $t") + // ignore tor errors. + if (msg == null || + ( + !msg.startsWith("failed to connect to /127.0.0.1") && + msg != "Socket closed" && + msg != "Socket is closed" && + msg != "Cancelled" + ) + ) { + if (code != null || response != null) { + listener.onCannotConnect(this@BasicRelayClient, "Server Misconfigured. Response: $code $response. Exception: ${t.message}") + } else { + listener.onCannotConnect(this@BasicRelayClient, "WebSocket Failure: ${t.message}") + } + } else { + // ignore local disconnect requests and tor errors + // listener.onServerError(this@BasicRelayClient, Error("Ignored Error $code $response. Exception: ${t.message}")) } // Failures disconnect the relay. markConnectionAsClosed() - Log.w(logTag, "OnFailure $code $response ${t.message}") - - if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") { + if (code != null || t.message?.endsWith("Host unreachable") == true) { dontTryAgainForALongTime() } - listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) - listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t)) + listener.onDisconnected(this@BasicRelayClient) } } } - fun consumeIncomingMessage( - text: String, - onConnected: () -> Unit, - ) { - // Log.d(logTag, "Processing: $text") - stats.addBytesReceived(text.bytesUsedInMemory()) - - try { - when (val msg = OptimizedJsonMapper.fromJsonTo(text)) { - is EventMessage -> processEvent(msg) - is EoseMessage -> processEose(msg) - is NoticeMessage -> processNotice(msg) - is OkMessage -> processOk(msg, onConnected) - is AuthMessage -> processAuth(msg) - is NotifyMessage -> processNotify(msg) - is ClosedMessage -> processClosed(msg) - else -> processUnknownMessage(text) - } - } catch (e: Throwable) { - if (e is CancellationException) throw e - stats.newError("Error processing: $text") - Log.e(logTag, "Error processing: $text", e) - listener.onError(this@BasicRelayClient, "", Error("Error processing $text")) - } - } - - fun markConnectionAsReady( - pingInMs: Int, - usingCompression: Boolean, - ) { - this.resetEOSEStatuses() + fun markConnectionAsReady(usingCompression: Boolean) { this.isReady = true this.usingCompression = usingCompression // resets any extra delays added during on offline state this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS - - stats.pingInMs = pingInMs } fun markConnectionAsClosed() { this.socket = null this.isReady = false this.usingCompression = false - this.resetEOSEStatuses() - } - - private fun processEvent(msg: EventMessage) { - // Log.w(logTag, "Event ${msg.subId} ${msg.event.toJson()}") - listener.onEvent( - relay = this, - subId = msg.subId, - event = msg.event, - arrivalTime = TimeUtils.now(), - afterEOSE = afterEOSEPerSubscription[msg.subId] == true, - ) - } - - private fun processEose(msg: EoseMessage) { - Log.d(logTag, "EOSE ${msg.subId}") - afterEOSEPerSubscription[msg.subId] = true - listener.onEOSE(this, msg.subId, TimeUtils.now()) - } - - private fun processNotice(msg: NoticeMessage) { - Log.w(logTag, "Notice ${msg.message}") - stats.newNotice(msg.message) - listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message")) - } - - private fun processOk( - msg: OkMessage, - onConnected: () -> Unit, - ) { - Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") - - // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authResponseWatcher.containsKey(msg.eventId)) { - val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId] - authResponseWatcher.put(msg.eventId, msg.success) - if (wasAlreadyAuthenticated != true && msg.success) { - onConnected() - listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message) - } - } - - if (!msg.success) { - stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}") - } - - listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message) - } - - private fun processAuth(msg: AuthMessage) { - Log.d(logTag, "Auth ${msg.challenge}") - listener.onAuth(this@BasicRelayClient, msg.challenge) - } - - private fun processNotify(msg: NotifyMessage) { - // Log.w(logTag, "Notify $newMessage") - listener.onNotify(this@BasicRelayClient, msg.message) - } - - private fun processClosed(msg: ClosedMessage) { - Log.w(logTag, "Relay Closed Subscription ${msg.subId} ${msg.message}") - stats.newNotice("Subscription closed: ${msg.subId} ${msg.message}") - afterEOSEPerSubscription[msg.subId] = false - listener.onClosed(this@BasicRelayClient, msg.subId, msg.message) - } - - private fun processUnknownMessage(newMessage: String) { - stats.newError("Unsupported message: $newMessage") - Log.w(logTag, "Unsupported message: $newMessage") - listener.onError(this, "", Error("Unsupported message: $newMessage")) } override fun disconnect() { - Log.d(logTag, "Disconnecting...") lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested. delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS socket?.disconnect() socket = null isReady = false usingCompression = false - resetEOSEStatuses() - } - - fun resetEOSEStatuses() { - afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size) - - authResponseWatcher.clear() - authChallengesSent.clear() - } - - override fun sendRequest( - subId: String, - filters: List, - ) { - if (isConnectionStarted()) { - if (isReady) { - if (filters.isNotEmpty()) { - afterEOSEPerSubscription[subId] = false - writeToSocket(ReqCmd(subId, filters)) - } - } - } else { - connectAndSyncFiltersIfDisconnected() - } - } - - override fun sendCount( - subId: String, - filters: List, - ) { - if (isConnectionStarted()) { - if (isReady) { - if (filters.isNotEmpty()) { - afterEOSEPerSubscription[subId] = false - writeToSocket(CountCmd(subId, filters)) - } - } - } else { - connectAndSyncFiltersIfDisconnected() - } } override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { @@ -398,62 +232,21 @@ open class BasicRelayClient( delayToConnectInSeconds = TimeUtils.ONE_DAY } - override fun send(event: Event) { - listener.onBeforeSend(this@BasicRelayClient, event) - - if (event is RelayAuthEvent) { - sendAuth(event) - } else { - sendEvent(event) - } - } - - override fun sendAuth(signedEvent: RelayAuthEvent) { - val challenge = signedEvent.challenge() - - // only send replies to new challenges to avoid infinite loop: - if (challenge != null && challenge !in authChallengesSent) { - authResponseWatcher[signedEvent.id] = false - authChallengesSent.add(challenge) - writeToSocket(AuthCmd(signedEvent)) - } - } - - override fun sendEvent(event: Event) { + override fun sendOrConnectAndSync(cmd: Command) { if (isConnectionStarted()) { - if (isReady) { - writeToSocket(EventCmd(event)) + if (isReady && cmd.isValid()) { + sendIfConnected(cmd) } } else { connectAndSyncFiltersIfDisconnected() } } - private fun writeToSocket(cmd: Command) { - writeToSocket(OptimizedJsonMapper.toJson(cmd)) - } - - private fun writeToSocket(str: String) { - if (socket == null) { - listener.onError( - this@BasicRelayClient, - "", - Error("Failed to send $str. Relay is not connected."), - ) - } + override fun sendIfConnected(cmd: Command) { socket?.let { - // Log.d(logTag, "Sending (${str.length} chars): $str") - val result = it.send(str) - listener.onSend(this@BasicRelayClient, str, result) - stats.addBytesSent(str.bytesUsedInMemory()) - } - } - - override fun close(subscriptionId: String) { - // avoids sending closes for subscriptions that were never sent to this relay. - if (afterEOSEPerSubscription.containsKey(subscriptionId)) { - writeToSocket(CloseCmd(subscriptionId)) - afterEOSEPerSubscription[subscriptionId] = false + val msg = OptimizedJsonMapper.toJson(cmd) + val success = it.send(msg) + listener.onSent(this@BasicRelayClient, msg, cmd, success) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt deleted file mode 100644 index 263d0dbe2..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/simple/OutboxCache.kt +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 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.nip01Core.relay.client.single.simple - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.utils.cache.LargeCache - -class OutboxCache( - listener: IRelayClientListener, -) : RedirectRelayClientListener(listener) { - /** - * Auth procedures require us to keep track of the outgoing events - * to make sure the relay waits for the auth to finish and send them. - */ - private val outboxCache = LargeCache() - - override fun onRelayStateChange( - relay: IRelayClient, - type: RelayState, - ) { - if (type == RelayState.CONNECTED) { - outboxCache.forEach { id, event -> - relay.sendEvent(event) - } - } - - super.onRelayStateChange(relay, type) - } - - override fun onAuthed( - relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, - ) { - super.onAuthed(relay, eventId, success, message) - outboxCache.forEach { id, event -> - relay.sendEvent(event) - } - } - - override fun onBeforeSend( - relay: IRelayClient, - event: Event, - ) { - if (event !is RelayAuthEvent) { - outboxCache.put(event.id, event) - } - super.onBeforeSend(relay, event) - } - - override fun onSendResponse( - relay: IRelayClient, - eventId: String, - success: Boolean, - message: String, - ) { - // remove from cache for any error that is not an auth required error. - // for auth required, we will do the auth and try to send again. - if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) { - outboxCache.remove(eventId) - } - - super.onSendResponse(relay, eventId, success, message) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt new file mode 100644 index 000000000..65ff8f7d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/standalone/StandaloneRelayClient.kt @@ -0,0 +1,136 @@ +/** + * 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.nip01Core.relay.client.single.standalone + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.cache.LargeCache + +/** + * This relay client saves any event that will be sent in an outbox + * waits for auth and sends it again to make sure it is delivered. + */ +class StandaloneRelayClient( + url: NormalizedRelayUrl, + socketBuilder: WebsocketBuilder, + listener: IRelayClientListener = EmptyClientListener, +) { + private val outbox = LargeCache() + private val reqs = LargeCache>() + private val counts = LargeCache>() + + val client = + BasicRelayClient( + url, + socketBuilder, + object : RedirectRelayClientListener(listener) { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + super.onConnected(relay, pingMillis, compressed) + renewFilters() + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is OkMessage) { + // remove from cache for any error that is not an auth required error. + // for auth required, we will do the auth and try to send again. + if (outbox.containsKey(msg.eventId) && !msg.message.startsWith("auth-required")) { + outbox.remove(msg.eventId) + } + } + super.onIncomingMessage(relay, msgStr, msg) + } + }, + ) + + fun renewFilters() { + outbox.forEach { id, event -> + client.sendOrConnectAndSync(EventCmd(event)) + } + reqs.forEach { subId, filters -> + client.sendOrConnectAndSync(ReqCmd(subId, filters)) + } + counts.forEach { subId, filters -> + client.sendOrConnectAndSync(CountCmd(subId, filters)) + } + } + + fun connect() = client.connect() + + fun needsToReconnect() = client.needsToReconnect() + + fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = client.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays) + + fun isConnected() = client.isConnected() + + fun sendRequest( + subId: String, + filters: List, + ) { + reqs.put(subId, filters) + client.sendOrConnectAndSync(ReqCmd(subId, filters)) + } + + fun sendCount( + queryId: String, + filters: List, + ) { + counts.put(queryId, filters) + client.sendOrConnectAndSync(CountCmd(queryId, filters)) + } + + fun send(event: Event) { + if (event !is RelayAuthEvent) { + outbox.put(event.id, event) + } + client.sendOrConnectAndSync(EventCmd(event)) + } + + fun close(subscriptionId: String) { + reqs.remove(subscriptionId) + counts.remove(subscriptionId) + client.sendIfConnected(CloseCmd(subscriptionId)) + } + + fun disconnect() = client.disconnect() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index 13be36066..c7576094f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -29,6 +29,7 @@ class RelayStat( var spamCounter: Int = 0, var errorCounter: Int = 0, var pingInMs: Int = 0, + var compression: Boolean = false, ) { val messages = LruCache(100) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index 8a5bc2343..db1e5a926 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -22,7 +22,17 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.bytesUsedInMemory class RelayStats( val client: INostrClient, @@ -32,47 +42,67 @@ class RelayStats( override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat() } - fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) ?: throw IllegalArgumentException("Should never happen") + fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen") - fun addBytesReceived( - url: NormalizedRelayUrl, - bytesUsedInMemory: Int, - ) { - get(url).addBytesReceived(bytesUsedInMemory) + private val clientListener = + object : IRelayClientListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + with(get(relay.url)) { + pingInMs = pingMillis + compression = compressed + } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + get(relay.url).newError(errorMessage) + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + get(relay.url).addBytesSent(cmdStr.bytesUsedInMemory()) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + val stat = get(relay.url) + + stat.addBytesReceived(msgStr.bytesUsedInMemory()) + + when (msg) { + is NoticeMessage -> stat.newNotice(msg.message) + is NotifyMessage -> stat.newNotice("Notify user: " + msg.message) + is OkMessage -> { + if (!msg.success) { + stat.newNotice("Rejected event ${msg.eventId}: ${msg.message}") + } + } + is ClosedMessage -> stat.newNotice("Subscription closed: ${msg.subId} ${msg.message}") + } + } + } + + init { + Log.d("RelayStats", "Init, Subscribe") + client.subscribe(clientListener) } - fun addBytesSent( - url: NormalizedRelayUrl, - bytesUsedInMemory: Int, - ) { - get(url).addBytesSent(bytesUsedInMemory) - } - - fun newError( - url: NormalizedRelayUrl, - error: String?, - ) { - get(url).newError(error) - } - - fun newNotice( - url: NormalizedRelayUrl, - notice: String?, - ) { - get(url).newNotice(notice) - } - - fun setPing( - url: NormalizedRelayUrl, - pingInMs: Int, - ) { - get(url).pingInMs = pingInMs - } - - fun newSpam( - url: NormalizedRelayUrl, - explanation: String, - ) { - get(url).newSpam(explanation) + fun destroy() { + // makes sure to run + Log.d("RelayStats", "Destroy, Unsubscribe") + client.unsubscribe(clientListener) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt index 8ed376769..47e94c0e5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -20,30 +20,24 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class Subscription( val id: String = newSubId(), - val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null, + val listener: IRequestListener, ) { - private var filters: Map>? = null // Inactive when null + private var currentVersion: Map>? = null // Inactive when null fun reset() { - filters = null + currentVersion = null } fun updateFilters(newFilters: Map>?) { - filters = newFilters + currentVersion = newFilters } - fun filters() = filters - - fun callEose( - time: Long, - relay: NormalizedRelayUrl, - ) { - onEose?.let { it(time, relay) } - } + fun filters() = currentVersion } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index 1ca12b2db..c87bb5158 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -20,10 +20,8 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -51,47 +49,12 @@ class SubscriptionController( ) { private val subscriptions = LargeCache() - private val clientListener = - object : IRelayClientListener { - override fun onEvent( - relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, - ) { - if (subscriptions.containsKey(subId)) { - if (afterEOSE) { - subscriptions.get(subId)?.callEose(arrivalTime, relay.url) - } - } - } - - override fun onEOSE( - relay: IRelayClient, - subId: String, - arrivalTime: Long, - ) { - if (subscriptions.containsKey(subId)) { - subscriptions.get(subId)?.callEose(arrivalTime, relay.url) - } - } - } - - init { - client.subscribe(clientListener) - } - - fun destroy() { - client.unsubscribe(clientListener) - } - fun getSub(subId: String) = subscriptions.get(subId) fun requestNewSubscription( subId: String, - onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null, - ): Subscription = Subscription(subId, onEose = onEOSE).also { subscriptions.put(it.id, it) } + listener: IRequestListener, + ): Subscription = Subscription(subId, listener).also { subscriptions.put(it.id, it) } fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } @@ -108,28 +71,29 @@ class SubscriptionController( } subscriptions.forEach { id, sub -> - updateRelaysIfNeeded(id, sub.filters(), currentFilters[id]) + updateRelaysIfNeeded(id, sub.listener, sub.filters(), currentFilters[id]) } } fun updateRelaysIfNeeded( subId: String, - updatedFilters: Map>?, - currentFilters: Map>?, + listener: IRequestListener, + newFilters: Map>?, + oldFilters: Map>?, ) { - if (currentFilters != null) { - if (updatedFilters == null) { + if (oldFilters != null) { + if (newFilters == null) { // was active and is not active anymore, just close. client.close(subId) } else { - client.openReqSubscription(subId, updatedFilters) + client.openReqSubscription(subId, newFilters, listener) } } else { - if (updatedFilters == null) { + if (newFilters == null) { // was not active and is still not active, does nothing } else { // was not active and becomes active, sends the entire filter. - client.openReqSubscription(subId, updatedFilters) + client.openReqSubscription(subId, newFilters, listener) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt index e1a04b381..5ee890bcc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CountCmd.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter class CountCmd( - val subId: String, + val queryId: String, val filters: List, ) : Command { override fun label(): String = LABEL - override fun isValid() = subId.isNotEmpty() && filters.isNotEmpty() + override fun isValid() = queryId.isNotEmpty() && filters.isNotEmpty() companion object { const val LABEL = "COUNT" diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index 1ae9543a6..268f20d2e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -61,7 +61,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { } CountCmd.LABEL -> { - val subId = jp.nextTextValue() + val queryId = jp.nextTextValue() val filters = mutableListOf() while (jp.nextToken() != JsonToken.END_ARRAY) { @@ -71,7 +71,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { } CountCmd( - subId = subId, + queryId = queryId, filters = filters, ) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index 47775b850..b1ffa8e0b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -59,7 +59,7 @@ class CommandSerializer : StdSerializer(Command::class.java) { } is CountCmd -> { - gen.writeString(cmd.subId) + gen.writeString(cmd.queryId) cmd.filters.forEach { filterSerializer.serialize(it, gen, provider) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 287673f4d..111a05ddd 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -50,32 +50,24 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val mySubId = "test-sub-id-1" val listener = - object : IRelayClientListener { + object : IRequestListener { override fun onEvent( - relay: IRelayClient, - subId: String, event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, ) { - if (mySubId == subId) { - resultChannel.trySend(event.id) - } + resultChannel.trySend(event.id) } - override fun onEOSE( - relay: IRelayClient, - subId: String, - arrivalTime: Long, + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, ) { - if (mySubId == subId) { - resultChannel.trySend("EOSE") - } + resultChannel.trySend("EOSE") } } - client.subscribe(listener) - val filters = mapOf( RelayUrlNormalizer.normalize("wss://relay.damus.io") to @@ -87,7 +79,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { ), ) - client.openReqSubscription(mySubId, filters) + client.openReqSubscription(mySubId, filters, listener) withTimeoutOrNull(30000) { while (events.size < 101) { @@ -99,7 +91,6 @@ class NostrClientManualSubTest : BaseNostrClientTest() { resultChannel.close() client.close(mySubId) - client.unsubscribe(listener) client.disconnect() appScope.cancel() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 1fa725e3c..c88551e13 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -55,25 +57,21 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val listener = object : IRelayClientListener { - override fun onEvent( + override fun onIncomingMessage( relay: IRelayClient, - subId: String, - event: Event, - arrivalTime: Long, - afterEOSE: Boolean, + msgStr: String, + msg: Message, ) { - if (mySubId == subId) { - resultChannel.trySend(event.id) - } - } - - override fun onEOSE( - relay: IRelayClient, - subId: String, - arrivalTime: Long, - ) { - if (mySubId == subId) { - resultChannel.trySend("EOSE") + Log.d("Test", "Receiving message: $msgStr") + when (msg) { + is EventMessage -> + if (mySubId == msg.subId) { + resultChannel.trySend(msg.event.id) + } + is EoseMessage -> + if (mySubId == msg.subId) { + resultChannel.trySend("EOSE") + } } } } @@ -91,13 +89,24 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { ), ) - val filters2 = + val filtersShouldIgnore = mapOf( RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 100, + limit = 500, + ), + ), + ) + + val filtersShouldSendAfterEOSE = + mapOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io") to + listOf( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + limit = 10, ), ), ) @@ -105,14 +114,16 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { coroutineScope { launch { withTimeoutOrNull(30000) { - while (events.size < 202) { + while (events.size < 112) { + Log.d("Test", "Processing message ${events.size}") // simulates an update in the middle of the sub if (events.size == 1) { - client.openReqSubscription(mySubId, filters2) + client.openReqSubscription(mySubId, filtersShouldIgnore) } - val event = resultChannel.receive() - Log.d("OkHttpWebsocketListener", "Processing: ${events.size} $event") - events.add(event) + if (events.size == 5) { + client.openReqSubscription(mySubId, filtersShouldSendAfterEOSE) + } + events.add(resultChannel.receive()) } } } @@ -128,10 +139,15 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { appScope.cancel() - assertEquals(202, events.size) + // gets all 113 messages (100 events + 1 EOSE + 10 events + 1 EOSE) + assertEquals(112, events.size) + // checks if first 100 have Ids assertEquals(true, events.take(100).all { it.length == 64 }) + // checks if EOSE is after the first 100 assertEquals("EOSE", events[100]) - assertEquals(true, events.drop(101).take(100).all { it.length == 64 }) - assertEquals("EOSE", events[201]) + // checks if next 10 have Ids + assertEquals(true, events.drop(101).take(10).all { it.length == 64 }) + // checks if EOSE is after the next 10 + assertEquals("EOSE", events[111]) } }