diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 2845df088..aa3084380 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -310,6 +310,8 @@ dependencies { // Language picker and Theme chooser implementation libs.androidx.appcompat + + // Dynamically adjust between phone and tablet UI implementation libs.androidx.window.core.android // Local model for language identification diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt index 8d0d989be..3d85bf673 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/SelectNotificationProvider.kt @@ -50,8 +50,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material3.RichText @@ -59,8 +59,6 @@ import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler -import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log @@ -130,7 +128,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) { val astNode = remember { - CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) } RichText( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index da67134d8..868c7c275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.quartz.utils.Log class Amethyst : Application() { + init { + Log.d("AmethystApp", "Creating App $this") + } + companion object { lateinit var instance: AppModules private set diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 906c3b510..f8b44419c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -25,6 +25,7 @@ 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 @@ -251,6 +252,11 @@ class AppModules( fun initiate(appContext: Context) { Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) + applicationIOScope.launch { + // loads main account quickly. + LocalPreferences.loadAccountConfigFromEncryptedStorage() + } + // forces initialization of uiPrefs in the main thread to avoid blinking themes uiPrefs diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index 4f5ecbc9e..78a12f4bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -26,6 +26,7 @@ import android.content.pm.ApplicationInfo import android.os.Debug import androidx.core.content.getSystemService import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls import com.vitorpamplona.quartz.utils.Log import kotlin.time.DurationUnit import kotlin.time.measureTimedValue @@ -68,6 +69,13 @@ fun debugState(context: Context) { .value.available.size, ) + Log.d( + STATE_DUMP_TAG, + "Indexed Relays: " + + Amethyst.instance.cache.relayHints.relayDB + .size() + "/" + normalizedUrls.size(), + ) + Log.d( STATE_DUMP_TAG, "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt index a36abd280..7bf5816de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -36,7 +36,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -79,7 +78,7 @@ class FollowListState( val userList = flow .map { - it.authors.map { + it.authors.mapNotNull { cache.checkGetOrCreateUser(it) } }.flowOn(Dispatchers.Default) @@ -87,7 +86,7 @@ class FollowListState( scope, SharingStarted.Eagerly, // this has priority. - flow.value.authors.map { + flow.value.authors.mapNotNull { cache.checkGetOrCreateUser(it) }, ) @@ -160,10 +159,10 @@ class FollowListState( init { settings.backupContactList?.let { - Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") + Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts") @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } } // saves contact list for the next time. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index f570d9b57..2cfa30326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -24,6 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket.Companion.exceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -33,7 +40,6 @@ class OkHttpWebSocket( val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { - private val listener = OkHttpWebsocketListener() private var usingOkHttp: OkHttpClient? = null private var socket: okhttp3.WebSocket? = null @@ -64,10 +70,21 @@ class OkHttpWebSocket( override fun connect() { usingOkHttp = httpClient(url) - socket = usingOkHttp?.newWebSocket(buildRequest(), listener) + socket = usingOkHttp?.newWebSocket(buildRequest(), OkHttpWebsocketListener(out)) } - inner class OkHttpWebsocketListener : okhttp3.WebSocketListener() { + inner class OkHttpWebsocketListener( + val out: WebSocketListener, + ) : okhttp3.WebSocketListener() { + val scope = CoroutineScope(Dispatchers.Default + exceptionHandler) + val incomingMessages: Channel = Channel(Channel.UNLIMITED) + val job = // Launch a coroutine to process messages from the channel. + scope.launch { + for (message in incomingMessages) { + out.onMessage(message) + } + } + override fun onOpen( webSocket: okhttp3.WebSocket, response: Response, @@ -79,7 +96,12 @@ class OkHttpWebSocket( override fun onMessage( webSocket: okhttp3.WebSocket, text: String, - ) = out.onMessage(text) + ) { + // Asynchronously send the received message to the channel. + // `trySendBlocking` is used here for simplicity within the callback, + // but it's important to understand potential thread blocking if the buffer is full. + incomingMessages.trySendBlocking(text) + } override fun onClosing( webSocket: okhttp3.WebSocket, @@ -92,6 +114,11 @@ class OkHttpWebSocket( code: Int, reason: String, ) { + // Close the channel on failure, and propagate the error. + incomingMessages.close() + job.cancel() + scope.cancel() + socket = null out.onClosed(code, reason) } @@ -101,6 +128,11 @@ class OkHttpWebSocket( t: Throwable, response: Response?, ) { + // Close the channel on failure, and propagate the error. + incomingMessages.close() + job.cancel() + scope.cancel() + socket = null out.onFailure(t, response?.code, response?.message) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 277d9450d..1cb1148e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -72,7 +72,7 @@ class RelayProxyClientConnector( if (it.connectivity is ConnectivityStatus.StartingService) { // ignore } else if (it.connectivity is ConnectivityStatus.Off) { - Log.d("ManageRelayServices", "Pausing Relay Services ${it.connectivity}") + Log.d("ManageRelayServices", "Connectivity Off: Pausing Relay Services ${it.connectivity}") if (client.isActive()) { client.disconnect() } @@ -82,7 +82,7 @@ class RelayProxyClientConnector( Log.d("ManageRelayServices", "Pausing Tor Activity") } } else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) { - Log.d("ManageRelayServices", "Resuming Relay Services") + Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services") val torStatus = torManager.status.value if (torStatus is TorServiceStatus.Active) { @@ -95,7 +95,10 @@ class RelayProxyClientConnector( client.connect() } else { Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to") - client.reconnect(true) + client.reconnect( + onlyIfChanged = true, + ignoreRetryDelays = true, + ) } }.onStart { Log.d("ManageRelayServices", "Resuming Relay Services") 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 06e79b57c..bd87b7d69 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 @@ -29,17 +29,26 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +interface IEoseManager { + fun invalidateFilters(ignoreIfDoing: Boolean = false) + + fun destroy() + + fun printStats() +} + abstract class BaseEoseManager( val client: INostrClient, val allKeys: () -> Set, -) { + val sampleTime: Long = 500, +) : IEoseManager { protected val logTag: String = this.javaClass.simpleName private val orchestrator = SubscriptionController(client) abstract fun updateSubscriptions(keys: Set) - fun printStats() = orchestrator.printStats(logTag) + override fun printStats() = orchestrator.printStats(logTag) fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId() @@ -50,21 +59,18 @@ abstract class BaseEoseManager( fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) // Refreshes observers in batches. - private val bundler = BundledUpdate(500, Dispatchers.Default) + private val bundler = BundledUpdate(sampleTime, Dispatchers.Default) - fun invalidateFilters() { - bundler.invalidate { - forceInvalidate() - } + override fun invalidateFilters(ignoreIfDoing: Boolean) { + bundler.invalidate(ignoreIfDoing, ::forceInvalidate) } fun forceInvalidate() { updateSubscriptions(allKeys()) - orchestrator.updateRelays() } - fun destroy() { + override fun destroy() { bundler.cancel() orchestrator.destroy() if (isDebug) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt new file mode 100644 index 000000000..6b56afd3e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.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.amethyst.service.relayClient.reqCommand.account.drafts + +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AccountDraftsEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List = + if (key.account.isWriteable()) { + relayFlow(key).value.flatMap { + listOf( + filterDraftsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time), + ).flatten() + } + } else { + emptyList() + } + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.Default) { + relayFlow(key).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt index ec0908e23..738a12ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterDraftsAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/FilterDraftsAndReportsFromKey.kt @@ -18,24 +18,20 @@ * 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.metadata +package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip56Reports.ReportEvent -val ReportsAndBookmarksFromKeyKinds = +val DraftKinds = listOf( DraftWrapEvent.KIND, - ReportEvent.KIND, - BookmarkListEvent.KIND, ) -fun filterDraftsAndReportsFromKey( +fun filterDraftsFromKey( relay: NormalizedRelayUrl, pubkey: HexKey?, since: Long?, @@ -47,7 +43,7 @@ fun filterDraftsAndReportsFromKey( relay = relay, filter = Filter( - kinds = ReportsAndBookmarksFromKeyKinds, + kinds = DraftKinds, authors = listOf(pubkey), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index de51e0bea..7ddeedd85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -50,8 +51,8 @@ class AccountMetadataEoseManager( listOf( filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), - filterDraftsAndReportsFromKey(it, user(key).pubkeyHex, since), - filterLastPostsFromKey(it, user(key).pubkeyHex, since), + filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since), + filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()), filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt new file mode 100644 index 000000000..a537a5249 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -0,0 +1,54 @@ +/** + * 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.metadata + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent + +val ReportsAndBookmarksFromKeyKinds = + listOf( + ReportEvent.KIND, + BookmarkListEvent.KIND, + ) + +fun filterBookmarksAndReportsFromKey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + since: Long?, +): List { + if (pubkey == null || pubkey.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = ReportsAndBookmarksFromKeyKinds, + authors = listOf(pubkey), + since = since, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 38c677f4d..79a45b409 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -42,14 +42,20 @@ class AccountGiftWrapsEoseManager( override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, - ): List = - key.account.dmRelays.flow.value.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = since?.get(relay)?.time, - ) + ): List { + // Only loads DMs if the account is writeable + return if (key.account.isWriteable()) { + key.account.dmRelays.flow.value.flatMap { relay -> + filterGiftWrapsToPubkey( + relay = relay, + pubkey = user(key).pubkeyHex, + since = since?.get(relay)?.time, + ) + } + } else { + emptyList() } + } val userJobMap = mutableMapOf>() 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 e468c2ba7..d1760b322 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 @@ -36,7 +36,7 @@ class EventWatcherSubAssembler( allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { var lastNotesOnFilter = emptyList() - var latestEOSEs: EOSEAccountFast = EOSEAccountFast(10000) + var latestEOSEs: EOSEAccountFast = EOSEAccountFast(1000) override fun newEose( relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 4be4fcb40..47f05e6ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -26,15 +26,17 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.material3.RichText import com.vitorpamplona.amethyst.model.LocalCache @@ -69,24 +71,25 @@ fun RenderContentAsMarkdown( accountViewModel: AccountViewModel, nav: INav, ) { - val uri = LocalUriHandler.current + val uriHandler = LocalUriHandler.current val onClick = - remember { - { link: String -> - val route = uriToRoute(link, accountViewModel.account) - if (route != null) { - nav.nav(route) - } else { - runCatching { uri.openUri(link) } + remember(uriHandler) { + object : UriHandler { + override fun openUri(uri: String) { + val route = uriToRoute(uri, accountViewModel.account) + if (route != null) { + nav.nav(route) + } else { + runCatching { uriHandler.openUri(uri) } + } } - Unit } } ProvideTextStyle(MarkdownTextStyle) { val astNode = remember(content) { - CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) } val renderer = @@ -103,12 +106,13 @@ fun RenderContentAsMarkdown( ) } - RichText( - style = MaterialTheme.colorScheme.markdownStyle, - linkClickHandler = onClick, - renderer = renderer, - ) { - BasicMarkdown(astNode) + CompositionLocalProvider(LocalUriHandler provides onClick) { + RichText( + style = MaterialTheme.colorScheme.markdownStyle, + renderer = renderer, + ) { + BasicMarkdown(astNode) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 23a3da0e8..9f255ef01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -121,7 +121,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { if (notificationPermissionState.status.isGranted) { LifecycleResumeEffect(key1 = accountViewModel, notificationPermissionState.status.isGranted) { - Log.d("RegisterAccounts", "Registering for push notifications $notificationPermissionState") + Log.d("RegisterAccounts", "Registering for push notifications") scope.launch { PushNotificationUtils.checkAndInit( LocalPreferences.allSavedAccounts(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt index f875f58b6..c62456059 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChatroomFilterSubAssembler( client: INostrClient, @@ -31,7 +32,12 @@ class ChatroomFilterSubAssembler( override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, - ) = filterNip04DMs(key.room.users, key.account, since) + ): List? = + if (key.account.isWriteable()) { + filterNip04DMs(key.room.users, key.account, since) + } else { + emptyList() + } override fun user(key: ChatroomQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 30a34dc59..f57593127 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -40,12 +40,16 @@ class DMsFromUserFilterSubAssembler( key: ChatroomListState, since: SincePerRelayMap?, ): List? = - key.account.homeRelays.flow.value.map { - filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) - } + - key.account.dmRelays.flow.value.map { - filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) - } + if (key.account.isWriteable()) { + key.account.homeRelays.flow.value.map { + filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) + } + + key.account.dmRelays.flow.value.map { + filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) + } + } else { + emptyList() + } override fun user(key: ChatroomListState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt index 4d6e95a21..92bf2d60e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt @@ -83,8 +83,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material3.RichText @@ -177,7 +177,7 @@ private fun DialogContents( val astNode1 = remember { - CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content1) + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content1) } RichText( @@ -205,7 +205,7 @@ private fun DialogContents( val astNode = remember { - CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) } RichText( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index ffaea839a..930839dd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -47,7 +47,6 @@ val UserProfilePostKinds1 = GenericRepostEvent.KIND, RepostEvent.KIND, LongTextNoteEvent.KIND, - PinListEvent.KIND, PollNoteEvent.KIND, HighlightEvent.KIND, WikiNoteEvent.KIND, @@ -62,6 +61,7 @@ val UserProfilePostKinds2 = InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, VoiceReplyEvent.KIND, + PinListEvent.KIND, ) fun filterUserProfilePosts( @@ -70,8 +70,7 @@ fun filterUserProfilePosts( ): List { val relays = user.outboxRelays()?.ifEmpty { null } - ?: user.relaysBeingUsed.keys.ifEmpty { null } - ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + ?: (user.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) return relays .map { relay -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index 4fce997dd..eaa1e4c28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = - account.searchRelayList.flow.value + account.searchRelayList.flowNoDefaults.value .toList() override suspend fun saveRelayList(urlList: List) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 29a1e50a6..4c75a29de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.theme +import android.R.attr.fontFamily +import android.R.id.primary import android.app.Activity import android.app.UiModeManager import android.content.Context @@ -49,6 +51,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp @@ -292,8 +295,11 @@ val MarkDownStyleOnDark = stringStyle = RichTextDefaults.stringStyle?.copy( linkStyle = - SpanStyle( - color = DarkColorPalette.primary, + TextLinkStyles( + style = + SpanStyle( + color = DarkColorPalette.primary, + ), ), codeStyle = SpanStyle( @@ -330,8 +336,11 @@ val MarkDownStyleOnLight = stringStyle = RichTextDefaults.stringStyle?.copy( linkStyle = - SpanStyle( - color = LightColorPalette.primary, + TextLinkStyles( + style = + SpanStyle( + color = LightColorPalette.primary, + ), ), codeStyle = SpanStyle( diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index e4553ade8..f603471a7 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -100,17 +100,7 @@ class HexBenchmark { } @Test - fun newIsHex() { - val isHexChar = - BooleanArray(256).apply { - "0123456789abcdefABCDEF".forEach { this[it.code] = true } - } - - r.measureRepeated { - for (c in hex.indices) { - if (!isHexChar[hex[c].code]) return@measureRepeated - } - return@measureRepeated - } + fun isHex64() { + r.measureRepeated { Hex.isHex64(hex) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f835e77be..c1527b5dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ benchmark = "1.4.1" benchmarkJunit4 = "1.4.1" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.09.00" +composeBom = "2025.09.01" coreKtx = "1.17.0" datastore = "1.1.7" espressoCore = "3.7.0" @@ -34,11 +34,11 @@ lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.9.4" lightcompressor = "1.5.0" -markdown = "e1151c8" +markdown = "f92ef49c9d" media3 = "1.8.0" mockk = "1.14.5" kotlinx-coroutines-test = "1.10.2" -navigationCompose = "2.9.4" +navigationCompose = "2.9.5" okhttp = "5.1.0" runner = "1.7.0" rfc3986 = "0.1.2" @@ -54,11 +54,11 @@ zelory = "3.0.1" zoomable = "2.8.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" -windowCoreAndroid = "1.4.0" +windowCoreAndroid = "1.5.0" androidxCamera = "1.5.0" androidxCollection = "1.5.0" kotlinStdlib = "2.2.20" -kotlinTest = "2.2.10" +kotlinTest = "2.2.20" core = "1.7.0" mavenPublish = "0.34.0" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index e15428326..be89e1e5d 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -107,7 +107,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) // in your shared module's dependencies block - implementation( libs.kotlinx.datetime) + // implementation( libs.kotlinx.datetime) // immutable collections to avoid recomposition implementation(libs.kotlinx.collections.immutable) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt index 2eab43448..c53f67157 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/utils/HexEncodingTest.kt @@ -83,17 +83,50 @@ class HexEncodingTest { assertFalse("`a", Hex.isHex("`a")) assertFalse("gg", Hex.isHex("gg")) assertFalse("fg", Hex.isHex("fg")) + assertFalse("\uD83E\uDD70", Hex.isHex("\uD83E\uDD70")) } @OptIn(ExperimentalStdlibApi::class) @Test fun testRandomsIsHex() { + val lowerCaseHexNeg = "ghijklmnopqrstuvwxyz" + val upperCaseHexNeg = "GHIJKLMNOPQRSTUVWXYZ" + for (i in 0..10000) { val bytes = RandomInstance.bytes(32) val hex = bytes.toHexString(HexFormat.Default) assertTrue(hex, Hex.isHex(hex)) val hexUpper = bytes.toHexString(HexFormat.UpperCase) assertTrue(hexUpper, Hex.isHex(hexUpper)) + + // scramble + val negHex = hex.replaceFirst(hex.random(), lowerCaseHexNeg.random()) + val negHexUpper = hexUpper.replaceFirst(hexUpper.random(), upperCaseHexNeg.random()) + + assertFalse(negHex, Hex.isHex(negHex)) + assertFalse(negHexUpper, Hex.isHex(negHexUpper)) + } + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRandomsIsHex64() { + val lowerCaseHexNeg = "ghijklmnopqrstuvwxyz" + val upperCaseHexNeg = "GHIJKLMNOPQRSTUVWXYZ" + + for (i in 0..10000) { + val bytes = RandomInstance.bytes(32) + val hex = bytes.toHexString(HexFormat.Default) + assertTrue(hex, Hex.isHex64(hex)) + val hexUpper = bytes.toHexString(HexFormat.UpperCase) + assertTrue(hexUpper, Hex.isHex64(hexUpper)) + + // scramble + val negHex = hex.replaceFirst(hex.random(), lowerCaseHexNeg.random()) + val negHexUpper = hexUpper.replaceFirst(hexUpper.random(), upperCaseHexNeg.random()) + + assertFalse(hex + ":" + negHex, Hex.isHex64(negHex)) + assertFalse(hexUpper + ":" + negHexUpper, Hex.isHex64(negHexUpper)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt index 77347773a..144673a8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexer.kt @@ -42,7 +42,8 @@ class HintIndexer { // 3.75MB for keys private val pubKeyHints = BloomFilterMurMur3(30_000_000, 10) - private val relayDB = LargeCache() + + val relayDB = LargeCache() private fun add( id: ByteArray, 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 79b6a595d..6e86149b5 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 @@ -36,7 +36,10 @@ interface INostrClient { fun disconnect() - fun reconnect(onlyIfChanged: Boolean = false) + fun reconnect( + onlyIfChanged: Boolean = false, + ignoreRetryDelays: Boolean = false, + ) fun isActive(): Boolean @@ -78,7 +81,10 @@ object EmptyNostrClient : INostrClient { override fun disconnect() { } - override fun reconnect(onlyIfChanged: Boolean) { } + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) { } override fun isActive() = false 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 adae3512f..2db300300 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 @@ -37,13 +37,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.launch /** * The NostrClient manages Nostr relay operations, subscriptions, and event delivery. It maintains: @@ -85,7 +87,8 @@ class NostrClient( // 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. - private var isActive = false + // STARTS active + private var isActive = true /** * Whatches for any changes in the relay list from subscriptions or outbox @@ -115,12 +118,13 @@ class NostrClient( socketBuilder = websocketBuilder, listener = relayPool, stats = RelayStats.get(relay), - scope = scope, ) { liveRelay -> if (isActive) { - activeRequests.forEachSub(relay, liveRelay::sendRequest) - activeCounts.forEachSub(relay, liveRelay::sendCount) - eventOutbox.forEachUnsentEvent(relay, liveRelay::send) + scope.launch(Dispatchers.Default) { + activeRequests.forEachSub(relay, liveRelay::sendRequest) + activeCounts.forEachSub(relay, liveRelay::sendCount) + eventOutbox.forEachUnsentEvent(relay, liveRelay::send) + } } } @@ -137,21 +141,35 @@ class NostrClient( override fun isActive() = isActive - val myReconnectMutex = Mutex() + class Reconnect( + val onlyIfChanged: Boolean, + val ignoreRetryDelays: Boolean, + ) - override fun reconnect(onlyIfChanged: Boolean) { - if (myReconnectMutex.tryLock()) { - try { - if (onlyIfChanged) { - relayPool.reconnectIfNeedsToORIfItIsTime() + val refreshConnection = MutableStateFlow(Reconnect(false, false)) + + @OptIn(FlowPreview::class) + val debouncingConnection = + refreshConnection + .debounce(200) + .onEach { + if (it.onlyIfChanged) { + relayPool.reconnectIfNeedsTo(it.ignoreRetryDelays) } else { relayPool.disconnect() relayPool.connect() } - } finally { - myReconnectMutex.unlock() - } - } + }.stateIn( + scope, + SharingStarted.Eagerly, + false, + ) + + override fun reconnect( + onlyIfChanged: Boolean, + ignoreRetryDelays: Boolean, + ) { + refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays)) } fun needsToResendRequest( @@ -228,7 +246,10 @@ class NostrClient( if (newFilters.isNullOrEmpty()) { // some relays are not in this sub anymore. Stop their subscriptions - relayPool.close(relay, subId) + 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) @@ -244,7 +265,7 @@ class NostrClient( } // wakes up all the other relays - relayPool.reconnectIfNeedsToORIfItIsTime() + reconnect(true) } } @@ -264,7 +285,10 @@ class NostrClient( if (newFilters.isNullOrEmpty()) { // some relays are not in this sub anymore. Stop their subscriptions - relayPool.close(relay, subId) + 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) @@ -280,7 +304,7 @@ class NostrClient( } // wakes up all the other relays - relayPool.reconnectIfNeedsToORIfItIsTime() + reconnect(true) } } @@ -292,7 +316,7 @@ class NostrClient( relayPool.getRelay(connectedRelay)?.send(event) // wakes up all the other relays - relayPool.reconnectIfNeedsToORIfItIsTime() + reconnect(true) } } @@ -301,11 +325,12 @@ class NostrClient( relayList: Set, ) { eventOutbox.markAsSending(event, relayList) + if (isActive) { relayPool.send(event, relayList) // wakes up all the other relays - relayPool.reconnectIfNeedsToORIfItIsTime() + reconnect(true) } } @@ -333,6 +358,14 @@ class NostrClient( 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, 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 adcfeb577..c186cbe92 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 @@ -34,10 +34,6 @@ class NostrClientSubscription( ) : IRelayClientListener { private val subId = RandomInstance.randomChars(10) - init { - client.subscribe(this) - } - override fun onEvent( relay: IRelayClient, subId: String, @@ -57,4 +53,13 @@ class NostrClientSubscription( fun updateFilter() = client.openReqSubscription(subId, filter()) fun closeSubscription() = client.close(subId) + + fun destroy() { + client.unsubscribe(this) + } + + init { + client.subscribe(this) + updateFilter() + } } 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 0fed3c8d1..81344f075 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 @@ -32,6 +32,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withTimeoutOrNull @@ -46,7 +47,7 @@ suspend fun INostrClient.sendAndWaitForResponse( relayList: Set, timeoutInSeconds: Long = 15, ): Boolean { - val resultChannel = Channel() + val resultChannel = Channel(UNLIMITED) Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses") @@ -91,25 +92,28 @@ suspend fun INostrClient.sendAndWaitForResponse( // subscribe before sending the result. val resultSubscription = coroutineScope { - async(Dispatchers.IO) { - val receivedResults = mutableMapOf() - // The withTimeout block will cancel the coroutine if the loop takes too long - withTimeoutOrNull(timeoutInSeconds * 1000) { - while (receivedResults.size < relayList.size) { - val result = resultChannel.receive() + val result = + async(Dispatchers.IO) { + val receivedResults = mutableMapOf() + // The withTimeout block will cancel the coroutine if the loop takes too long + withTimeoutOrNull(timeoutInSeconds * 1000) { + while (receivedResults.size < relayList.size) { + val result = resultChannel.receive() - val currentResult = receivedResults[result.relay] - // do not override a successful result. - if (currentResult == null || !currentResult) { - receivedResults.put(result.relay, result.success) + val currentResult = receivedResults[result.relay] + // do not override a successful result. + if (currentResult == null || !currentResult) { + receivedResults.put(result.relay, result.success) + } } } + receivedResults } - receivedResults - } - } - send(event, relayList) + send(event, relayList) + + result + } val receivedResults = resultSubscription.await() 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 85fc4b4c4..cfcc7fe00 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 @@ -27,14 +27,48 @@ 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 +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull +suspend fun INostrClient.downloadFirstEvent( + relay: String, + filter: Filter, +) = downloadFirstEvent(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter))) + +suspend fun INostrClient.downloadFirstEvent( + relay: String, + filters: List, +) = downloadFirstEvent(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters)) + +suspend fun INostrClient.downloadFirstEvent( + subscriptionId: String = newSubId(), + relay: String, + filters: List, +) = downloadFirstEvent(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters)) + +suspend fun INostrClient.downloadFirstEvent( + relay: NormalizedRelayUrl, + filter: Filter, +) = downloadFirstEvent(newSubId(), mapOf(relay to listOf(filter))) + +suspend fun INostrClient.downloadFirstEvent( + relay: NormalizedRelayUrl, + filters: List, +) = downloadFirstEvent(newSubId(), mapOf(relay to filters)) + +suspend fun INostrClient.downloadFirstEvent( + subscriptionId: String = newSubId(), + relay: NormalizedRelayUrl, + filters: List, +) = downloadFirstEvent(subscriptionId, mapOf(relay to filters)) + suspend fun INostrClient.downloadFirstEvent( subscriptionId: String = newSubId(), filters: Map>, ): Event? { - val resultChannel = Channel() + val resultChannel = Channel(UNLIMITED) val listener = object : IRelayClientListener { 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 9a7a6cebe..d91cb08d9 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 @@ -28,7 +28,6 @@ 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.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -71,17 +70,7 @@ class RelayPool( fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) - var lastReconnectCall = TimeUtils.now() - - fun reconnectIfNeedsToORIfItIsTime() { - if (lastReconnectCall < TimeUtils.oneMinuteAgo()) { - reconnectIfNeedsTo() - - lastReconnectCall = TimeUtils.now() - } - } - - fun reconnectIfNeedsTo() { + fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) { relays.forEach { url, relay -> if (relay.isConnected()) { if (relay.needsToReconnect()) { @@ -91,7 +80,7 @@ class RelayPool( } } else { // relay is not connected. Connect if it is time - relay.connectAndSyncFiltersIfDisconnected() + relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays) } } updateStatus() @@ -125,7 +114,7 @@ class RelayPool( subId: String, filters: List, ) { - relays.get(relay)?.sendRequest(subId, filters) + getOrCreateRelay(relay).sendRequest(subId, filters) } fun sendRequest( @@ -145,7 +134,7 @@ class RelayPool( subId: String, filters: List, ) { - relays.get(relay)?.sendCount(subId, filters) + getOrCreateRelay(relay).sendCount(subId, filters) } fun sendCount( 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 76b562722..dc2f6e268 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 @@ -34,7 +34,7 @@ interface IRelayClient { fun connectAndRunAfterSync(onConnected: () -> Unit) - fun connectAndSyncFiltersIfDisconnected() + fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean = false) fun isConnected(): Boolean 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 ecf3e2143..ed95cf216 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 @@ -51,9 +51,6 @@ 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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.cancellation.CancellationException @@ -69,7 +66,7 @@ import kotlin.coroutines.cancellation.CancellationException * @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_MSECS] (500ms). + * - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_SECS] (500ms). * - Doubles the delay between reconnection attempts in case of failure. * * Message Handling: @@ -82,13 +79,11 @@ open class BasicRelayClient( val socketBuilder: WebsocketBuilder, val listener: IRelayClientListener, val stats: RelayStat = RelayStat(), - val scope: CoroutineScope, val defaultOnConnect: (BasicRelayClient) -> Unit = { }, ) : IRelayClient { companion object { - // waits 3 minutes to reconnect once things fail + // minimum wait time to reconnect: 1 second const val DELAY_TO_RECONNECT_IN_SECS = 1 - const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\"" } private val logTag = "Relay ${url.displayUrl()}" @@ -166,24 +161,13 @@ open class BasicRelayClient( markConnectionAsReady(pingMillis, compression) - scope.launch(Dispatchers.Default) { - onConnected() - } + onConnected() listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) } override fun onMessage(text: String) { - // Log.d(logTag, "Receiving: $text") - - if (text.startsWith(EVENT_MESSAGE_PREFIX)) { - // defers the parsing of ["EVENTS" to avoid blocking the HTTP thread - scope.launch(Dispatchers.Default) { - consumeIncomingCommand(text, onConnected) - } - } else { - consumeIncomingCommand(text, onConnected) - } + consumeIncomingMessage(text, onConnected) } override fun onClosing( @@ -237,7 +221,7 @@ open class BasicRelayClient( } } - fun consumeIncomingCommand( + fun consumeIncomingMessage( text: String, onConnected: () -> Unit, ) { @@ -258,7 +242,7 @@ open class BasicRelayClient( } catch (e: Throwable) { if (e is CancellationException) throw e stats.newError("Error processing: $text") - Log.e(logTag, "Error processing: $text") + Log.e(logTag, "Error processing: $text", e) listener.onError(this@BasicRelayClient, "", Error("Error processing $text")) } } @@ -296,7 +280,7 @@ open class BasicRelayClient( } private fun processEose(msg: EoseMessage) { - // Log.w(logTag, "EOSE ${msg.subId}") + Log.d(logTag, "EOSE ${msg.subId}") afterEOSEPerSubscription[msg.subId] = true listener.onEOSE(this, msg.subId, TimeUtils.now()) } @@ -311,7 +295,7 @@ open class BasicRelayClient( msg: OkMessage, onConnected: () -> Unit, ) { - Log.w(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") + 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)) { @@ -403,10 +387,10 @@ open class BasicRelayClient( } } - override fun connectAndSyncFiltersIfDisconnected() { + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { if (!isConnectionStarted() && !connectingMutex.load()) { // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { + if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { upRelayDelayToConnect() connect() } 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/single/simple/SimpleRelayClient.kt index 0619ffb41..6064dbc2a 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/single/simple/SimpleRelayClient.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayCl import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import kotlinx.coroutines.CoroutineScope /** * This relay client saves any event that will be sent in an outbox @@ -37,13 +36,11 @@ class SimpleRelayClient( socketBuilder: WebsocketBuilder, listener: IRelayClientListener, stats: RelayStat = RelayStat(), - scopeToParseEvents: CoroutineScope, defaultOnConnect: (BasicRelayClient) -> Unit = { }, ) : IRelayClient by BasicRelayClient( url, socketBuilder, OutboxCache(listener), stats, - scopeToParseEvents, defaultOnConnect, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index a3f6dde13..5fd4e5d0a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -58,13 +58,13 @@ class ContactListEvent( /** * Returns a list of p-tags that are verified as hex keys. */ - fun verifiedFollowKeySet(): Set = tags.mapNotNullTo(HashSet(), ContactTag::parseValidKey) + fun verifiedFollowKeySet(): Set = tags.mapNotNullTo(mutableSetOf(), ContactTag::parseValidKey) /** * Returns a list of a-tags that are verified as correct. */ @Deprecated("Use CommunityListEvent instead.") - fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress) + fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(mutableSetOf(), ATag::parseValidAddress) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt index ace9606d3..8320fcfcf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.bytesUsedInMemory @@ -100,7 +101,11 @@ data class ContactTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } return try { - decodePublicKey(tag[1]).toHexKey() + if (Hex.isHex64(tag[1])) { + tag[1] + } else { + null + } } catch (e: Exception) { Log.w("ContactListEvent", "Can't parse contact list pubkey ${tag.joinToString(", ")}", e) null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt index d0cf6daa5..7b368c37c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/VerifyResult.kt @@ -20,12 +20,6 @@ */ package com.vitorpamplona.quartz.nip03Timestamp.ots -import kotlinx.datetime.TimeZone -import kotlinx.datetime.number -import kotlinx.datetime.toLocalDateTime -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - /** * Class that lets us compare, sort, store and print timestamps. */ @@ -38,19 +32,12 @@ class VerifyResult( /** * Returns, if existing, a string representation describing the existence of a block attest */ - @OptIn(ExperimentalTime::class) override fun toString(): String { if (height == 0 || timestamp == null) { return "" } - // 1. Create an Instant from the Unix timestamp (milliseconds) - val instant = Instant.fromEpochMilliseconds(timestamp * 1000) - - // 2. Convert the Instant to a LocalDateTime in the UTC time zone - val dateTime = instant.toLocalDateTime(TimeZone.UTC) - - return "block $height attests data existed as of ${dateTime.year}-${dateTime.month.number}-${dateTime.day} UTC" + return "block $height attests data existed as of unix timestamp of $timestamp" } override fun compareTo(other: VerifyResult): Int = this.height - other.height diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 4882f15fc..b42bd9f4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -36,23 +36,114 @@ object Hex { (LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code } + // 47ns in debug on the Emulator fun isHex(hex: String?): Boolean { - if (hex.isNullOrEmpty()) return false + if (hex == null) return false if (hex.length and 1 != 0) return false - try { - for (c in hex.indices) { - if (c < 0 || c > 255) return false - if (hexToByte[hex[c].code] < 0) return false - } + return try { + internalIsHex(hex, hexToByte) } catch (_: IllegalArgumentException) { // there are p tags with emoji's which makes the hex[c].code > 256 - return false + false + } catch (_: IndexOutOfBoundsException) { + // there are p tags with emoji's which makes the hex[c].code > 256 + false } + } + // breaking this function away from the main one improves performance for some reason + fun internalIsHex( + hex: String, + hexToByte: IntArray, + ): Boolean { + for (c in hex.indices) { + if (hexToByte[hex[c].code] < 0) return false + } return true } + // 30% faster than isHex + fun isHex64(hex: String): Boolean = + try { + hexToByte[hex[0].code] >= 0 && + hexToByte[hex[1].code] >= 0 && + hexToByte[hex[2].code] >= 0 && + hexToByte[hex[3].code] >= 0 && + hexToByte[hex[4].code] >= 0 && + hexToByte[hex[5].code] >= 0 && + hexToByte[hex[6].code] >= 0 && + hexToByte[hex[7].code] >= 0 && + hexToByte[hex[8].code] >= 0 && + hexToByte[hex[9].code] >= 0 && + + hexToByte[hex[10].code] >= 0 && + hexToByte[hex[11].code] >= 0 && + hexToByte[hex[12].code] >= 0 && + hexToByte[hex[13].code] >= 0 && + hexToByte[hex[14].code] >= 0 && + hexToByte[hex[15].code] >= 0 && + hexToByte[hex[16].code] >= 0 && + hexToByte[hex[17].code] >= 0 && + hexToByte[hex[18].code] >= 0 && + hexToByte[hex[19].code] >= 0 && + + hexToByte[hex[20].code] >= 0 && + hexToByte[hex[21].code] >= 0 && + hexToByte[hex[22].code] >= 0 && + hexToByte[hex[23].code] >= 0 && + hexToByte[hex[24].code] >= 0 && + hexToByte[hex[25].code] >= 0 && + hexToByte[hex[26].code] >= 0 && + hexToByte[hex[27].code] >= 0 && + hexToByte[hex[28].code] >= 0 && + hexToByte[hex[29].code] >= 0 && + + hexToByte[hex[30].code] >= 0 && + hexToByte[hex[31].code] >= 0 && + hexToByte[hex[32].code] >= 0 && + hexToByte[hex[33].code] >= 0 && + hexToByte[hex[34].code] >= 0 && + hexToByte[hex[35].code] >= 0 && + hexToByte[hex[36].code] >= 0 && + hexToByte[hex[37].code] >= 0 && + hexToByte[hex[38].code] >= 0 && + hexToByte[hex[39].code] >= 0 && + + hexToByte[hex[40].code] >= 0 && + hexToByte[hex[41].code] >= 0 && + hexToByte[hex[42].code] >= 0 && + hexToByte[hex[43].code] >= 0 && + hexToByte[hex[44].code] >= 0 && + hexToByte[hex[45].code] >= 0 && + hexToByte[hex[46].code] >= 0 && + hexToByte[hex[47].code] >= 0 && + hexToByte[hex[48].code] >= 0 && + hexToByte[hex[49].code] >= 0 && + + hexToByte[hex[50].code] >= 0 && + hexToByte[hex[51].code] >= 0 && + hexToByte[hex[52].code] >= 0 && + hexToByte[hex[53].code] >= 0 && + hexToByte[hex[54].code] >= 0 && + hexToByte[hex[55].code] >= 0 && + hexToByte[hex[56].code] >= 0 && + hexToByte[hex[57].code] >= 0 && + hexToByte[hex[58].code] >= 0 && + hexToByte[hex[59].code] >= 0 && + + hexToByte[hex[60].code] >= 0 && + hexToByte[hex[61].code] >= 0 && + hexToByte[hex[62].code] >= 0 && + hexToByte[hex[63].code] >= 0 + } catch (_: IllegalArgumentException) { + // there are p tags with emoji's which makes the hex[c].code > 256 + false + } catch (_: IndexOutOfBoundsException) { + // there are p tags with emoji's which makes the hex[c].code > 256 + false + } + fun decode(hex: String): ByteArray { // faster version of hex decoder require(hex.length and 1 == 0) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index 17f64c17f..afd06b36b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -32,6 +32,14 @@ class FilterSerializer : StdSerializer(Filter::class.java) { ) { gen.writeStartObject() + filter.kinds?.run { + gen.writeArrayFieldStart("kinds") + for (i in indices) { + gen.writeNumber(this[i]) + } + gen.writeEndArray() + } + filter.ids?.run { gen.writeArrayFieldStart("ids") for (i in indices) { @@ -48,14 +56,6 @@ class FilterSerializer : StdSerializer(Filter::class.java) { gen.writeEndArray() } - filter.kinds?.run { - gen.writeArrayFieldStart("kinds") - for (i in indices) { - gen.writeNumber(this[i]) - } - gen.writeEndArray() - } - filter.tags?.run { entries.forEach { kv -> gen.writeArrayFieldStart("#${kv.key}") diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt index 685c17d86..36e99b570 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt @@ -24,6 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.utils.Log +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -35,6 +43,14 @@ class BasicOkHttpWebSocket( val httpClient: (NormalizedRelayUrl) -> OkHttpClient, val out: WebSocketListener, ) : WebSocket { + companion object { + // Exists to avoid exceptions stopping the coroutine + val exceptionHandler = + CoroutineExceptionHandler { _, throwable -> + Log.e("BasicOkHttpWebSocket", "WebsocketListener Caught exception: ${throwable.message}", throwable) + } + } + private var socket: OkHttpWebSocket? = null override fun needsReconnect() = socket == null @@ -44,6 +60,15 @@ class BasicOkHttpWebSocket( val listener = object : OkHttpWebSocketListener() { + val scope = CoroutineScope(Dispatchers.Default + exceptionHandler) + val incomingMessages: Channel = Channel(Channel.UNLIMITED) + val job = // Launch a coroutine to process messages from the channel. + scope.launch { + for (message in incomingMessages) { + out.onMessage(message) + } + } + override fun onOpen( webSocket: OkHttpWebSocket, response: Response, @@ -55,7 +80,13 @@ class BasicOkHttpWebSocket( override fun onMessage( webSocket: OkHttpWebSocket, text: String, - ) = out.onMessage(text) + ) { + Log.d("OkHttpWebsocketListener", "Processing: $text") + // Asynchronously send the received message to the channel. + // `trySendBlocking` is used here for simplicity within the callback, + // but it's important to understand potential thread blocking if the buffer is full. + incomingMessages.trySendBlocking(text) + } override fun onClosing( webSocket: OkHttpWebSocket, @@ -67,13 +98,27 @@ class BasicOkHttpWebSocket( webSocket: OkHttpWebSocket, code: Int, reason: String, - ) = out.onClosed(code, reason) + ) { + // Close the channel when the WebSocket connection is closed. + incomingMessages.close() + job.cancel() + scope.cancel() + + out.onClosed(code, reason) + } override fun onFailure( webSocket: OkHttpWebSocket, t: Throwable, response: Response?, - ) = out.onFailure(t, response?.code, response?.message) + ) { + // Close the channel on failure, and propagate the error. + incomingMessages.close() + job.cancel() + scope.cancel() + + out.onFailure(t, response?.code, response?.message) + } } socket = httpClient(url).newWebSocket(request, listener) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt new file mode 100644 index 000000000..a5b2d7d1d --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -0,0 +1,31 @@ +/** + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +open class BaseNostrClientTest { + companion object { + val rootClient = OkHttpClient.Builder().build() + val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt new file mode 100644 index 000000000..1213951f7 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt @@ -0,0 +1,58 @@ +/** + * 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 + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.downloadFirstEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientFirstEventTest : BaseNostrClientTest() { + @Test + fun testDownloadFirstEvent() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val event = + client.downloadFirstEvent( + relay = "wss://nos.lol", + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + + client.disconnect() + appScope.cancel() + + assertEquals(MetadataEvent.KIND, event?.kind) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey) + } +} 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 new file mode 100644 index 000000000..287673f4d --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.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 + +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.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientManualSubTest : BaseNostrClientTest() { + @Test + fun testEoseAfter100Events() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val resultChannel = Channel(UNLIMITED) + val events = mutableListOf() + val mySubId = "test-sub-id-1" + + val listener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (mySubId == subId) { + resultChannel.trySend(event.id) + } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + if (mySubId == subId) { + resultChannel.trySend("EOSE") + } + } + } + + client.subscribe(listener) + + val filters = + mapOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io") to + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 100, + ), + ), + ) + + client.openReqSubscription(mySubId, filters) + + withTimeoutOrNull(30000) { + while (events.size < 101) { + val event = resultChannel.receive() + events.add(event) + } + } + + resultChannel.close() + + client.close(mySubId) + client.unsubscribe(listener) + client.disconnect() + + appScope.cancel() + + assertEquals(101, events.size) + assertEquals(true, events.take(100).all { it.length == 64 }) + assertEquals("EOSE", events[100]) + } +} 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 new file mode 100644 index 000000000..1fa725e3c --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -0,0 +1,137 @@ +/** + * 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 + +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.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientRepeatSubTest : BaseNostrClientTest() { + @Test + fun testRepeatSubEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val resultChannel = Channel(UNLIMITED) + val events = mutableListOf() + val mySubId = "test-sub-id-2" + + val listener = + object : IRelayClientListener { + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (mySubId == subId) { + resultChannel.trySend(event.id) + } + } + + override fun onEOSE( + relay: IRelayClient, + subId: String, + arrivalTime: Long, + ) { + if (mySubId == subId) { + resultChannel.trySend("EOSE") + } + } + } + + client.subscribe(listener) + + val filters = + mapOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io") to + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 100, + ), + ), + ) + + val filters2 = + mapOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io") to + listOf( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + limit = 100, + ), + ), + ) + + coroutineScope { + launch { + withTimeoutOrNull(30000) { + while (events.size < 202) { + // simulates an update in the middle of the sub + if (events.size == 1) { + client.openReqSubscription(mySubId, filters2) + } + val event = resultChannel.receive() + Log.d("OkHttpWebsocketListener", "Processing: ${events.size} $event") + events.add(event) + } + } + } + + launch { + client.openReqSubscription(mySubId, filters) + } + } + + client.close(mySubId) + client.unsubscribe(listener) + client.disconnect() + + appScope.cancel() + + assertEquals(202, events.size) + assertEquals(true, events.take(100).all { it.length == 64 }) + assertEquals("EOSE", events[100]) + assertEquals(true, events.drop(101).take(100).all { it.length == 64 }) + assertEquals("EOSE", events[201]) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt new file mode 100644 index 000000000..037fed402 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -0,0 +1,66 @@ +/** + * 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 + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientSendAndWaitTest : BaseNostrClientTest() { + @Test + fun testSendAndWaitForResponse() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val randomSigner = NostrSignerInternal(KeyPair()) + + val event = randomSigner.sign(TextNoteEvent.build("Hello World")) + + val resultDamus = + client.sendAndWaitForResponse( + event = event, + relayList = setOf(RelayUrlNormalizer.normalize("wss://relay.damus.io")), + ) + + val resultNos = + client.sendAndWaitForResponse( + event = event, + relayList = setOf(RelayUrlNormalizer.normalize("wss://nos.lol")), + ) + + client.disconnect() + appScope.cancel() + + assertEquals(true, resultDamus) + assertEquals(false, resultNos) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt new file mode 100644 index 000000000..f8eeb1acd --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -0,0 +1,85 @@ +/** + * 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 + +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.NostrClientSubscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientSubscriptionTest : BaseNostrClientTest() { + @Test + fun testNostrClientSubscription() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val resultChannel = Channel(UNLIMITED) + val events = mutableSetOf() + + val sub = + NostrClientSubscription( + client = client, + filter = { + mapOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io") to + listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 100, + ), + ), + ) + }, + ) { event -> + assertEquals(MetadataEvent.KIND, event.kind) + resultChannel.trySend(event) + } + + withTimeoutOrNull(30000) { + while (events.size < 100) { + val event = resultChannel.receive() + events.add(event) + } + } + + resultChannel.close() + + sub.closeSubscription() + + client.disconnect() + appScope.cancel() + + assertEquals(100, events.size) + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Log.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Log.jvm.kt index 29a4cb091..d58ad307a 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Log.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Log.jvm.kt @@ -20,16 +20,24 @@ */ package com.vitorpamplona.quartz.utils +import java.time.LocalTime +import java.time.format.DateTimeFormatter + actual object Log { + // Define a formatter for the desired output format (e.g., HH:mm:ss) + val formatter: DateTimeFormatter? = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + + fun time() = LocalTime.now().format(formatter) + actual fun w( tag: String, message: String, throwable: Throwable?, ) { if (throwable != null) { - println("WARN: [$tag] $message. Throwable: ${throwable.message}") + println("${time()} WARN : [$tag] $message. Throwable: ${throwable.message}") } else { - println("WARN: [$tag] $message") + println("${time()} WARN : [$tag] $message") } } @@ -39,9 +47,9 @@ actual object Log { throwable: Throwable?, ) { if (throwable != null) { - println("ERROR: [$tag] $message. Throwable: ${throwable.message}") + println("${time()} ERROR: [$tag] $message. Throwable: ${throwable.message}") } else { - println("ERROR: [$tag] $message") + println("${time()} ERROR: [$tag] $message") } } @@ -49,13 +57,13 @@ actual object Log { tag: String, message: String, ) { - println("DEBUG: [$tag] $message") + println("${time()} DEBUG: [$tag] $message") } actual fun i( tag: String, message: String, ) { - println("INFO: [$tag] $message") + println("${time()} INFO : [$tag] $message") } }