Merge branch 'vitorpamplona:main' into followset-improvements

This commit is contained in:
KotlinGeekDev
2025-10-04 06:48:59 +00:00
committed by GitHub
50 changed files with 1148 additions and 228 deletions
+2
View File
@@ -310,6 +310,8 @@ dependencies {
// Language picker and Theme chooser // Language picker and Theme chooser
implementation libs.androidx.appcompat implementation libs.androidx.appcompat
// Dynamically adjust between phone and tablet UI
implementation libs.androidx.window.core.android implementation libs.androidx.window.core.android
// Local model for language identification // Local model for language identification
@@ -50,8 +50,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText 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.R
import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler 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.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -130,7 +128,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
val astNode = val astNode =
remember { remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
} }
RichText( RichText(
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
class Amethyst : Application() { class Amethyst : Application() {
init {
Log.d("AmethystApp", "Creating App $this")
}
companion object { companion object {
lateinit var instance: AppModules lateinit var instance: AppModules
private set private set
@@ -25,6 +25,7 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
@@ -251,6 +252,11 @@ class AppModules(
fun initiate(appContext: Context) { fun initiate(appContext: Context) {
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) 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 // forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs uiPrefs
@@ -26,6 +26,7 @@ import android.content.pm.ApplicationInfo
import android.os.Debug import android.os.Debug
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@@ -68,6 +69,13 @@ fun debugState(context: Context) {
.value.available.size, .value.available.size,
) )
Log.d(
STATE_DUMP_TAG,
"Indexed Relays: " +
Amethyst.instance.cache.relayHints.relayDB
.size() + "/" + normalizedUrls.size(),
)
Log.d( Log.d(
STATE_DUMP_TAG, STATE_DUMP_TAG,
"Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB", "Image Disk Cache ${(Amethyst.instance.diskCache.size) / (1024 * 1024)}/${(Amethyst.instance.diskCache.maxSize) / (1024 * 1024)} MB",
@@ -36,7 +36,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -79,7 +78,7 @@ class FollowListState(
val userList = val userList =
flow flow
.map { .map {
it.authors.map { it.authors.mapNotNull {
cache.checkGetOrCreateUser(it) cache.checkGetOrCreateUser(it)
} }
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
@@ -87,7 +86,7 @@ class FollowListState(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
// this has priority. // this has priority.
flow.value.authors.map { flow.value.authors.mapNotNull {
cache.checkGetOrCreateUser(it) cache.checkGetOrCreateUser(it)
}, },
) )
@@ -160,10 +159,10 @@ class FollowListState(
init { init {
settings.backupContactList?.let { settings.backupContactList?.let {
Log.d("AccountRegisterObservers", "Loading saved contacts ${it.toJson()}") Log.d("AccountRegisterObservers", "Loading saved ${it.tags.size} contacts")
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
} }
// saves contact list for the next time. // saves contact list for the next time.
@@ -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.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -33,7 +40,6 @@ class OkHttpWebSocket(
val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient, val httpClient: (url: NormalizedRelayUrl) -> OkHttpClient,
val out: WebSocketListener, val out: WebSocketListener,
) : WebSocket { ) : WebSocket {
private val listener = OkHttpWebsocketListener()
private var usingOkHttp: OkHttpClient? = null private var usingOkHttp: OkHttpClient? = null
private var socket: okhttp3.WebSocket? = null private var socket: okhttp3.WebSocket? = null
@@ -64,10 +70,21 @@ class OkHttpWebSocket(
override fun connect() { override fun connect() {
usingOkHttp = httpClient(url) 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<String> = 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( override fun onOpen(
webSocket: okhttp3.WebSocket, webSocket: okhttp3.WebSocket,
response: Response, response: Response,
@@ -79,7 +96,12 @@ class OkHttpWebSocket(
override fun onMessage( override fun onMessage(
webSocket: okhttp3.WebSocket, webSocket: okhttp3.WebSocket,
text: String, 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( override fun onClosing(
webSocket: okhttp3.WebSocket, webSocket: okhttp3.WebSocket,
@@ -92,6 +114,11 @@ class OkHttpWebSocket(
code: Int, code: Int,
reason: String, reason: String,
) { ) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null socket = null
out.onClosed(code, reason) out.onClosed(code, reason)
} }
@@ -101,6 +128,11 @@ class OkHttpWebSocket(
t: Throwable, t: Throwable,
response: Response?, response: Response?,
) { ) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null socket = null
out.onFailure(t, response?.code, response?.message) out.onFailure(t, response?.code, response?.message)
} }
@@ -72,7 +72,7 @@ class RelayProxyClientConnector(
if (it.connectivity is ConnectivityStatus.StartingService) { if (it.connectivity is ConnectivityStatus.StartingService) {
// ignore // ignore
} else if (it.connectivity is ConnectivityStatus.Off) { } 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()) { if (client.isActive()) {
client.disconnect() client.disconnect()
} }
@@ -82,7 +82,7 @@ class RelayProxyClientConnector(
Log.d("ManageRelayServices", "Pausing Tor Activity") Log.d("ManageRelayServices", "Pausing Tor Activity")
} }
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) { } 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 val torStatus = torManager.status.value
if (torStatus is TorServiceStatus.Active) { if (torStatus is TorServiceStatus.Active) {
@@ -95,7 +95,10 @@ class RelayProxyClientConnector(
client.connect() client.connect()
} else { } else {
Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to") Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to")
client.reconnect(true) client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = true,
)
} }
}.onStart { }.onStart {
Log.d("ManageRelayServices", "Resuming Relay Services") Log.d("ManageRelayServices", "Resuming Relay Services")
@@ -29,17 +29,26 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
interface IEoseManager {
fun invalidateFilters(ignoreIfDoing: Boolean = false)
fun destroy()
fun printStats()
}
abstract class BaseEoseManager<T>( abstract class BaseEoseManager<T>(
val client: INostrClient, val client: INostrClient,
val allKeys: () -> Set<T>, val allKeys: () -> Set<T>,
) { val sampleTime: Long = 500,
) : IEoseManager {
protected val logTag: String = this.javaClass.simpleName protected val logTag: String = this.javaClass.simpleName
private val orchestrator = SubscriptionController(client) private val orchestrator = SubscriptionController(client)
abstract fun updateSubscriptions(keys: Set<T>) abstract fun updateSubscriptions(keys: Set<T>)
fun printStats() = orchestrator.printStats(logTag) override fun printStats() = orchestrator.printStats(logTag)
fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId() fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId()
@@ -50,21 +59,18 @@ abstract class BaseEoseManager<T>(
fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId)
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.Default) private val bundler = BundledUpdate(sampleTime, Dispatchers.Default)
fun invalidateFilters() { override fun invalidateFilters(ignoreIfDoing: Boolean) {
bundler.invalidate { bundler.invalidate(ignoreIfDoing, ::forceInvalidate)
forceInvalidate()
}
} }
fun forceInvalidate() { fun forceInvalidate() {
updateSubscriptions(allKeys()) updateSubscriptions(allKeys())
orchestrator.updateRelays() orchestrator.updateRelays()
} }
fun destroy() { override fun destroy() {
bundler.cancel() bundler.cancel()
orchestrator.destroy() orchestrator.destroy()
if (isDebug) { if (isDebug) {
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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<RelayBasedFilter> =
if (key.account.isWriteable()) {
relayFlow(key).value.flatMap {
listOf(
filterDraftsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time),
).flatten()
}
} else {
emptyList()
}
val userJobMap = mutableMapOf<User, List<Job>>()
@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() }
}
}
@@ -18,24 +18,20 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
val ReportsAndBookmarksFromKeyKinds = val DraftKinds =
listOf( listOf(
DraftWrapEvent.KIND, DraftWrapEvent.KIND,
ReportEvent.KIND,
BookmarkListEvent.KIND,
) )
fun filterDraftsAndReportsFromKey( fun filterDraftsFromKey(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
pubkey: HexKey?, pubkey: HexKey?,
since: Long?, since: Long?,
@@ -47,7 +43,7 @@ fun filterDraftsAndReportsFromKey(
relay = relay, relay = relay,
filter = filter =
Filter( Filter(
kinds = ReportsAndBookmarksFromKeyKinds, kinds = DraftKinds,
authors = listOf(pubkey), authors = listOf(pubkey),
since = since, since = since,
), ),
@@ -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.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -50,8 +51,8 @@ class AccountMetadataEoseManager(
listOf( listOf(
filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since), filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since),
filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since), filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since),
filterDraftsAndReportsFromKey(it, user(key).pubkeyHex, since), filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since),
filterLastPostsFromKey(it, user(key).pubkeyHex, since), filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()),
filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since), filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since),
).flatten() ).flatten()
} }
@@ -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<RelayBasedFilter> {
if (pubkey == null || pubkey.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = ReportsAndBookmarksFromKeyKinds,
authors = listOf(pubkey),
since = since,
),
),
)
}
@@ -42,14 +42,20 @@ class AccountGiftWrapsEoseManager(
override fun updateFilter( override fun updateFilter(
key: AccountQueryState, key: AccountQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
key.account.dmRelays.flow.value.flatMap { relay -> // Only loads DMs if the account is writeable
filterGiftWrapsToPubkey( return if (key.account.isWriteable()) {
relay = relay, key.account.dmRelays.flow.value.flatMap { relay ->
pubkey = user(key).pubkeyHex, filterGiftWrapsToPubkey(
since = since?.get(relay)?.time, relay = relay,
) pubkey = user(key).pubkeyHex,
since = since?.get(relay)?.time,
)
}
} else {
emptyList()
} }
}
val userJobMap = mutableMapOf<User, List<Job>>() val userJobMap = mutableMapOf<User, List<Job>>()
@@ -36,7 +36,7 @@ class EventWatcherSubAssembler(
allKeys: () -> Set<EventFinderQueryState>, allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubEoseManager<EventFinderQueryState>(client, allKeys) { ) : SingleSubEoseManager<EventFinderQueryState>(client, allKeys) {
var lastNotesOnFilter = emptyList<Note>() var lastNotesOnFilter = emptyList<Note>()
var latestEOSEs: EOSEAccountFast<Note> = EOSEAccountFast<Note>(10000) var latestEOSEs: EOSEAccountFast<Note> = EOSEAccountFast<Note>(1000)
override fun newEose( override fun newEose(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
@@ -26,15 +26,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.material3.RichText
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
@@ -69,24 +71,25 @@ fun RenderContentAsMarkdown(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val uri = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val onClick = val onClick =
remember { remember(uriHandler) {
{ link: String -> object : UriHandler {
val route = uriToRoute(link, accountViewModel.account) override fun openUri(uri: String) {
if (route != null) { val route = uriToRoute(uri, accountViewModel.account)
nav.nav(route) if (route != null) {
} else { nav.nav(route)
runCatching { uri.openUri(link) } } else {
runCatching { uriHandler.openUri(uri) }
}
} }
Unit
} }
} }
ProvideTextStyle(MarkdownTextStyle) { ProvideTextStyle(MarkdownTextStyle) {
val astNode = val astNode =
remember(content) { remember(content) {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
} }
val renderer = val renderer =
@@ -103,12 +106,13 @@ fun RenderContentAsMarkdown(
) )
} }
RichText( CompositionLocalProvider(LocalUriHandler provides onClick) {
style = MaterialTheme.colorScheme.markdownStyle, RichText(
linkClickHandler = onClick, style = MaterialTheme.colorScheme.markdownStyle,
renderer = renderer, renderer = renderer,
) { ) {
BasicMarkdown(astNode) BasicMarkdown(astNode)
}
} }
} }
} }
@@ -121,7 +121,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
if (notificationPermissionState.status.isGranted) { if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(key1 = accountViewModel, 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 { scope.launch {
PushNotificationUtils.checkAndInit( PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(), LocalPreferences.allSavedAccounts(),
@@ -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.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChatroomFilterSubAssembler( class ChatroomFilterSubAssembler(
client: INostrClient, client: INostrClient,
@@ -31,7 +32,12 @@ class ChatroomFilterSubAssembler(
override fun updateFilter( override fun updateFilter(
key: ChatroomQueryState, key: ChatroomQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
) = filterNip04DMs(key.room.users, key.account, since) ): List<RelayBasedFilter>? =
if (key.account.isWriteable()) {
filterNip04DMs(key.room.users, key.account, since)
} else {
emptyList()
}
override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun user(key: ChatroomQueryState) = key.account.userProfile()
@@ -40,12 +40,16 @@ class DMsFromUserFilterSubAssembler(
key: ChatroomListState, key: ChatroomListState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? = ): List<RelayBasedFilter>? =
key.account.homeRelays.flow.value.map { if (key.account.isWriteable()) {
filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) 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) 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() override fun user(key: ChatroomListState) = key.account.userProfile()
@@ -83,8 +83,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.material3.RichText
@@ -177,7 +177,7 @@ private fun DialogContents(
val astNode1 = val astNode1 =
remember { remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content1) CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content1)
} }
RichText( RichText(
@@ -205,7 +205,7 @@ private fun DialogContents(
val astNode = val astNode =
remember { remember {
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content) CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content)
} }
RichText( RichText(
@@ -47,7 +47,6 @@ val UserProfilePostKinds1 =
GenericRepostEvent.KIND, GenericRepostEvent.KIND,
RepostEvent.KIND, RepostEvent.KIND,
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND, PollNoteEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
WikiNoteEvent.KIND, WikiNoteEvent.KIND,
@@ -62,6 +61,7 @@ val UserProfilePostKinds2 =
InteractiveStoryPrologueEvent.KIND, InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
VoiceReplyEvent.KIND, VoiceReplyEvent.KIND,
PinListEvent.KIND,
) )
fun filterUserProfilePosts( fun filterUserProfilePosts(
@@ -70,8 +70,7 @@ fun filterUserProfilePosts(
): List<RelayBasedFilter> { ): List<RelayBasedFilter> {
val relays = val relays =
user.outboxRelays()?.ifEmpty { null } user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null } ?: (user.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays return relays
.map { relay -> .map { relay ->
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
class SearchRelayListViewModel : BasicRelaySetupInfoModel() { class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.searchRelayList.flow.value account.searchRelayList.flowNoDefaults.value
.toList() .toList()
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) { override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.theme package com.vitorpamplona.amethyst.ui.theme
import android.R.attr.fontFamily
import android.R.id.primary
import android.app.Activity import android.app.Activity
import android.app.UiModeManager import android.app.UiModeManager
import android.content.Context 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.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -292,8 +295,11 @@ val MarkDownStyleOnDark =
stringStyle = stringStyle =
RichTextDefaults.stringStyle?.copy( RichTextDefaults.stringStyle?.copy(
linkStyle = linkStyle =
SpanStyle( TextLinkStyles(
color = DarkColorPalette.primary, style =
SpanStyle(
color = DarkColorPalette.primary,
),
), ),
codeStyle = codeStyle =
SpanStyle( SpanStyle(
@@ -330,8 +336,11 @@ val MarkDownStyleOnLight =
stringStyle = stringStyle =
RichTextDefaults.stringStyle?.copy( RichTextDefaults.stringStyle?.copy(
linkStyle = linkStyle =
SpanStyle( TextLinkStyles(
color = LightColorPalette.primary, style =
SpanStyle(
color = LightColorPalette.primary,
),
), ),
codeStyle = codeStyle =
SpanStyle( SpanStyle(
@@ -100,17 +100,7 @@ class HexBenchmark {
} }
@Test @Test
fun newIsHex() { fun isHex64() {
val isHexChar = r.measureRepeated { Hex.isHex64(hex) }
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
}
} }
} }
+5 -5
View File
@@ -13,7 +13,7 @@ benchmark = "1.4.1"
benchmarkJunit4 = "1.4.1" benchmarkJunit4 = "1.4.1"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coil = "3.3.0" coil = "3.3.0"
composeBom = "2025.09.00" composeBom = "2025.09.01"
coreKtx = "1.17.0" coreKtx = "1.17.0"
datastore = "1.1.7" datastore = "1.1.7"
espressoCore = "3.7.0" espressoCore = "3.7.0"
@@ -34,11 +34,11 @@ lazysodiumAndroid = "5.2.0"
lazysodiumJava = "5.2.0" lazysodiumJava = "5.2.0"
lifecycleRuntimeKtx = "2.9.4" lifecycleRuntimeKtx = "2.9.4"
lightcompressor = "1.5.0" lightcompressor = "1.5.0"
markdown = "e1151c8" markdown = "f92ef49c9d"
media3 = "1.8.0" media3 = "1.8.0"
mockk = "1.14.5" mockk = "1.14.5"
kotlinx-coroutines-test = "1.10.2" kotlinx-coroutines-test = "1.10.2"
navigationCompose = "2.9.4" navigationCompose = "2.9.5"
okhttp = "5.1.0" okhttp = "5.1.0"
runner = "1.7.0" runner = "1.7.0"
rfc3986 = "0.1.2" rfc3986 = "0.1.2"
@@ -54,11 +54,11 @@ zelory = "3.0.1"
zoomable = "2.8.1" zoomable = "2.8.1"
zxing = "3.5.3" zxing = "3.5.3"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.4.0" windowCoreAndroid = "1.5.0"
androidxCamera = "1.5.0" androidxCamera = "1.5.0"
androidxCollection = "1.5.0" androidxCollection = "1.5.0"
kotlinStdlib = "2.2.20" kotlinStdlib = "2.2.20"
kotlinTest = "2.2.10" kotlinTest = "2.2.20"
core = "1.7.0" core = "1.7.0"
mavenPublish = "0.34.0" mavenPublish = "0.34.0"
+1 -1
View File
@@ -107,7 +107,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// in your shared module's dependencies block // in your shared module's dependencies block
implementation( libs.kotlinx.datetime) // implementation( libs.kotlinx.datetime)
// immutable collections to avoid recomposition // immutable collections to avoid recomposition
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
@@ -83,17 +83,50 @@ class HexEncodingTest {
assertFalse("`a", Hex.isHex("`a")) assertFalse("`a", Hex.isHex("`a"))
assertFalse("gg", Hex.isHex("gg")) assertFalse("gg", Hex.isHex("gg"))
assertFalse("fg", Hex.isHex("fg")) assertFalse("fg", Hex.isHex("fg"))
assertFalse("\uD83E\uDD70", Hex.isHex("\uD83E\uDD70"))
} }
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
@Test @Test
fun testRandomsIsHex() { fun testRandomsIsHex() {
val lowerCaseHexNeg = "ghijklmnopqrstuvwxyz"
val upperCaseHexNeg = "GHIJKLMNOPQRSTUVWXYZ"
for (i in 0..10000) { for (i in 0..10000) {
val bytes = RandomInstance.bytes(32) val bytes = RandomInstance.bytes(32)
val hex = bytes.toHexString(HexFormat.Default) val hex = bytes.toHexString(HexFormat.Default)
assertTrue(hex, Hex.isHex(hex)) assertTrue(hex, Hex.isHex(hex))
val hexUpper = bytes.toHexString(HexFormat.UpperCase) val hexUpper = bytes.toHexString(HexFormat.UpperCase)
assertTrue(hexUpper, Hex.isHex(hexUpper)) 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))
} }
} }
@@ -42,7 +42,8 @@ class HintIndexer {
// 3.75MB for keys // 3.75MB for keys
private val pubKeyHints = BloomFilterMurMur3(30_000_000, 10) private val pubKeyHints = BloomFilterMurMur3(30_000_000, 10)
private val relayDB = LargeCache<NormalizedRelayUrl, NormalizedRelayUrl>()
val relayDB = LargeCache<NormalizedRelayUrl, NormalizedRelayUrl>()
private fun add( private fun add(
id: ByteArray, id: ByteArray,
@@ -36,7 +36,10 @@ interface INostrClient {
fun disconnect() fun disconnect()
fun reconnect(onlyIfChanged: Boolean = false) fun reconnect(
onlyIfChanged: Boolean = false,
ignoreRetryDelays: Boolean = false,
)
fun isActive(): Boolean fun isActive(): Boolean
@@ -78,7 +81,10 @@ object EmptyNostrClient : INostrClient {
override fun disconnect() { } override fun disconnect() { }
override fun reconnect(onlyIfChanged: Boolean) { } override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) { }
override fun isActive() = false override fun isActive() = false
@@ -37,13 +37,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn 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: * 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 // 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 // new filters will be sent to the relays and a potential reconnect can
// be triggered. // be triggered.
private var isActive = false // STARTS active
private var isActive = true
/** /**
* Whatches for any changes in the relay list from subscriptions or outbox * Whatches for any changes in the relay list from subscriptions or outbox
@@ -115,12 +118,13 @@ class NostrClient(
socketBuilder = websocketBuilder, socketBuilder = websocketBuilder,
listener = relayPool, listener = relayPool,
stats = RelayStats.get(relay), stats = RelayStats.get(relay),
scope = scope,
) { liveRelay -> ) { liveRelay ->
if (isActive) { if (isActive) {
activeRequests.forEachSub(relay, liveRelay::sendRequest) scope.launch(Dispatchers.Default) {
activeCounts.forEachSub(relay, liveRelay::sendCount) activeRequests.forEachSub(relay, liveRelay::sendRequest)
eventOutbox.forEachUnsentEvent(relay, liveRelay::send) activeCounts.forEachSub(relay, liveRelay::sendCount)
eventOutbox.forEachUnsentEvent(relay, liveRelay::send)
}
} }
} }
@@ -137,21 +141,35 @@ class NostrClient(
override fun isActive() = isActive override fun isActive() = isActive
val myReconnectMutex = Mutex() class Reconnect(
val onlyIfChanged: Boolean,
val ignoreRetryDelays: Boolean,
)
override fun reconnect(onlyIfChanged: Boolean) { val refreshConnection = MutableStateFlow(Reconnect(false, false))
if (myReconnectMutex.tryLock()) {
try { @OptIn(FlowPreview::class)
if (onlyIfChanged) { val debouncingConnection =
relayPool.reconnectIfNeedsToORIfItIsTime() refreshConnection
.debounce(200)
.onEach {
if (it.onlyIfChanged) {
relayPool.reconnectIfNeedsTo(it.ignoreRetryDelays)
} else { } else {
relayPool.disconnect() relayPool.disconnect()
relayPool.connect() relayPool.connect()
} }
} finally { }.stateIn(
myReconnectMutex.unlock() scope,
} SharingStarted.Eagerly,
} false,
)
override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) {
refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays))
} }
fun needsToResendRequest( fun needsToResendRequest(
@@ -228,7 +246,10 @@ class NostrClient(
if (newFilters.isNullOrEmpty()) { if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions // 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()) { } else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them // new relays were added. Start a new sub in them
relayPool.sendRequest(relay, subId, newFilters) relayPool.sendRequest(relay, subId, newFilters)
@@ -244,7 +265,7 @@ class NostrClient(
} }
// wakes up all the other relays // wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime() reconnect(true)
} }
} }
@@ -264,7 +285,10 @@ class NostrClient(
if (newFilters.isNullOrEmpty()) { if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions // 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()) { } else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them // new relays were added. Start a new sub in them
relayPool.sendCount(relay, subId, newFilters) relayPool.sendCount(relay, subId, newFilters)
@@ -280,7 +304,7 @@ class NostrClient(
} }
// wakes up all the other relays // wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime() reconnect(true)
} }
} }
@@ -292,7 +316,7 @@ class NostrClient(
relayPool.getRelay(connectedRelay)?.send(event) relayPool.getRelay(connectedRelay)?.send(event)
// wakes up all the other relays // wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime() reconnect(true)
} }
} }
@@ -301,11 +325,12 @@ class NostrClient(
relayList: Set<NormalizedRelayUrl>, relayList: Set<NormalizedRelayUrl>,
) { ) {
eventOutbox.markAsSending(event, relayList) eventOutbox.markAsSending(event, relayList)
if (isActive) { if (isActive) {
relayPool.send(event, relayList) relayPool.send(event, relayList)
// wakes up all the other relays // wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime() reconnect(true)
} }
} }
@@ -333,6 +358,14 @@ class NostrClient(
listeners.forEach { it.onEOSE(relay, subId, arrivalTime) } 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( override fun onRelayStateChange(
relay: IRelayClient, relay: IRelayClient,
type: RelayState, type: RelayState,
@@ -34,10 +34,6 @@ class NostrClientSubscription(
) : IRelayClientListener { ) : IRelayClientListener {
private val subId = RandomInstance.randomChars(10) private val subId = RandomInstance.randomChars(10)
init {
client.subscribe(this)
}
override fun onEvent( override fun onEvent(
relay: IRelayClient, relay: IRelayClient,
subId: String, subId: String,
@@ -57,4 +53,13 @@ class NostrClientSubscription(
fun updateFilter() = client.openReqSubscription(subId, filter()) fun updateFilter() = client.openReqSubscription(subId, filter())
fun closeSubscription() = client.close(subId) fun closeSubscription() = client.close(subId)
fun destroy() {
client.unsubscribe(this)
}
init {
client.subscribe(this)
updateFilter()
}
} }
@@ -32,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -46,7 +47,7 @@ suspend fun INostrClient.sendAndWaitForResponse(
relayList: Set<NormalizedRelayUrl>, relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15, timeoutInSeconds: Long = 15,
): Boolean { ): Boolean {
val resultChannel = Channel<Result>() val resultChannel = Channel<Result>(UNLIMITED)
Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses") Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses")
@@ -91,25 +92,28 @@ suspend fun INostrClient.sendAndWaitForResponse(
// subscribe before sending the result. // subscribe before sending the result.
val resultSubscription = val resultSubscription =
coroutineScope { coroutineScope {
async(Dispatchers.IO) { val result =
val receivedResults = mutableMapOf<NormalizedRelayUrl, Boolean>() async(Dispatchers.IO) {
// The withTimeout block will cancel the coroutine if the loop takes too long val receivedResults = mutableMapOf<NormalizedRelayUrl, Boolean>()
withTimeoutOrNull(timeoutInSeconds * 1000) { // The withTimeout block will cancel the coroutine if the loop takes too long
while (receivedResults.size < relayList.size) { withTimeoutOrNull(timeoutInSeconds * 1000) {
val result = resultChannel.receive() while (receivedResults.size < relayList.size) {
val result = resultChannel.receive()
val currentResult = receivedResults[result.relay] val currentResult = receivedResults[result.relay]
// do not override a successful result. // do not override a successful result.
if (currentResult == null || !currentResult) { if (currentResult == null || !currentResult) {
receivedResults.put(result.relay, result.success) receivedResults.put(result.relay, result.success)
}
} }
} }
receivedResults
} }
receivedResults
}
}
send(event, relayList) send(event, relayList)
result
}
val receivedResults = resultSubscription.await() val receivedResults = resultSubscription.await()
@@ -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.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeoutOrNull 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<Filter>,
) = downloadFirstEvent(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters))
suspend fun INostrClient.downloadFirstEvent(
subscriptionId: String = newSubId(),
relay: String,
filters: List<Filter>,
) = 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<Filter>,
) = downloadFirstEvent(newSubId(), mapOf(relay to filters))
suspend fun INostrClient.downloadFirstEvent(
subscriptionId: String = newSubId(),
relay: NormalizedRelayUrl,
filters: List<Filter>,
) = downloadFirstEvent(subscriptionId, mapOf(relay to filters))
suspend fun INostrClient.downloadFirstEvent( suspend fun INostrClient.downloadFirstEvent(
subscriptionId: String = newSubId(), subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>, filters: Map<NormalizedRelayUrl, List<Filter>>,
): Event? { ): Event? {
val resultChannel = Channel<Event>() val resultChannel = Channel<Event>(UNLIMITED)
val listener = val listener =
object : IRelayClientListener { object : IRelayClientListener {
@@ -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.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -71,17 +70,7 @@ class RelayPool(
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
var lastReconnectCall = TimeUtils.now() fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) {
fun reconnectIfNeedsToORIfItIsTime() {
if (lastReconnectCall < TimeUtils.oneMinuteAgo()) {
reconnectIfNeedsTo()
lastReconnectCall = TimeUtils.now()
}
}
fun reconnectIfNeedsTo() {
relays.forEach { url, relay -> relays.forEach { url, relay ->
if (relay.isConnected()) { if (relay.isConnected()) {
if (relay.needsToReconnect()) { if (relay.needsToReconnect()) {
@@ -91,7 +80,7 @@ class RelayPool(
} }
} else { } else {
// relay is not connected. Connect if it is time // relay is not connected. Connect if it is time
relay.connectAndSyncFiltersIfDisconnected() relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays)
} }
} }
updateStatus() updateStatus()
@@ -125,7 +114,7 @@ class RelayPool(
subId: String, subId: String,
filters: List<Filter>, filters: List<Filter>,
) { ) {
relays.get(relay)?.sendRequest(subId, filters) getOrCreateRelay(relay).sendRequest(subId, filters)
} }
fun sendRequest( fun sendRequest(
@@ -145,7 +134,7 @@ class RelayPool(
subId: String, subId: String,
filters: List<Filter>, filters: List<Filter>,
) { ) {
relays.get(relay)?.sendCount(subId, filters) getOrCreateRelay(relay).sendCount(subId, filters)
} }
fun sendCount( fun sendCount(
@@ -34,7 +34,7 @@ interface IRelayClient {
fun connectAndRunAfterSync(onConnected: () -> Unit) fun connectAndRunAfterSync(onConnected: () -> Unit)
fun connectAndSyncFiltersIfDisconnected() fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean = false)
fun isConnected(): Boolean fun isConnected(): Boolean
@@ -51,9 +51,6 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory 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.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.cancellation.CancellationException 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. * @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic.
* *
* Reconnection Strategy: * 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. * - Doubles the delay between reconnection attempts in case of failure.
* *
* Message Handling: * Message Handling:
@@ -82,13 +79,11 @@ open class BasicRelayClient(
val socketBuilder: WebsocketBuilder, val socketBuilder: WebsocketBuilder,
val listener: IRelayClientListener, val listener: IRelayClientListener,
val stats: RelayStat = RelayStat(), val stats: RelayStat = RelayStat(),
val scope: CoroutineScope,
val defaultOnConnect: (BasicRelayClient) -> Unit = { }, val defaultOnConnect: (BasicRelayClient) -> Unit = { },
) : IRelayClient { ) : IRelayClient {
companion object { 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 DELAY_TO_RECONNECT_IN_SECS = 1
const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\""
} }
private val logTag = "Relay ${url.displayUrl()}" private val logTag = "Relay ${url.displayUrl()}"
@@ -166,24 +161,13 @@ open class BasicRelayClient(
markConnectionAsReady(pingMillis, compression) markConnectionAsReady(pingMillis, compression)
scope.launch(Dispatchers.Default) { onConnected()
onConnected()
}
listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED) listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED)
} }
override fun onMessage(text: String) { override fun onMessage(text: String) {
// Log.d(logTag, "Receiving: $text") consumeIncomingMessage(text, onConnected)
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)
}
} }
override fun onClosing( override fun onClosing(
@@ -237,7 +221,7 @@ open class BasicRelayClient(
} }
} }
fun consumeIncomingCommand( fun consumeIncomingMessage(
text: String, text: String,
onConnected: () -> Unit, onConnected: () -> Unit,
) { ) {
@@ -258,7 +242,7 @@ open class BasicRelayClient(
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
stats.newError("Error processing: $text") 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")) listener.onError(this@BasicRelayClient, "", Error("Error processing $text"))
} }
} }
@@ -296,7 +280,7 @@ open class BasicRelayClient(
} }
private fun processEose(msg: EoseMessage) { private fun processEose(msg: EoseMessage) {
// Log.w(logTag, "EOSE ${msg.subId}") Log.d(logTag, "EOSE ${msg.subId}")
afterEOSEPerSubscription[msg.subId] = true afterEOSEPerSubscription[msg.subId] = true
listener.onEOSE(this, msg.subId, TimeUtils.now()) listener.onEOSE(this, msg.subId, TimeUtils.now())
} }
@@ -311,7 +295,7 @@ open class BasicRelayClient(
msg: OkMessage, msg: OkMessage,
onConnected: () -> Unit, 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 this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
if (authResponseWatcher.containsKey(msg.eventId)) { if (authResponseWatcher.containsKey(msg.eventId)) {
@@ -403,10 +387,10 @@ open class BasicRelayClient(
} }
} }
override fun connectAndSyncFiltersIfDisconnected() { override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {
if (!isConnectionStarted() && !connectingMutex.load()) { if (!isConnectionStarted() && !connectingMutex.load()) {
// waits 60 seconds to reconnect after disconnected. // waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) {
upRelayDelayToConnect() upRelayDelayToConnect()
connect() connect()
} }
@@ -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.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder 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 * This relay client saves any event that will be sent in an outbox
@@ -37,13 +36,11 @@ class SimpleRelayClient(
socketBuilder: WebsocketBuilder, socketBuilder: WebsocketBuilder,
listener: IRelayClientListener, listener: IRelayClientListener,
stats: RelayStat = RelayStat(), stats: RelayStat = RelayStat(),
scopeToParseEvents: CoroutineScope,
defaultOnConnect: (BasicRelayClient) -> Unit = { }, defaultOnConnect: (BasicRelayClient) -> Unit = { },
) : IRelayClient by BasicRelayClient( ) : IRelayClient by BasicRelayClient(
url, url,
socketBuilder, socketBuilder,
OutboxCache(listener), OutboxCache(listener),
stats, stats,
scopeToParseEvents,
defaultOnConnect, defaultOnConnect,
) )
@@ -58,13 +58,13 @@ class ContactListEvent(
/** /**
* Returns a list of p-tags that are verified as hex keys. * Returns a list of p-tags that are verified as hex keys.
*/ */
fun verifiedFollowKeySet(): Set<HexKey> = tags.mapNotNullTo(HashSet(), ContactTag::parseValidKey) fun verifiedFollowKeySet(): Set<HexKey> = tags.mapNotNullTo(mutableSetOf(), ContactTag::parseValidKey)
/** /**
* Returns a list of a-tags that are verified as correct. * Returns a list of a-tags that are verified as correct.
*/ */
@Deprecated("Use CommunityListEvent instead.") @Deprecated("Use CommunityListEvent instead.")
fun verifiedFollowAddressSet(): Set<HexKey> = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress) fun verifiedFollowAddressSet(): Set<HexKey> = tags.mapNotNullTo(mutableSetOf(), ATag::parseValidAddress)
fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey)
@@ -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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.bytesUsedInMemory
@@ -100,7 +101,11 @@ data class ContactTag(
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null } ensure(tag[1].length == 64) { return null }
return try { return try {
decodePublicKey(tag[1]).toHexKey() if (Hex.isHex64(tag[1])) {
tag[1]
} else {
null
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse contact list pubkey ${tag.joinToString(", ")}", e) Log.w("ContactListEvent", "Can't parse contact list pubkey ${tag.joinToString(", ")}", e)
null null
@@ -20,12 +20,6 @@
*/ */
package com.vitorpamplona.quartz.nip03Timestamp.ots 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. * 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 * Returns, if existing, a string representation describing the existence of a block attest
*/ */
@OptIn(ExperimentalTime::class)
override fun toString(): String { override fun toString(): String {
if (height == 0 || timestamp == null) { if (height == 0 || timestamp == null) {
return "" return ""
} }
// 1. Create an Instant from the Unix timestamp (milliseconds) return "block $height attests data existed as of unix timestamp of $timestamp"
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"
} }
override fun compareTo(other: VerifyResult): Int = this.height - other.height override fun compareTo(other: VerifyResult): Int = this.height - other.height
@@ -36,23 +36,114 @@ object Hex {
(LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code (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 { fun isHex(hex: String?): Boolean {
if (hex.isNullOrEmpty()) return false if (hex == null) return false
if (hex.length and 1 != 0) return false if (hex.length and 1 != 0) return false
try { return try {
for (c in hex.indices) { internalIsHex(hex, hexToByte)
if (c < 0 || c > 255) return false
if (hexToByte[hex[c].code] < 0) return false
}
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
// there are p tags with emoji's which makes the hex[c].code > 256 // 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 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 { fun decode(hex: String): ByteArray {
// faster version of hex decoder // faster version of hex decoder
require(hex.length and 1 == 0) require(hex.length and 1 == 0)
@@ -32,6 +32,14 @@ class FilterSerializer : StdSerializer<Filter>(Filter::class.java) {
) { ) {
gen.writeStartObject() gen.writeStartObject()
filter.kinds?.run {
gen.writeArrayFieldStart("kinds")
for (i in indices) {
gen.writeNumber(this[i])
}
gen.writeEndArray()
}
filter.ids?.run { filter.ids?.run {
gen.writeArrayFieldStart("ids") gen.writeArrayFieldStart("ids")
for (i in indices) { for (i in indices) {
@@ -48,14 +56,6 @@ class FilterSerializer : StdSerializer<Filter>(Filter::class.java) {
gen.writeEndArray() gen.writeEndArray()
} }
filter.kinds?.run {
gen.writeArrayFieldStart("kinds")
for (i in indices) {
gen.writeNumber(this[i])
}
gen.writeEndArray()
}
filter.tags?.run { filter.tags?.run {
entries.forEach { kv -> entries.forEach { kv ->
gen.writeArrayFieldStart("#${kv.key}") gen.writeArrayFieldStart("#${kv.key}")
@@ -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.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -35,6 +43,14 @@ class BasicOkHttpWebSocket(
val httpClient: (NormalizedRelayUrl) -> OkHttpClient, val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
val out: WebSocketListener, val out: WebSocketListener,
) : WebSocket { ) : 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 private var socket: OkHttpWebSocket? = null
override fun needsReconnect() = socket == null override fun needsReconnect() = socket == null
@@ -44,6 +60,15 @@ class BasicOkHttpWebSocket(
val listener = val listener =
object : OkHttpWebSocketListener() { object : OkHttpWebSocketListener() {
val scope = CoroutineScope(Dispatchers.Default + exceptionHandler)
val incomingMessages: Channel<String> = 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( override fun onOpen(
webSocket: OkHttpWebSocket, webSocket: OkHttpWebSocket,
response: Response, response: Response,
@@ -55,7 +80,13 @@ class BasicOkHttpWebSocket(
override fun onMessage( override fun onMessage(
webSocket: OkHttpWebSocket, webSocket: OkHttpWebSocket,
text: String, 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( override fun onClosing(
webSocket: OkHttpWebSocket, webSocket: OkHttpWebSocket,
@@ -67,13 +98,27 @@ class BasicOkHttpWebSocket(
webSocket: OkHttpWebSocket, webSocket: OkHttpWebSocket,
code: Int, code: Int,
reason: String, 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( override fun onFailure(
webSocket: OkHttpWebSocket, webSocket: OkHttpWebSocket,
t: Throwable, t: Throwable,
response: Response?, 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) socket = httpClient(url).newWebSocket(request, listener)
@@ -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 }
}
}
@@ -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)
}
}
@@ -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<String>(UNLIMITED)
val events = mutableListOf<String>()
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])
}
}
@@ -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<String>(UNLIMITED)
val events = mutableListOf<String>()
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])
}
}
@@ -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)
}
}
@@ -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<Event>(UNLIMITED)
val events = mutableSetOf<Event>()
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)
}
}
@@ -20,16 +20,24 @@
*/ */
package com.vitorpamplona.quartz.utils package com.vitorpamplona.quartz.utils
import java.time.LocalTime
import java.time.format.DateTimeFormatter
actual object Log { 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( actual fun w(
tag: String, tag: String,
message: String, message: String,
throwable: Throwable?, throwable: Throwable?,
) { ) {
if (throwable != null) { if (throwable != null) {
println("WARN: [$tag] $message. Throwable: ${throwable.message}") println("${time()} WARN : [$tag] $message. Throwable: ${throwable.message}")
} else { } else {
println("WARN: [$tag] $message") println("${time()} WARN : [$tag] $message")
} }
} }
@@ -39,9 +47,9 @@ actual object Log {
throwable: Throwable?, throwable: Throwable?,
) { ) {
if (throwable != null) { if (throwable != null) {
println("ERROR: [$tag] $message. Throwable: ${throwable.message}") println("${time()} ERROR: [$tag] $message. Throwable: ${throwable.message}")
} else { } else {
println("ERROR: [$tag] $message") println("${time()} ERROR: [$tag] $message")
} }
} }
@@ -49,13 +57,13 @@ actual object Log {
tag: String, tag: String,
message: String, message: String,
) { ) {
println("DEBUG: [$tag] $message") println("${time()} DEBUG: [$tag] $message")
} }
actual fun i( actual fun i(
tag: String, tag: String,
message: String, message: String,
) { ) {
println("INFO: [$tag] $message") println("${time()} INFO : [$tag] $message")
} }
} }