Merge branch 'vitorpamplona:main' into followset-improvements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+2
-4
@@ -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",
|
||||||
|
|||||||
+4
-5
@@ -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.
|
||||||
|
|||||||
+36
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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")
|
||||||
|
|||||||
+15
-9
@@ -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) {
|
||||||
|
|||||||
+83
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-8
@@ -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,
|
||||||
),
|
),
|
||||||
+3
-2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
-7
@@ -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>>()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+21
-17
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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(),
|
||||||
|
|||||||
+7
-1
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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(
|
||||||
|
|||||||
+2
-3
@@ -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 ->
|
||||||
|
|||||||
+1
-1
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+33
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+8
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
+55
-22
@@ -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,
|
||||||
|
|||||||
+9
-4
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-15
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -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 {
|
||||||
|
|||||||
+4
-15
@@ -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(
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
+10
-26
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -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
|
||||||
|
|||||||
+1
-14
@@ -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)
|
||||||
|
|||||||
+8
-8
@@ -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}")
|
||||||
|
|||||||
+48
-3
@@ -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)
|
||||||
|
|||||||
+31
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user