Restructuring Relay systems to:
- Maintain order of incoming messages for relay listeners - Defers all processing of incoming messages to coroutines via channels, freeing OkHttp's thread as soon as possible. - Simplifies the main relay class by using attached listener modules for each function of the relay client. - Migrate defaultOnConnect calls to become listener based and moved to NostrClients - Treat counts as query only, not subscriptions. - Coordinates REQs so that if an update is required to be sent but the server has not finished processing events, waits for it to finish and sends it later as soon as EOSE or Close arrives - Correctly sustain the local state of each Req. - Creates an Account follow list per Relay state that only includes shared relays as a better source of functioning relays - Changes UserLoading features in a tentative to make them faster since they are used by all functions in the app. - Correctly marks EOSE for filters that are aligned with the Req State from NostrClient - Avoid subsequent REQ updates before EOSE or CLOSE calls. - Refactoring RelayClient listener to be not dependent of which module is active for a relay client. - Refactors authenticators to do complete operation as a module - Breaks down Relay Client modules (Auth, Reqs, Counts, Event submissions) in the Relay Pool class. - Creates listeners just for special REQ situations - Move statistics to outside the base relay class as a listener - Move logs to outside the base relay class as a listener - Better structures a Standalone Relay client - More appropriately communicate errors to the listeners - Remove relay state on listeners
This commit is contained in:
+17
-14
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -43,22 +45,25 @@ interface INostrClient {
|
||||
|
||||
fun isActive(): Boolean
|
||||
|
||||
/**
|
||||
* Sends all current filters, events, etc to the relay.
|
||||
* This is called every time the relay connects
|
||||
* and when auth is successful
|
||||
*/
|
||||
fun renewFilters(relay: IRelayClient)
|
||||
|
||||
fun openReqSubscription(
|
||||
subId: String = newSubId(),
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener? = null,
|
||||
)
|
||||
|
||||
fun openCountSubscription(
|
||||
fun queryCount(
|
||||
subId: String = newSubId(),
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
)
|
||||
|
||||
fun close(subscriptionId: String)
|
||||
|
||||
fun sendIfExists(
|
||||
event: Event,
|
||||
connectedRelay: NormalizedRelayUrl,
|
||||
)
|
||||
fun close(subId: String)
|
||||
|
||||
fun send(
|
||||
event: Event,
|
||||
@@ -88,22 +93,20 @@ object EmptyNostrClient : INostrClient {
|
||||
|
||||
override fun isActive() = false
|
||||
|
||||
override fun renewFilters(relay: IRelayClient) { }
|
||||
|
||||
override fun openReqSubscription(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
) { }
|
||||
|
||||
override fun openCountSubscription(
|
||||
override fun queryCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) { }
|
||||
|
||||
override fun close(subscriptionId: String) { }
|
||||
|
||||
override fun sendIfExists(
|
||||
event: Event,
|
||||
connectedRelay: NormalizedRelayUrl,
|
||||
) { }
|
||||
override fun close(subId: String) { }
|
||||
|
||||
override fun send(
|
||||
event: Event,
|
||||
|
||||
+104
-228
@@ -23,18 +23,20 @@ package com.vitorpamplona.quartz.nip01Core.relay.client
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutboxRepository
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolSubscriptionRepository
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolCounts
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolEventOutbox
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -77,17 +79,18 @@ class NostrClient(
|
||||
private val scope: CoroutineScope,
|
||||
) : INostrClient,
|
||||
IRelayClientListener {
|
||||
private val relayPool: RelayPool = RelayPool(this, ::buildRelay)
|
||||
private val activeRequests: PoolSubscriptionRepository = PoolSubscriptionRepository()
|
||||
private val activeCounts: PoolSubscriptionRepository = PoolSubscriptionRepository()
|
||||
private val eventOutbox: PoolEventOutboxRepository = PoolEventOutboxRepository()
|
||||
private val relayPool: RelayPool = RelayPool(websocketBuilder, this)
|
||||
|
||||
private val activeRequests: PoolRequests = PoolRequests()
|
||||
private val activeCounts: PoolCounts = PoolCounts()
|
||||
private val eventOutbox: PoolEventOutbox = PoolEventOutbox()
|
||||
|
||||
private var listeners = setOf<IRelayClientListener>()
|
||||
|
||||
// controls the state of the client in such a way that if it is active
|
||||
// new filters will be sent to the relays and a potential reconnect can
|
||||
// be triggered.
|
||||
// STARTS active
|
||||
// Default: STARTS active
|
||||
private var isActive = true
|
||||
|
||||
/**
|
||||
@@ -97,7 +100,7 @@ class NostrClient(
|
||||
@OptIn(FlowPreview::class)
|
||||
private val allRelays =
|
||||
combine(
|
||||
activeRequests.relays,
|
||||
activeRequests.desiredRelays,
|
||||
activeCounts.relays,
|
||||
eventOutbox.relays,
|
||||
) { reqs, counts, outbox ->
|
||||
@@ -109,25 +112,9 @@ class NostrClient(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value,
|
||||
activeRequests.desiredRelays.value + activeCounts.relays.value + eventOutbox.relays.value,
|
||||
)
|
||||
|
||||
fun buildRelay(relay: NormalizedRelayUrl): IRelayClient =
|
||||
BasicRelayClient(
|
||||
url = relay,
|
||||
socketBuilder = websocketBuilder,
|
||||
listener = relayPool,
|
||||
stats = RelayStats.get(relay),
|
||||
) { liveRelay ->
|
||||
if (isActive) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
activeRequests.forEachSub(relay, liveRelay::sendRequest)
|
||||
activeCounts.forEachSub(relay, liveRelay::sendCount)
|
||||
eventOutbox.forEachUnsentEvent(relay, liveRelay::send)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnects all relays that may have disconnected
|
||||
override fun connect() {
|
||||
isActive = true
|
||||
@@ -172,134 +159,42 @@ class NostrClient(
|
||||
refreshConnection.tryEmit(Reconnect(onlyIfChanged, ignoreRetryDelays))
|
||||
}
|
||||
|
||||
fun needsToResendRequest(
|
||||
oldFilters: List<Filter>,
|
||||
newFilters: List<Filter>,
|
||||
): Boolean {
|
||||
if (oldFilters.size != newFilters.size) return true
|
||||
|
||||
oldFilters.forEachIndexed { index, oldFilter ->
|
||||
val newFilter = newFilters.getOrNull(index) ?: return true
|
||||
|
||||
return needsToResendRequest(oldFilter, newFilter)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the filter has changed, with a special case for when the since changes due to new
|
||||
* EOSE times.
|
||||
*/
|
||||
fun needsToResendRequest(
|
||||
oldFilter: Filter,
|
||||
newFilter: Filter,
|
||||
): Boolean {
|
||||
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
|
||||
// fast check
|
||||
if (oldFilter.authors?.size != newFilter.authors?.size ||
|
||||
oldFilter.ids?.size != newFilter.ids?.size ||
|
||||
oldFilter.tags?.size != newFilter.tags?.size ||
|
||||
oldFilter.kinds?.size != newFilter.kinds?.size ||
|
||||
oldFilter.limit != newFilter.limit ||
|
||||
oldFilter.search?.length != newFilter.search?.length ||
|
||||
oldFilter.until != newFilter.until
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// deep check
|
||||
if (oldFilter.ids != newFilter.ids ||
|
||||
oldFilter.authors != newFilter.authors ||
|
||||
oldFilter.tags != newFilter.tags ||
|
||||
oldFilter.kinds != newFilter.kinds ||
|
||||
oldFilter.search != newFilter.search
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (oldFilter.since != null) {
|
||||
if (newFilter.since == null) {
|
||||
// went was checking the future only and now wants everything
|
||||
return true
|
||||
} else if (oldFilter.since > newFilter.since) {
|
||||
// went backwards in time, forces update
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun openReqSubscription(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
) {
|
||||
val oldFilters = activeRequests.getSubscriptionFiltersOrNull(subId) ?: emptyMap()
|
||||
activeRequests.addOrUpdate(subId, filters)
|
||||
val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener)
|
||||
|
||||
if (isActive) {
|
||||
val allRelays = filters.keys + oldFilters.keys
|
||||
|
||||
allRelays.forEach { relay ->
|
||||
val oldFilters = oldFilters[relay]
|
||||
val newFilters = filters[relay]
|
||||
|
||||
if (newFilters.isNullOrEmpty()) {
|
||||
// some relays are not in this sub anymore. Stop their subscriptions
|
||||
if (!oldFilters.isNullOrEmpty()) {
|
||||
// only update if the old filters are not already closed.
|
||||
relayPool.close(relay, subId)
|
||||
}
|
||||
} else if (oldFilters.isNullOrEmpty()) {
|
||||
// new relays were added. Start a new sub in them
|
||||
relayPool.sendRequest(relay, subId, newFilters)
|
||||
} else if (needsToResendRequest(oldFilters, newFilters)) {
|
||||
// filters were changed enough (not only an update in since) to warn a new update
|
||||
relayPool.sendRequest(relay, subId, newFilters)
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
|
||||
if (cmd is CloseCmd || cmd is AuthCmd) {
|
||||
relayPool.sendIfConnected(relay, cmd)
|
||||
} else {
|
||||
// makes sure the relay wakes up if it was disconnected by the server
|
||||
// upon connection, the relay will run the default Sync and update all
|
||||
// filters, including this one.
|
||||
relayPool.connectIfDisconnected(relay)
|
||||
relayPool.sendOrConnectAndSync(relay, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// wakes up all the other relays
|
||||
// makes sure the relay wakes up if it was disconnected by the server
|
||||
// upon connection, the relay will run the default Sync and update all
|
||||
// filters, including this one.
|
||||
reconnect(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openCountSubscription(
|
||||
override fun queryCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
val oldFilters = activeCounts.getSubscriptionFiltersOrNull(subId) ?: emptyMap()
|
||||
activeCounts.addOrUpdate(subId, filters)
|
||||
val relaysToUpdate = activeCounts.addOrUpdate(subId, filters)
|
||||
|
||||
if (isActive) {
|
||||
val allRelays = filters.keys + oldFilters.keys
|
||||
|
||||
allRelays.forEach { relay ->
|
||||
val oldFilters = oldFilters[relay]
|
||||
val newFilters = filters[relay]
|
||||
|
||||
if (newFilters.isNullOrEmpty()) {
|
||||
// some relays are not in this sub anymore. Stop their subscriptions
|
||||
if (!oldFilters.isNullOrEmpty()) {
|
||||
// only update if the old filters are not already closed.
|
||||
relayPool.close(relay, subId)
|
||||
}
|
||||
} else if (oldFilters.isNullOrEmpty()) {
|
||||
// new relays were added. Start a new sub in them
|
||||
relayPool.sendCount(relay, subId, newFilters)
|
||||
} else if (needsToResendRequest(oldFilters, newFilters)) {
|
||||
// filters were changed enough (not only an update in since) to warn a new update
|
||||
relayPool.sendCount(relay, subId, newFilters)
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
|
||||
if (cmd is CloseCmd || cmd is AuthCmd) {
|
||||
relayPool.sendIfConnected(relay, cmd)
|
||||
} else {
|
||||
// makes sure the relay wakes up if it was disconnected by the server
|
||||
// upon connection, the relay will run the default Sync and update all
|
||||
// filters, including this one.
|
||||
relayPool.connectIfDisconnected(relay)
|
||||
relayPool.sendOrConnectAndSync(relay, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,123 +203,104 @@ class NostrClient(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendIfExists(
|
||||
event: Event,
|
||||
connectedRelay: NormalizedRelayUrl,
|
||||
) {
|
||||
if (isActive) {
|
||||
relayPool.getRelay(connectedRelay)?.send(event)
|
||||
|
||||
// wakes up all the other relays
|
||||
reconnect(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun send(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
eventOutbox.markAsSending(event, relayList)
|
||||
val relaysToUpdate = eventOutbox.markAsSending(event, relayList)
|
||||
|
||||
if (isActive) {
|
||||
relayPool.send(event, relayList)
|
||||
eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync)
|
||||
|
||||
// wakes up all the other relays
|
||||
reconnect(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close(subscriptionId: String) {
|
||||
activeRequests.remove(subscriptionId)
|
||||
activeCounts.remove(subscriptionId)
|
||||
relayPool.close(subscriptionId)
|
||||
override fun close(subId: String) {
|
||||
val relaysToUpdateReqs = activeRequests.remove(subId)
|
||||
val relaysToUpdateCounts = activeCounts.remove(subId)
|
||||
|
||||
if (isActive) {
|
||||
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
|
||||
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override fun renewFilters(relay: IRelayClient) {
|
||||
if (isActive) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
activeRequests.syncState(relay.url, relay::sendOrConnectAndSync)
|
||||
activeCounts.syncState(relay.url, relay::sendOrConnectAndSync)
|
||||
eventOutbox.syncState(relay.url, relay::sendOrConnectAndSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* when a new connection starts, resets the state
|
||||
*/
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
activeRequests.onConnecting(relay.url)
|
||||
listeners.forEach { it.onConnecting(relay) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay just connected. Use this to send all
|
||||
* filters and events you need.
|
||||
*/
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
listeners.forEach { it.onEvent(relay, subId, event, arrivalTime, afterEOSE) }
|
||||
renewFilters(relay)
|
||||
listeners.forEach { it.onConnected(relay, pingMillis, compressed) }
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
) {
|
||||
listeners.forEach { it.onEOSE(relay, subId, arrivalTime) }
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
message: String,
|
||||
) {
|
||||
listeners.forEach { it.onClosed(relay, subId, message) }
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) {
|
||||
listeners.forEach { it.onRelayStateChange(relay, type) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onBeforeSend(
|
||||
relay: IRelayClient,
|
||||
event: Event,
|
||||
) {
|
||||
eventOutbox.newTry(event.id, relay.url)
|
||||
listeners.forEach { it.onBeforeSend(relay, event) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onSendResponse(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
eventOutbox.newResponse(eventId, relay.url, success, message)
|
||||
listeners.forEach { it.onSendResponse(relay, eventId, success, message) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onAuth(
|
||||
relay: IRelayClient,
|
||||
challenge: String,
|
||||
) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onNotify(
|
||||
relay: IRelayClient,
|
||||
description: String,
|
||||
) {
|
||||
listeners.forEach { it.onNotify(relay, description) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onSend(
|
||||
relay: IRelayClient,
|
||||
msg: String,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
listeners.forEach { it.onSend(relay, msg, success) }
|
||||
if (success) {
|
||||
activeRequests.onSent(relay.url, cmd)
|
||||
activeCounts.onSent(relay.url, cmd)
|
||||
eventOutbox.onSent(relay.url, cmd)
|
||||
}
|
||||
listeners.forEach { it.onSent(relay, cmdStr, cmd, success) }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onError(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
error: Error,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
listeners.forEach { it.onError(relay, subId, error) }
|
||||
activeRequests.onIncomingMessage(relay, msg)
|
||||
activeCounts.onIncomingMessage(relay, msg)
|
||||
eventOutbox.onIncomingMessage(relay.url, msg)
|
||||
|
||||
listeners.forEach { it.onIncomingMessage(relay, msgStr, msg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay just diconnected.
|
||||
*/
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
activeRequests.onDisconnected(relay.url)
|
||||
listeners.forEach { it.onDisconnected(relay) }
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
activeRequests.onCannotConnect(relay.url, errorMessage)
|
||||
activeCounts.onCannotConnect(relay.url, errorMessage)
|
||||
eventOutbox.onCannotConnect(relay.url, errorMessage)
|
||||
|
||||
listeners.forEach { it.onCannotConnect(relay, errorMessage) }
|
||||
}
|
||||
|
||||
override fun subscribe(listener: IRelayClientListener) {
|
||||
|
||||
+8
-13
@@ -21,8 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
@@ -31,35 +30,31 @@ class NostrClientSubscription(
|
||||
val client: INostrClient,
|
||||
val filter: () -> Map<NormalizedRelayUrl, List<Filter>> = { emptyMap() },
|
||||
val onEvent: (event: Event) -> Unit = {},
|
||||
) : IRelayClientListener {
|
||||
) : IRequestListener {
|
||||
private val subId = RandomInstance.randomChars(10)
|
||||
|
||||
override fun onEvent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (this.subId == subId) {
|
||||
onEvent(event)
|
||||
}
|
||||
onEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or Updates the filter with relays. This method should be called
|
||||
* everytime the filter changes.
|
||||
*/
|
||||
fun updateFilter() = client.openReqSubscription(subId, filter())
|
||||
fun updateFilter() = client.openReqSubscription(subId, filter(), this)
|
||||
|
||||
fun closeSubscription() = client.close(subId)
|
||||
|
||||
fun destroy() {
|
||||
client.unsubscribe(this)
|
||||
closeSubscription()
|
||||
}
|
||||
|
||||
init {
|
||||
client.subscribe(this)
|
||||
updateFilter()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -35,14 +37,14 @@ class EventCollector(
|
||||
) {
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onEvent(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
onEvent(event, relay)
|
||||
if (msg is EventMessage) {
|
||||
onEvent(msg.event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-18
@@ -23,8 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -53,36 +54,37 @@ suspend fun INostrClient.sendAndWaitForResponse(
|
||||
|
||||
val subscription =
|
||||
object : IRelayClientListener {
|
||||
override fun onError(
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
error: Error,
|
||||
errorMessage: String,
|
||||
) {
|
||||
if (relay.url in relayList) {
|
||||
resultChannel.trySend(Result(relay.url, false))
|
||||
Log.d("sendAndWaitForResponse", "onError Error from relay ${relay.url} error: $error")
|
||||
Log.d("sendAndWaitForResponse", "Error from relay ${relay.url}: $errorMessage")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) {
|
||||
if (type == RelayState.DISCONNECTED && relay.url in relayList) {
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
if (relay.url in relayList) {
|
||||
resultChannel.trySend(Result(relay.url, false))
|
||||
Log.d("sendAndWaitForResponse", "onRelayStateChange ${type.name} from relay ${relay.url}")
|
||||
Log.d("sendAndWaitForResponse", "Disconnected from relay ${relay.url}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
if (eventId == event.id) {
|
||||
resultChannel.trySend(Result(relay.url, success))
|
||||
Log.d("sendAndWaitForResponse", "onSendResponse Received response for $eventId from relay ${relay.url} message $message success $success")
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
|
||||
when (msg) {
|
||||
is OkMessage -> {
|
||||
if (msg.eventId == event.id) {
|
||||
resultChannel.trySend(Result(relay.url, msg.success))
|
||||
Log.d("sendAndWaitForResponse", "onSendResponse Received response for ${msg.eventId} from relay ${relay.url} message ${msg.message} success ${msg.success}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-14
@@ -22,8 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -71,23 +70,18 @@ suspend fun INostrClient.downloadFirstEvent(
|
||||
val resultChannel = Channel<Event>(UNLIMITED)
|
||||
|
||||
val listener =
|
||||
object : IRelayClientListener {
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (subId == subscriptionId) {
|
||||
resultChannel.trySend(event)
|
||||
}
|
||||
resultChannel.trySend(event)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener)
|
||||
|
||||
openReqSubscription(subscriptionId, filters)
|
||||
openReqSubscription(subscriptionId, filters, listener)
|
||||
|
||||
val result =
|
||||
withTimeoutOrNull(30000) {
|
||||
@@ -95,7 +89,7 @@ suspend fun INostrClient.downloadFirstEvent(
|
||||
}
|
||||
|
||||
close(subscriptionId)
|
||||
unsubscribe(listener)
|
||||
|
||||
resultChannel.close()
|
||||
|
||||
return result
|
||||
|
||||
+7
-6
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -35,14 +37,13 @@ class RelayInsertConfirmationCollector(
|
||||
) {
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onSendResponse(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
if (success) {
|
||||
onRelayReceived(eventId, relay)
|
||||
if (msg is OkMessage && msg.success) {
|
||||
onRelayReceived(msg.eventId, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-12
@@ -20,10 +20,20 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -31,27 +41,68 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
*/
|
||||
class RelayLogger(
|
||||
val client: INostrClient,
|
||||
val notify: (message: String, relay: IRelayClient) -> Unit,
|
||||
val debugSending: Boolean = false,
|
||||
val debugReceiving: Boolean = false,
|
||||
) {
|
||||
fun logTag(url: NormalizedRelayUrl) = "Relay ${url.displayUrl()}"
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
/** A new message was received */
|
||||
override fun onEvent(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
Log.d("Relay", "Relay onEVENT ${relay.url} ($subId - $afterEOSE) ${event.toJson()}")
|
||||
val logTag = logTag(relay.url)
|
||||
|
||||
when (msg) {
|
||||
is EventMessage -> if (debugReceiving) Log.d(logTag, "Received: $msgStr")
|
||||
is EoseMessage -> if (debugReceiving) Log.d(logTag, "EOSE: ${msg.subId}")
|
||||
is NoticeMessage -> Log.w(logTag, "Notice: ${msg.message}")
|
||||
is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
|
||||
is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}")
|
||||
is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}")
|
||||
is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSend(
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
msg: String,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
Log.d("Relay", "Relay send ${relay.url} (${msg.length} chars) $msg")
|
||||
if (success) {
|
||||
if (debugSending) {
|
||||
Log.d(logTag(relay.url), "Sent (${cmdStr.length} chars): $cmdStr")
|
||||
}
|
||||
} else {
|
||||
Log.e(logTag(relay.url), "Failure sending (${cmdStr.length} chars): $cmdStr")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
Log.d(logTag(relay.url), "Connecting...")
|
||||
}
|
||||
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
Log.d(logTag(relay.url), "OnOpen (ping: ${pingMillis}ms${if (compressed) ", using compression" else ""})")
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
Log.d(logTag(relay.url), "Disconnected")
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
super.onCannotConnect(relay, errorMessage)
|
||||
Log.e(logTag(relay.url), errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -33,16 +35,20 @@ class RelayNotifier(
|
||||
val notify: (message: String, relay: IRelayClient) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
val TAG = "RelayNotifier"
|
||||
const val TAG = "RelayNotifier"
|
||||
}
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onNotify(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
message: String,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
notify(message, relay)
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
if (msg is NotifyMessage) {
|
||||
notify(msg.message, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
|
||||
class RelayAuthStatus {
|
||||
// Keeps track of auth responses to update the relay with all filters
|
||||
// after the authentication happen
|
||||
private val authResponseWatcher: MutableMap<HexKey, AuthEventReceiptStatus> = mutableMapOf()
|
||||
|
||||
// Avoids sending multiple replies for each auth.
|
||||
private val uniqueAuthChallengesSent: MutableSet<ChallengePair> = mutableSetOf()
|
||||
|
||||
enum class AuthEventReceiptStatus {
|
||||
AUTHENTICATING,
|
||||
AUTHENTICATED,
|
||||
NOT_AUTHENTICATED,
|
||||
}
|
||||
|
||||
data class ChallengePair(
|
||||
val user: HexKey,
|
||||
val challenge: String,
|
||||
)
|
||||
|
||||
fun saveAuthSubmission(authEvent: RelayAuthEvent): Boolean {
|
||||
val challenge = authEvent.challenge()
|
||||
if (challenge == null) return false
|
||||
|
||||
val challengePair = ChallengePair(authEvent.pubKey, challenge)
|
||||
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
return if (challengePair !in uniqueAuthChallengesSent) {
|
||||
authResponseWatcher[authEvent.id] = AuthEventReceiptStatus.AUTHENTICATING
|
||||
uniqueAuthChallengesSent.add(challengePair)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAuthResults(
|
||||
eventId: HexKey,
|
||||
success: Boolean,
|
||||
): Boolean =
|
||||
if (authResponseWatcher.containsKey(eventId)) {
|
||||
val wasAlreadyAuthenticated = authResponseWatcher[eventId]
|
||||
|
||||
if (success) {
|
||||
authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED)
|
||||
} else {
|
||||
authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED)
|
||||
}
|
||||
|
||||
wasAlreadyAuthenticated != AuthEventReceiptStatus.AUTHENTICATED && success
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
fun hasFinishedAllAuths() = authResponseWatcher.all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
|
||||
}
|
||||
+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.client.auth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface IAuthStatus {
|
||||
fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean
|
||||
}
|
||||
|
||||
object EmptyIAuthStatus : IAuthStatus {
|
||||
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = true
|
||||
}
|
||||
|
||||
class RelayAuthenticator(
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope,
|
||||
val signWithAllLoggedInUsers: suspend (EventTemplate<RelayAuthEvent>) -> List<RelayAuthEvent>,
|
||||
) : IAuthStatus {
|
||||
private val authStatus = mutableMapOf<NormalizedRelayUrl, RelayAuthStatus>()
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is AuthMessage -> authenticate(relay, msg)
|
||||
is OkMessage -> checkAuthResults(relay, msg)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
authStatus.put(relay.url, RelayAuthStatus())
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
authStatus.remove(relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticate(
|
||||
relay: IRelayClient,
|
||||
msg: AuthMessage,
|
||||
) {
|
||||
scope.launch {
|
||||
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
|
||||
signWithAllLoggedInUsers(ev).forEach { authEvent ->
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
if (authStatus[relay.url]?.saveAuthSubmission(authEvent) == true) {
|
||||
relay.sendIfConnected(AuthCmd(authEvent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAuthResults(
|
||||
relay: IRelayClient,
|
||||
msg: OkMessage,
|
||||
) {
|
||||
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
|
||||
if (authStatus[relay.url]?.checkAuthResults(msg.eventId, msg.success) == true) {
|
||||
client.renewFilters(relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasFinishedAuthentication(relay: NormalizedRelayUrl) = authStatus[relay]?.hasFinishedAllAuths() != false
|
||||
|
||||
init {
|
||||
Log.d("RelayAuthenticator", "Init, Subscribe")
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
Log.d("RelayAuthenticator", "Destroy, Unsubscribe")
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.counts
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
class CountQueryState<T> {
|
||||
// Logs the state of each channel to:
|
||||
// 1. inform when an event is received as live
|
||||
// 2. to block COUNTs being sent before finished (receiving an COUNT or Closed)
|
||||
//
|
||||
// If 2 happens, the relay might send multiple COUNTs in sequence
|
||||
// for the same sub and we won't know which COUNT was it for.
|
||||
private val queryStates = mutableMapOf<T, CountQueryStatus>()
|
||||
private val filterStates = mutableMapOf<T, List<Filter>>()
|
||||
|
||||
fun currentFilters() = filterStates
|
||||
|
||||
fun currentFilters(reference: T) = filterStates[reference]
|
||||
|
||||
fun currentState(reference: T) = queryStates[reference]
|
||||
|
||||
fun onCountReply(reference: T) {
|
||||
queryStates.put(reference, CountQueryStatus.RECEIVED)
|
||||
}
|
||||
|
||||
fun onClosed(reference: T) {
|
||||
queryStates.put(reference, CountQueryStatus.CLOSED)
|
||||
}
|
||||
|
||||
fun onQuery(
|
||||
reference: T,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
queryStates.put(reference, CountQueryStatus.SENT)
|
||||
filterStates.put(reference, filters)
|
||||
}
|
||||
|
||||
fun onCloseQuery(reference: T) {
|
||||
queryStates.put(reference, CountQueryStatus.CLOSED)
|
||||
}
|
||||
|
||||
fun connecting(reference: T) {
|
||||
queryStates.remove(reference)
|
||||
filterStates.remove(reference)
|
||||
}
|
||||
|
||||
fun disconnected(reference: T) {
|
||||
queryStates.remove(reference)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.counts
|
||||
|
||||
enum class CountQueryStatus {
|
||||
SENT,
|
||||
RECEIVED,
|
||||
CLOSED,
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.counts
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
class RelayActiveCountStates(
|
||||
val client: INostrClient,
|
||||
) {
|
||||
private var queryStates = mutableMapOf<NormalizedRelayUrl, CountQueryState<String>>()
|
||||
|
||||
fun subGetOrCreate(relay: NormalizedRelayUrl): CountQueryState<String> = queryStates[relay] ?: CountQueryState<String>().also { queryStates.put(relay, it) }
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
queryStates.put(relay.url, CountQueryState())
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is CountMessage -> subGetOrCreate(relay.url).onCountReply(msg.queryId)
|
||||
is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
when (cmd) {
|
||||
is ReqCmd -> subGetOrCreate(relay.url).onQuery(cmd.subId, cmd.filters)
|
||||
is CloseCmd -> subGetOrCreate(relay.url).onCloseQuery(cmd.subId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
queryStates.remove(relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("RelaySubStateMachine", "Init, Subscribe")
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
Log.d("RelaySubStateMachine", "Destroy, Unsubscribe")
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
}
|
||||
+24
-87
@@ -20,118 +20,55 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.listeners
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
|
||||
enum class RelayState {
|
||||
// Websocket connected
|
||||
CONNECTED,
|
||||
|
||||
// Websocket disconnecting
|
||||
DISCONNECTING,
|
||||
|
||||
// Websocket disconnected
|
||||
DISCONNECTED,
|
||||
}
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
|
||||
interface IRelayClientListener {
|
||||
fun onConnecting(relay: IRelayClient) {}
|
||||
|
||||
/**
|
||||
* New Event arrives from the relay.
|
||||
* Relay just connected. Use this to send all
|
||||
* filters and events you need.
|
||||
*/
|
||||
fun onEvent(
|
||||
fun onConnected(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* New EOSE command arrives for a subscription
|
||||
* Triggers after the event has been sent.
|
||||
* Success means that the event was successfully sent, not
|
||||
* that it received receipt confirmation from the relay
|
||||
*/
|
||||
fun onEOSE(
|
||||
fun onSent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* New error
|
||||
*/
|
||||
fun onError(
|
||||
fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
error: Error,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Relay is requesting authentication with the given challenge.
|
||||
* Relay just diconnected.
|
||||
*/
|
||||
fun onAuth(
|
||||
relay: IRelayClient,
|
||||
challenge: String,
|
||||
) {}
|
||||
fun onDisconnected(relay: IRelayClient) {}
|
||||
|
||||
/**
|
||||
* called after the relay receives the OK from an Auth message
|
||||
* The url is invalid or the server is unreachable.
|
||||
*/
|
||||
fun onAuthed(
|
||||
fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* RelayState changes
|
||||
*/
|
||||
fun onRelayStateChange(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* NOTIFY command has arrived.
|
||||
*/
|
||||
fun onNotify(
|
||||
relay: IRelayClient,
|
||||
description: String,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Relay closed the subscription
|
||||
*/
|
||||
fun onClosed(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
message: String,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Triggers this before sending the event.
|
||||
*/
|
||||
fun onBeforeSend(
|
||||
relay: IRelayClient,
|
||||
event: Event,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Triggers after the event has been sent.
|
||||
*/
|
||||
fun onSend(
|
||||
relay: IRelayClient,
|
||||
msg: String,
|
||||
success: Boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Relay accepted or rejected the event
|
||||
*/
|
||||
fun onSendResponse(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
errorMessage: String,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
+31
-56
@@ -20,75 +20,50 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.listeners
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
|
||||
open class RedirectRelayClientListener(
|
||||
val listener: IRelayClientListener,
|
||||
) : IRelayClientListener {
|
||||
override fun onEvent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
) = listener.onEvent(relay, subId, event, arrivalTime, afterEOSE)
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
listener.onConnecting(relay)
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
) = listener.onEOSE(relay, subId, arrivalTime)
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
listener.onConnected(relay, pingMillis, compressed)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
error: Error,
|
||||
) = listener.onError(relay, subId, error)
|
||||
|
||||
override fun onAuth(
|
||||
relay: IRelayClient,
|
||||
challenge: String,
|
||||
) = listener.onAuth(relay, challenge)
|
||||
|
||||
override fun onAuthed(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) = listener.onAuthed(relay, eventId, success, message)
|
||||
) {
|
||||
listener.onSent(relay, cmdStr, cmd, success)
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) = listener.onRelayStateChange(relay, type)
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
listener.onIncomingMessage(relay, msgStr, msg)
|
||||
}
|
||||
|
||||
override fun onNotify(
|
||||
relay: IRelayClient,
|
||||
description: String,
|
||||
) = listener.onNotify(relay, description)
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
listener.onDisconnected(relay)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
message: String,
|
||||
) = listener.onClosed(relay, subId, message)
|
||||
|
||||
override fun onBeforeSend(
|
||||
relay: IRelayClient,
|
||||
event: Event,
|
||||
) = listener.onBeforeSend(relay, event)
|
||||
|
||||
override fun onSend(
|
||||
relay: IRelayClient,
|
||||
msg: String,
|
||||
success: Boolean,
|
||||
) = listener.onSend(relay, msg, success)
|
||||
|
||||
override fun onSendResponse(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) = listener.onSendResponse(relay, eventId, success, message)
|
||||
errorMessage: String,
|
||||
) {
|
||||
listener.onCannotConnect(relay, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
+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.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
object FiltersChanged {
|
||||
fun needsToResendRequest(
|
||||
oldFilters: List<Filter>,
|
||||
newFilters: List<Filter>,
|
||||
): Boolean {
|
||||
if (oldFilters.size != newFilters.size) return true
|
||||
|
||||
oldFilters.forEachIndexed { index, oldFilter ->
|
||||
val newFilter = newFilters.getOrNull(index) ?: return true
|
||||
|
||||
return needsToResendRequest(oldFilter, newFilter)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the filter has changed, with a special case for when the since changes due to new
|
||||
* EOSE times.
|
||||
*/
|
||||
fun needsToResendRequest(
|
||||
oldFilter: Filter,
|
||||
newFilter: Filter,
|
||||
): Boolean {
|
||||
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
|
||||
// fast check
|
||||
if (oldFilter.authors?.size != newFilter.authors?.size ||
|
||||
oldFilter.ids?.size != newFilter.ids?.size ||
|
||||
oldFilter.tags?.size != newFilter.tags?.size ||
|
||||
oldFilter.kinds?.size != newFilter.kinds?.size ||
|
||||
oldFilter.limit != newFilter.limit ||
|
||||
oldFilter.search?.length != newFilter.search?.length ||
|
||||
oldFilter.until != newFilter.until
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// deep check
|
||||
if (oldFilter.ids != newFilter.ids ||
|
||||
oldFilter.authors != newFilter.authors ||
|
||||
oldFilter.tags != newFilter.tags ||
|
||||
oldFilter.kinds != newFilter.kinds ||
|
||||
oldFilter.search != newFilter.search
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (oldFilter.since != null) {
|
||||
if (newFilter.since == null) {
|
||||
// went was checking the future only and now wants everything
|
||||
return true
|
||||
} else if (oldFilter.since > newFilter.since) {
|
||||
// went backwards in time, forces update
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.counts.CountQueryStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.collections.plus
|
||||
|
||||
class PoolCounts {
|
||||
private var queries = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
private val relayState = LargeCache<String, CountQueryState<NormalizedRelayUrl>>()
|
||||
|
||||
fun subState(subId: String): CountQueryState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { CountQueryState() }
|
||||
|
||||
private fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
queries.forEach { queryId, perRelayFilters ->
|
||||
myRelays.addAll(perRelayFilters.keys)
|
||||
}
|
||||
|
||||
if (relays.value != myRelays) {
|
||||
relays.tryEmit(myRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
|
||||
val myRelays = mutableMapOf<String, List<Filter>>()
|
||||
queries.forEach { sub, perRelayFilters ->
|
||||
val filters = perRelayFilters.get(url)
|
||||
if (filters != null) {
|
||||
myRelays.put(sub, filters)
|
||||
}
|
||||
}
|
||||
return myRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new query to the pool, and returns the relays that need to be updated.
|
||||
*/
|
||||
fun addOrUpdate(
|
||||
subscriptionId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val oldRelays = queries.get(subscriptionId)?.keys ?: emptySet()
|
||||
queries.put(subscriptionId, filters)
|
||||
updateRelays()
|
||||
return oldRelays + filters.keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the query from the pool, and returns the relays that need to be updated.
|
||||
*/
|
||||
fun remove(queryId: String): Set<NormalizedRelayUrl> =
|
||||
if (queries.containsKey(queryId)) {
|
||||
val oldRelays = queries.get(queryId)?.keys ?: emptySet()
|
||||
queries.remove(queryId)
|
||||
updateRelays()
|
||||
oldRelays
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
fun getSubscriptionFiltersOrNull(queryId: String): Map<NormalizedRelayUrl, List<Filter>>? = queries.get(queryId)
|
||||
|
||||
// --------------------------
|
||||
// State management functions
|
||||
// --------------------------
|
||||
fun onConnecting(url: NormalizedRelayUrl) {
|
||||
relayState.forEach { subId, state ->
|
||||
state.connecting(url)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncState(
|
||||
relay: NormalizedRelayUrl,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
queries.forEach { subId, filters ->
|
||||
val filters = filters[relay]
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
sync(CountCmd(subId, filters))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSent(
|
||||
relay: NormalizedRelayUrl,
|
||||
cmd: Command,
|
||||
) {
|
||||
when (cmd) {
|
||||
is CountCmd -> subState(cmd.queryId).onQuery(relay, cmd.filters)
|
||||
is CloseCmd -> subState(cmd.subId).onCloseQuery(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is CountMessage -> {
|
||||
subState(msg.queryId).onCountReply(relay.url)
|
||||
sendToRelayIfChanged(msg.queryId, relay.url) { cmd ->
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
is ClosedMessage -> {
|
||||
subState(msg.subId).onClosed(relay.url)
|
||||
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDisconnected(url: NormalizedRelayUrl) {
|
||||
relayState.forEach { subId, state ->
|
||||
state.disconnected(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
queryId: String,
|
||||
relaysToUpdate: Set<NormalizedRelayUrl>,
|
||||
sync: (NormalizedRelayUrl, Command) -> Unit,
|
||||
) {
|
||||
relaysToUpdate.forEach { relay ->
|
||||
val currentState = relayState.get(queryId)?.currentState(relay)
|
||||
|
||||
if (currentState == CountQueryStatus.SENT) {
|
||||
// sending multiple REQs triggers multiple EOSEs back and we then don't know which
|
||||
// one is which.
|
||||
} else {
|
||||
sendToRelayIfChanged(queryId, relay) { cmd ->
|
||||
sync(relay, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
queryId: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
val oldFilters = relayState.get(queryId)?.currentFilters(relay)
|
||||
val newFilters = queries.get(queryId)?.get(relay)
|
||||
sendToRelayIfChanged(queryId, oldFilters, newFilters, sync)
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
queryId: String,
|
||||
oldFilters: List<Filter>?,
|
||||
newFilters: List<Filter>?,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
if (newFilters.isNullOrEmpty()) {
|
||||
// some relays are not in this sub anymore. Stop their subscriptions
|
||||
if (!oldFilters.isNullOrEmpty()) {
|
||||
// only update if the old filters are not already closed.
|
||||
sync(CloseCmd(queryId))
|
||||
}
|
||||
} else if (oldFilters.isNullOrEmpty()) {
|
||||
// new relays were added. Start a new sub in them
|
||||
sync(CountCmd(queryId, newFilters))
|
||||
} else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) {
|
||||
// filters were changed enough (not only an update in since) to warn a new update
|
||||
sync(CountCmd(queryId, newFilters))
|
||||
} else {
|
||||
// They are the same don't do anything.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If cannot connect, closes subs
|
||||
*/
|
||||
fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
errorMessage: String,
|
||||
) {
|
||||
// mark as impossible to get count from this relay
|
||||
}
|
||||
}
|
||||
+110
-49
@@ -21,70 +21,131 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class PoolEventOutbox(
|
||||
val event: Event,
|
||||
var relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
private val tries = mutableMapOf<NormalizedRelayUrl, Tries>()
|
||||
class PoolEventOutbox {
|
||||
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
|
||||
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
|
||||
relays = newRelays
|
||||
}
|
||||
fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
eventOutbox.values.forEach {
|
||||
myRelays.addAll(it.relaysLeft())
|
||||
}
|
||||
|
||||
fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false
|
||||
|
||||
fun isDone() = relays.all { isDone(it) }
|
||||
|
||||
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) }
|
||||
|
||||
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
|
||||
|
||||
fun forEachUnsentEvent(
|
||||
url: NormalizedRelayUrl,
|
||||
run: (url: Event) -> Unit,
|
||||
) = if (isSupposedToGo(url)) run(event) else null
|
||||
|
||||
fun newTry(url: NormalizedRelayUrl) {
|
||||
val currentTries = tries[url]
|
||||
if (currentTries != null) {
|
||||
currentTries.tries.add(TimeUtils.now())
|
||||
} else {
|
||||
tries.put(url, Tries(mutableListOf(TimeUtils.now())))
|
||||
if (relays.value != myRelays) {
|
||||
relays.tryEmit(myRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
|
||||
val myEvents = mutableSetOf<HexKey>()
|
||||
eventOutbox.forEach { (eventId, outboxCache) ->
|
||||
if (url in outboxCache.relays) {
|
||||
myEvents.add(eventId)
|
||||
}
|
||||
}
|
||||
return myEvents
|
||||
}
|
||||
|
||||
fun markAsSending(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val currentOutbox = eventOutbox[event.id]
|
||||
if (currentOutbox == null) {
|
||||
eventOutbox = eventOutbox + Pair(event.id, PoolEventOutboxState(event, relays))
|
||||
} else {
|
||||
currentOutbox.updateRelays(relays)
|
||||
}
|
||||
updateRelays()
|
||||
return eventOutbox[event.id]?.remainingRelays() ?: emptySet()
|
||||
}
|
||||
|
||||
fun newTry(
|
||||
id: HexKey,
|
||||
url: NormalizedRelayUrl,
|
||||
) {
|
||||
eventOutbox[id]?.newTry(url)
|
||||
}
|
||||
|
||||
fun newResponse(
|
||||
id: HexKey,
|
||||
url: NormalizedRelayUrl,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
val currentTries = tries[url]
|
||||
if (currentTries != null) {
|
||||
currentTries.responses.add(Response(success, message))
|
||||
} else {
|
||||
tries.put(
|
||||
url,
|
||||
Tries(
|
||||
mutableListOf(TimeUtils.now() - 1),
|
||||
mutableListOf(Response(success, message)),
|
||||
),
|
||||
)
|
||||
val waiting = eventOutbox[id]
|
||||
if (waiting != null) {
|
||||
waiting.newResponse(url, success, message)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Tries 3 times
|
||||
class Tries(
|
||||
val tries: MutableList<Long> = mutableListOf(),
|
||||
val responses: MutableList<Response> = mutableListOf(),
|
||||
) {
|
||||
fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3
|
||||
fun clear() {
|
||||
eventOutbox = eventOutbox.filter { !it.value.isDone() }
|
||||
updateRelays()
|
||||
}
|
||||
|
||||
class Response(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
)
|
||||
// --------------------------
|
||||
// State management functions
|
||||
// --------------------------
|
||||
suspend fun syncState(
|
||||
relay: NormalizedRelayUrl,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
eventOutbox.forEach {
|
||||
it.value.forEachUnsentEvent(relay) {
|
||||
sync(EventCmd(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onIncomingMessage(
|
||||
relay: NormalizedRelayUrl,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is OkMessage -> newResponse(msg.eventId, relay, msg.success, msg.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSent(
|
||||
relay: NormalizedRelayUrl,
|
||||
cmd: Command,
|
||||
) {
|
||||
if (cmd is EventCmd) {
|
||||
newTry(cmd.event.id, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
event: Event,
|
||||
relaysToUpdate: Set<NormalizedRelayUrl>,
|
||||
sync: (NormalizedRelayUrl, Command) -> Unit,
|
||||
) {
|
||||
relaysToUpdate.forEach { relay ->
|
||||
sync(relay, EventCmd(event))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If cannot connect, closes subs
|
||||
*/
|
||||
fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
errorMessage: String,
|
||||
) {
|
||||
eventOutbox.forEach {
|
||||
if (relay in it.value.relays) {
|
||||
newResponse(it.key, relay, false, errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class PoolEventOutboxRepository {
|
||||
private var eventOutbox = mapOf<HexKey, PoolEventOutbox>()
|
||||
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
eventOutbox.values.forEach {
|
||||
myRelays.addAll(it.relaysLeft())
|
||||
}
|
||||
|
||||
if (relays.value != myRelays) {
|
||||
relays.tryEmit(myRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
|
||||
val myEvents = mutableSetOf<HexKey>()
|
||||
eventOutbox.forEach { (eventId, outboxCache) ->
|
||||
if (url in outboxCache.relays) {
|
||||
myEvents.add(eventId)
|
||||
}
|
||||
}
|
||||
return myEvents
|
||||
}
|
||||
|
||||
fun markAsSending(
|
||||
event: Event,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val currentOutbox = eventOutbox[event.id]
|
||||
if (currentOutbox == null) {
|
||||
eventOutbox = eventOutbox + Pair(event.id, PoolEventOutbox(event, relays))
|
||||
} else {
|
||||
currentOutbox.updateRelays(relays)
|
||||
}
|
||||
updateRelays()
|
||||
}
|
||||
|
||||
fun newTry(
|
||||
id: HexKey,
|
||||
url: NormalizedRelayUrl,
|
||||
) {
|
||||
eventOutbox[id]?.newTry(url)
|
||||
}
|
||||
|
||||
fun newResponse(
|
||||
id: HexKey,
|
||||
url: NormalizedRelayUrl,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
val waiting = eventOutbox[id]
|
||||
if (waiting != null) {
|
||||
waiting.newResponse(url, success, message)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
eventOutbox = eventOutbox.filter { !it.value.isDone() }
|
||||
updateRelays()
|
||||
}
|
||||
|
||||
fun forEachUnsentEvent(
|
||||
url: NormalizedRelayUrl,
|
||||
run: (url: Event) -> Unit,
|
||||
) {
|
||||
eventOutbox.forEach {
|
||||
it.value.forEachUnsentEvent(url, run)
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class PoolEventOutboxState(
|
||||
val event: Event,
|
||||
var relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
private val tries = mutableMapOf<NormalizedRelayUrl, Tries>()
|
||||
|
||||
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
|
||||
relays = newRelays
|
||||
}
|
||||
|
||||
fun isDone(url: NormalizedRelayUrl) = tries[url]?.let { it.isDone() } ?: false
|
||||
|
||||
fun isDone() = relays.all { isDone(it) }
|
||||
|
||||
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) }
|
||||
|
||||
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
|
||||
|
||||
fun forEachUnsentEvent(
|
||||
url: NormalizedRelayUrl,
|
||||
run: (url: Event) -> Unit,
|
||||
) = if (isSupposedToGo(url)) run(event) else null
|
||||
|
||||
fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo)
|
||||
|
||||
fun newTry(url: NormalizedRelayUrl) {
|
||||
val currentTries = tries[url]
|
||||
if (currentTries != null) {
|
||||
currentTries.tries.add(TimeUtils.now())
|
||||
} else {
|
||||
tries.put(url, Tries(mutableListOf(TimeUtils.now())))
|
||||
}
|
||||
}
|
||||
|
||||
fun newResponse(
|
||||
url: NormalizedRelayUrl,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
val currentTries = tries[url]
|
||||
if (currentTries != null) {
|
||||
currentTries.responses.add(Response(success, message))
|
||||
} else {
|
||||
tries.put(
|
||||
url,
|
||||
Tries(
|
||||
mutableListOf(TimeUtils.now() - 1),
|
||||
mutableListOf(Response(success, message)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tries 3 times
|
||||
class Tries(
|
||||
val tries: MutableList<Long> = mutableListOf(),
|
||||
val responses: MutableList<Response> = mutableListOf(),
|
||||
) {
|
||||
fun isDone() = responses.any { it.success == true } || responses.size > 2 || tries.size > 3
|
||||
}
|
||||
|
||||
class Response(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
)
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.ReqSubStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.RequestSubscriptionState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class PoolRequests {
|
||||
/**
|
||||
* Desired subs and listeners are changed immediately when the local code requests
|
||||
*/
|
||||
private val desiredSubs = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
private val desiredSubListeners = LargeCache<String, IRequestListener>()
|
||||
val desiredRelays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
/**
|
||||
* relay states are kept and only removed after everything is processed.
|
||||
*/
|
||||
private val relayState = LargeCache<String, RequestSubscriptionState<NormalizedRelayUrl>>()
|
||||
|
||||
fun subState(subId: String): RequestSubscriptionState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { RequestSubscriptionState() }
|
||||
|
||||
private fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
desiredSubs.forEach { sub, perRelayFilters ->
|
||||
myRelays.addAll(perRelayFilters.keys)
|
||||
}
|
||||
|
||||
if (desiredRelays.value != myRelays) {
|
||||
desiredRelays.tryEmit(myRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
|
||||
val myRelays = mutableMapOf<String, List<Filter>>()
|
||||
desiredSubs.forEach { sub, perRelayFilters ->
|
||||
val filters = perRelayFilters[url]
|
||||
if (filters != null) {
|
||||
myRelays.put(sub, filters)
|
||||
}
|
||||
}
|
||||
return myRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new filter to the pool, and returns the relays that need to be updated.
|
||||
*/
|
||||
fun addOrUpdate(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
// saves old relays
|
||||
val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet()
|
||||
// update filters
|
||||
desiredSubs.put(subId, filters)
|
||||
// update listener
|
||||
if (listener != null) {
|
||||
desiredSubListeners.put(subId, listener)
|
||||
}
|
||||
// create sub state
|
||||
subState(subId)
|
||||
// update relays for pool
|
||||
updateRelays()
|
||||
// return all affected relays
|
||||
return oldRelays + filters.keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the sub from the pool, and returns the relays that need to be updated.
|
||||
*/
|
||||
fun remove(subId: String): Set<NormalizedRelayUrl> =
|
||||
if (desiredSubs.containsKey(subId)) {
|
||||
// saves old relays
|
||||
val oldRelays = desiredSubs.get(subId)?.keys ?: emptySet()
|
||||
// update filters
|
||||
desiredSubs.remove(subId)
|
||||
// update listener
|
||||
desiredSubListeners.remove(subId)
|
||||
// remove states
|
||||
relayState.remove(subId)
|
||||
// update relays for pool
|
||||
updateRelays()
|
||||
// return all affected relays
|
||||
oldRelays
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
fun getSubscriptionFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = desiredSubs.get(subId)
|
||||
|
||||
// --------------------------
|
||||
// State management functions
|
||||
// --------------------------
|
||||
fun onConnecting(url: NormalizedRelayUrl) {
|
||||
relayState.forEach { subId, state ->
|
||||
state.connecting(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun syncState(
|
||||
relay: NormalizedRelayUrl,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
desiredSubs.forEach { subId, filters ->
|
||||
val filters = filters[relay]
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
sync(ReqCmd(subId, filters))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSent(
|
||||
relay: NormalizedRelayUrl,
|
||||
cmd: Command,
|
||||
) {
|
||||
when (cmd) {
|
||||
is ReqCmd -> subState(cmd.subId).onOpenReq(relay, cmd.filters)
|
||||
is CloseCmd -> subState(cmd.subId).onCloseReq(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is EventMessage -> {
|
||||
val state = relayState.get(msg.subId)
|
||||
state?.onNewEvent(relay.url)
|
||||
desiredSubListeners.get(msg.subId)?.onEvent(
|
||||
event = msg.event,
|
||||
isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE,
|
||||
relay = relay.url,
|
||||
forFilters = state?.currentFilters(relay.url),
|
||||
)
|
||||
}
|
||||
is EoseMessage -> {
|
||||
val state = relayState.get(msg.subId)
|
||||
state?.onEose(relay.url)
|
||||
desiredSubListeners.get(msg.subId)?.onEose(
|
||||
relay = relay.url,
|
||||
forFilters = state?.currentFilters(relay.url),
|
||||
)
|
||||
|
||||
// send a newer version when done
|
||||
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
is ClosedMessage -> {
|
||||
val state = relayState.get(msg.subId)
|
||||
state?.onClosed(relay.url)
|
||||
|
||||
desiredSubListeners.get(msg.subId)?.onClosed(
|
||||
message = msg.message,
|
||||
relay = relay.url,
|
||||
forFilters = state?.currentFilters(relay.url),
|
||||
)
|
||||
|
||||
// send a newer version when done
|
||||
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
|
||||
// don't send a close if just closed
|
||||
if (cmd !is CloseCmd) {
|
||||
relay.sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDisconnected(url: NormalizedRelayUrl) {
|
||||
relayState.forEach { subId, state ->
|
||||
state.disconnected(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If cannot connect, closes subs
|
||||
*/
|
||||
fun onCannotConnect(
|
||||
url: NormalizedRelayUrl,
|
||||
errorMessage: String,
|
||||
) {
|
||||
relayState.forEach { subId, state ->
|
||||
desiredSubListeners.get(subId)?.onCannotConnect(
|
||||
message = errorMessage,
|
||||
relay = url,
|
||||
forFilters = state.currentFilters(url),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
subId: String,
|
||||
relaysToUpdate: Set<NormalizedRelayUrl>,
|
||||
sync: (NormalizedRelayUrl, Command) -> Unit,
|
||||
) {
|
||||
relaysToUpdate.forEach { relay ->
|
||||
val currentState = relayState.get(subId)?.currentState(relay)
|
||||
|
||||
if (currentState == ReqSubStatus.SENT || currentState == ReqSubStatus.QUERYING_PAST) {
|
||||
// sending multiple REQs triggers multiple EOSEs back and we then don't know which
|
||||
// one is which.
|
||||
} else {
|
||||
sendToRelayIfChanged(subId, relay) { cmd ->
|
||||
sync(relay, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
subId: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
val oldFilters = relayState.get(subId)?.currentFilters(relay)
|
||||
val newFilters = desiredSubs.get(subId)?.get(relay)
|
||||
sendToRelayIfChanged(subId, oldFilters, newFilters, sync)
|
||||
}
|
||||
|
||||
fun sendToRelayIfChanged(
|
||||
subId: String,
|
||||
oldFilters: List<Filter>?,
|
||||
newFilters: List<Filter>?,
|
||||
sync: (Command) -> Unit,
|
||||
) {
|
||||
if (newFilters.isNullOrEmpty()) {
|
||||
// some relays are not in this sub anymore. Stop their subscriptions
|
||||
if (!oldFilters.isNullOrEmpty()) {
|
||||
// only update if the old filters are not already closed.
|
||||
sync(CloseCmd(subId))
|
||||
}
|
||||
} else if (oldFilters.isNullOrEmpty()) {
|
||||
// new relays were added. Start a new sub in them
|
||||
sync(ReqCmd(subId, newFilters))
|
||||
} else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) {
|
||||
// filters were changed enough (not only an update in since) to warn a new update
|
||||
sync(ReqCmd(subId, newFilters))
|
||||
} else {
|
||||
// They are the same don't do anything.
|
||||
}
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class PoolSubscriptionRepository {
|
||||
private var subscriptions = LargeCache<String, Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
|
||||
|
||||
fun updateRelays() {
|
||||
val myRelays = mutableSetOf<NormalizedRelayUrl>()
|
||||
subscriptions.forEach { sub, perRelayFilters ->
|
||||
myRelays.addAll(perRelayFilters.keys)
|
||||
}
|
||||
|
||||
if (relays.value != myRelays) {
|
||||
relays.tryEmit(myRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeFiltersFor(url: NormalizedRelayUrl): Map<String, List<Filter>> {
|
||||
val myRelays = mutableMapOf<String, List<Filter>>()
|
||||
subscriptions.forEach { sub, perRelayFilters ->
|
||||
val filters = perRelayFilters.get(url)
|
||||
if (filters != null) {
|
||||
myRelays.put(sub, filters)
|
||||
}
|
||||
}
|
||||
return myRelays
|
||||
}
|
||||
|
||||
fun addOrUpdate(
|
||||
subscriptionId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
subscriptions.put(subscriptionId, filters)
|
||||
updateRelays()
|
||||
}
|
||||
|
||||
fun remove(subscriptionId: String) {
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
subscriptions.remove(subscriptionId)
|
||||
updateRelays()
|
||||
}
|
||||
}
|
||||
|
||||
fun forEachSub(
|
||||
relay: NormalizedRelayUrl,
|
||||
run: (String, List<Filter>) -> Unit,
|
||||
) {
|
||||
subscriptions.forEach { subId, filters ->
|
||||
val filters = filters[relay]
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
run(subId, filters)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptionFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = subscriptions.get(subId)
|
||||
}
|
||||
+54
-117
@@ -21,22 +21,19 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = {
|
||||
throw UnsupportedOperationException("Cannot create new relays")
|
||||
}
|
||||
|
||||
/**
|
||||
* RelayPool manages a collection of Nostr relays, abstracting individual connections and providing
|
||||
* unified methods for sending events, managing subscriptions, and tracking relay states.
|
||||
@@ -59,8 +56,8 @@ val UnsupportedRelayCreation: (url: NormalizedRelayUrl) -> IRelayClient = {
|
||||
* - Maintaining optimal relay connections (updatePool/addRelay/removeRelay methods)
|
||||
*/
|
||||
class RelayPool(
|
||||
val websocketBuilder: WebsocketBuilder,
|
||||
val listener: IRelayClientListener = EmptyClientListener,
|
||||
val createNewRelay: (url: NormalizedRelayUrl) -> IRelayClient = UnsupportedRelayCreation,
|
||||
) : IRelayClientListener {
|
||||
private val relays = LargeCache<NormalizedRelayUrl, IRelayClient>()
|
||||
|
||||
@@ -70,6 +67,13 @@ class RelayPool(
|
||||
|
||||
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url)
|
||||
|
||||
private fun createNewRelay(url: NormalizedRelayUrl) =
|
||||
BasicRelayClient(
|
||||
url = url,
|
||||
socketBuilder = websocketBuilder,
|
||||
listener = this,
|
||||
)
|
||||
|
||||
fun reconnectIfNeedsTo(ignoreRetryDelays: Boolean = false) {
|
||||
relays.forEach { url, relay ->
|
||||
if (relay.isConnected()) {
|
||||
@@ -109,71 +113,40 @@ class RelayPool(
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
fun sendRequest(
|
||||
fun sendOrConnectAndSync(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
getOrCreateRelay(relay).sendRequest(subId, filters)
|
||||
}
|
||||
cmd: Command,
|
||||
) = getOrCreateRelay(relay).sendOrConnectAndSync(cmd)
|
||||
|
||||
fun sendRequest(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
relays.forEach { url, relay ->
|
||||
val filters = filters[relay.url]
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
relay.sendRequest(subId, filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendCount(
|
||||
fun sendIfConnected(
|
||||
relay: NormalizedRelayUrl,
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
getOrCreateRelay(relay).sendCount(subId, filters)
|
||||
}
|
||||
cmd: Command,
|
||||
) = getOrCreateRelay(relay).sendIfConnected(cmd)
|
||||
|
||||
fun sendCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {
|
||||
relays.forEach { url, relay ->
|
||||
val filters = filters[relay.url]
|
||||
if (!filters.isNullOrEmpty()) {
|
||||
relay.sendCount(subId, filters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) =
|
||||
relays.forEach { url, relay ->
|
||||
relay.close(subscriptionId)
|
||||
}
|
||||
|
||||
fun close(
|
||||
relay: NormalizedRelayUrl,
|
||||
subscriptionId: String,
|
||||
) = relays.get(relay)?.close(subscriptionId)
|
||||
|
||||
fun send(
|
||||
signedEvent: Event,
|
||||
fun sendOrConnectAndSync(
|
||||
list: Set<NormalizedRelayUrl>,
|
||||
cmd: Command,
|
||||
) {
|
||||
list.forEach {
|
||||
getOrCreateRelay(it).send(signedEvent)
|
||||
getOrCreateRelay(it).sendOrConnectAndSync(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendIfConnected(
|
||||
list: Set<NormalizedRelayUrl>,
|
||||
cmd: Command,
|
||||
) {
|
||||
list.forEach {
|
||||
getOrCreateRelay(it).sendIfConnected(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Pool Maintenance
|
||||
// --------------------
|
||||
fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, createNewRelay)
|
||||
fun getOrCreateRelay(relay: NormalizedRelayUrl) = relays.getOrCreate(relay, ::createNewRelay)
|
||||
|
||||
fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, createNewRelay)
|
||||
fun createRelayIfAbsent(relay: NormalizedRelayUrl): Boolean = relays.createIfAbsent(relay, ::createNewRelay)
|
||||
|
||||
/**
|
||||
* Updates the pool of relays without disconnecting the existing ones.
|
||||
@@ -244,75 +217,39 @@ class RelayPool(
|
||||
// --------------------
|
||||
// Listener Interceptor
|
||||
// --------------------
|
||||
override fun onConnecting(relay: IRelayClient) = listener.onConnecting(relay)
|
||||
|
||||
override fun onEvent(
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
listener.onEvent(relay, subId, event, arrivalTime, afterEOSE)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
error: Error,
|
||||
) {
|
||||
listener.onError(relay, subId, error)
|
||||
updateStatus()
|
||||
listener.onConnected(relay, pingMillis, compressed)
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
) {
|
||||
listener.onEOSE(relay, subId, arrivalTime)
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) {
|
||||
listener.onRelayStateChange(relay, type)
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
updateStatus()
|
||||
listener.onDisconnected(relay)
|
||||
}
|
||||
|
||||
override fun onSendResponse(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) = listener.onIncomingMessage(relay, msgStr, msg)
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) = listener.onCannotConnect(relay, errorMessage)
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) = listener.onSendResponse(relay, eventId, success, message)
|
||||
|
||||
override fun onAuth(
|
||||
relay: IRelayClient,
|
||||
challenge: String,
|
||||
) = listener.onAuth(relay, challenge)
|
||||
|
||||
override fun onNotify(
|
||||
relay: IRelayClient,
|
||||
description: String,
|
||||
) = listener.onNotify(relay, description)
|
||||
|
||||
override fun onClosed(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
message: String,
|
||||
) = listener.onClosed(relay, subId, message)
|
||||
|
||||
override fun onSend(
|
||||
relay: IRelayClient,
|
||||
msg: String,
|
||||
success: Boolean,
|
||||
) = listener.onSend(relay, msg, success)
|
||||
|
||||
override fun onBeforeSend(
|
||||
relay: IRelayClient,
|
||||
event: Event,
|
||||
) = listener.onBeforeSend(relay, event)
|
||||
) = listener.onSent(relay, cmdStr, cmd, success)
|
||||
|
||||
// ---------------
|
||||
// STATUS Reports
|
||||
|
||||
+28
-23
@@ -18,29 +18,34 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single.simple
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
|
||||
/**
|
||||
* This relay client saves any event that will be sent in an outbox
|
||||
* waits for auth and sends it again to make sure it is delivered.
|
||||
*/
|
||||
class SimpleRelayClient(
|
||||
url: NormalizedRelayUrl,
|
||||
socketBuilder: WebsocketBuilder,
|
||||
listener: IRelayClientListener,
|
||||
stats: RelayStat = RelayStat(),
|
||||
defaultOnConnect: (BasicRelayClient) -> Unit = { },
|
||||
) : IRelayClient by BasicRelayClient(
|
||||
url,
|
||||
socketBuilder,
|
||||
OutboxCache(listener),
|
||||
stats,
|
||||
defaultOnConnect,
|
||||
)
|
||||
interface IRequestListener {
|
||||
fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {}
|
||||
|
||||
fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {}
|
||||
|
||||
fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {}
|
||||
|
||||
fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
class RelayActiveRequestStates(
|
||||
val client: INostrClient,
|
||||
) {
|
||||
private var subStates = mutableMapOf<NormalizedRelayUrl, RequestSubscriptionState<String>>()
|
||||
|
||||
fun subGetOrCreate(relay: NormalizedRelayUrl): RequestSubscriptionState<String> = subStates[relay] ?: RequestSubscriptionState<String>().also { subStates.put(relay, it) }
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
subStates.put(relay.url, RequestSubscriptionState())
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
when (msg) {
|
||||
is EventMessage -> subGetOrCreate(relay.url).onNewEvent(msg.subId)
|
||||
is EoseMessage -> subGetOrCreate(relay.url).onEose(msg.subId)
|
||||
is ClosedMessage -> subGetOrCreate(relay.url).onClosed(msg.subId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
when (cmd) {
|
||||
is ReqCmd -> subGetOrCreate(relay.url).onOpenReq(cmd.subId, cmd.filters)
|
||||
is CloseCmd -> subGetOrCreate(relay.url).onCloseReq(cmd.subId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
subStates.remove(relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("RelaySubStateMachine", "Init, Subscribe")
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
Log.d("RelaySubStateMachine", "Destroy, Unsubscribe")
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
|
||||
|
||||
enum class ReqSubStatus {
|
||||
SENT,
|
||||
QUERYING_PAST,
|
||||
LIVE,
|
||||
CLOSED,
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
class RequestSubscriptionState<T> {
|
||||
// Logs the state of each channel to:
|
||||
// 1. inform when an event is received as live
|
||||
// 2. to block REQs being sent before finished (receiving an EOSE or Closed)
|
||||
//
|
||||
// If 2 happens, the relay might send multiple EOSEs in sequence
|
||||
// for the same sub and we won't know which REQ was it for.
|
||||
private val subStates = mutableMapOf<T, ReqSubStatus>()
|
||||
private val filterStates = mutableMapOf<T, List<Filter>>()
|
||||
|
||||
fun currentFilters() = filterStates
|
||||
|
||||
fun currentFilters(reference: T) = filterStates[reference]
|
||||
|
||||
fun currentState(reference: T) = subStates[reference]
|
||||
|
||||
fun onNewEvent(reference: T) {
|
||||
if (subStates[reference] == ReqSubStatus.SENT) {
|
||||
subStates.put(reference, ReqSubStatus.QUERYING_PAST)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEose(reference: T) {
|
||||
subStates.put(reference, ReqSubStatus.LIVE)
|
||||
}
|
||||
|
||||
fun onClosed(reference: T) {
|
||||
subStates.put(reference, ReqSubStatus.CLOSED)
|
||||
}
|
||||
|
||||
fun onOpenReq(
|
||||
reference: T,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
subStates.put(reference, ReqSubStatus.SENT)
|
||||
filterStates.put(reference, filters)
|
||||
}
|
||||
|
||||
fun onCloseReq(reference: T) {
|
||||
subStates.put(reference, ReqSubStatus.CLOSED)
|
||||
}
|
||||
|
||||
fun connecting(reference: T) {
|
||||
subStates.remove(reference)
|
||||
filterStates.remove(reference)
|
||||
}
|
||||
|
||||
fun disconnected(reference: T) {
|
||||
// Can't remove filterStates because disconnections happen
|
||||
// before processing all the events in the channel queue.
|
||||
subStates.remove(reference)
|
||||
}
|
||||
}
|
||||
+22
-12
@@ -18,40 +18,50 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RelayAuthenticator(
|
||||
/**
|
||||
* Listens to NostrClient's onNotify messages from the relay
|
||||
*/
|
||||
class RelayReqStats(
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope,
|
||||
val authenticate: suspend (challenge: String, relay: IRelayClient) -> Unit,
|
||||
) {
|
||||
private val stats = ReqStatsRepository()
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onAuth(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
challenge: String,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
scope.launch {
|
||||
authenticate(challenge, relay)
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
if (msg is EventMessage) {
|
||||
stats.add(msg.subId, msg.event.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun printStats() =
|
||||
stats.printCounter { subId, kind, counter ->
|
||||
Log.d("RelaySubStats", "$subId, kind $kind: $counter")
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("RelayAuthenticator", "Init, Subscribe")
|
||||
Log.d("RelaySubStats", "Init, Subscribe")
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
Log.d("RelayAuthenticator", "Destroy, Unsubscribe")
|
||||
Log.d("RelaySubStats", "Destroy, Unsubscribe")
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -18,12 +18,11 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
|
||||
class SubscriptionStats {
|
||||
class ReqStatsRepository {
|
||||
data class Counter(
|
||||
val subscriptionId: String,
|
||||
val eventKind: Int,
|
||||
@@ -47,12 +46,9 @@ class SubscriptionStats {
|
||||
stats.counter++
|
||||
}
|
||||
|
||||
fun printCounter(tag: String) {
|
||||
fun printCounter(action: (subId: String, kind: Int, counter: Int) -> Unit) {
|
||||
eventCounter.forEach { _, stats ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Received Events ${stats.subscriptionId} ${stats.eventKind}: ${stats.counter}",
|
||||
)
|
||||
action(stats.subscriptionId, stats.eventKind, stats.counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-21
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
|
||||
interface IRelayClient {
|
||||
val url: NormalizedRelayUrl
|
||||
@@ -32,29 +30,13 @@ interface IRelayClient {
|
||||
|
||||
fun needsToReconnect(): Boolean
|
||||
|
||||
fun connectAndRunAfterSync(onConnected: () -> Unit)
|
||||
|
||||
fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean = false)
|
||||
|
||||
fun isConnected(): Boolean
|
||||
|
||||
fun sendRequest(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
)
|
||||
fun sendOrConnectAndSync(cmd: Command)
|
||||
|
||||
fun sendCount(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
)
|
||||
|
||||
fun send(event: Event)
|
||||
|
||||
fun sendAuth(signedEvent: RelayAuthEvent)
|
||||
|
||||
fun sendEvent(event: Event)
|
||||
|
||||
fun close(subscriptionId: String)
|
||||
fun sendIfConnected(cmd: Command)
|
||||
|
||||
fun disconnect()
|
||||
}
|
||||
|
||||
+57
-264
@@ -20,37 +20,18 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_SECS
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -62,8 +43,6 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* @property url The relay's normalized URL.
|
||||
* @property socketBuilder Provides the WebSocket instance for connection.
|
||||
* @property listener Interface to notify the application of relay events and errors.
|
||||
* @property stats Tracks operational statistics of the relay connection.
|
||||
* @property defaultOnConnect Callback executed after a successful connection, allowing subclasses to add initialization logic.
|
||||
*
|
||||
* Reconnection Strategy:
|
||||
* - Uses exponential backoff to retry connections, starting with [DELAY_TO_RECONNECT_IN_SECS] (500ms).
|
||||
@@ -78,28 +57,25 @@ open class BasicRelayClient(
|
||||
override val url: NormalizedRelayUrl,
|
||||
val socketBuilder: WebsocketBuilder,
|
||||
val listener: IRelayClientListener,
|
||||
val stats: RelayStat = RelayStat(),
|
||||
val defaultOnConnect: (BasicRelayClient) -> Unit = { },
|
||||
) : IRelayClient {
|
||||
companion object {
|
||||
// minimum wait time to reconnect: 1 second
|
||||
const val DELAY_TO_RECONNECT_IN_SECS = 1
|
||||
}
|
||||
|
||||
private val logTag = "Relay ${url.displayUrl()}"
|
||||
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
// True if it has received the onOpen call from the socket.
|
||||
private var isReady: Boolean = false
|
||||
private var usingCompression: Boolean = false
|
||||
|
||||
// keeps increasing the delay to connect when errors happen.
|
||||
// This avoids the constant desire to connect when the server is
|
||||
// having trouble or offline.
|
||||
private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
|
||||
private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
|
||||
|
||||
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
private val authResponseWatcher = mutableMapOf<HexKey, Boolean>()
|
||||
private val authChallengesSent = mutableSetOf<String>()
|
||||
|
||||
// Makes sure only one socket is open for each url
|
||||
private var connectingMutex = AtomicBoolean(false)
|
||||
|
||||
fun isConnectionStarted(): Boolean = socket != null
|
||||
@@ -108,19 +84,7 @@ open class BasicRelayClient(
|
||||
|
||||
override fun needsToReconnect() = socket?.needsReconnect() ?: true
|
||||
|
||||
override fun connect() =
|
||||
connectAndRunOverride {
|
||||
defaultOnConnect(this)
|
||||
}
|
||||
|
||||
override fun connectAndRunAfterSync(onConnected: () -> Unit) {
|
||||
connectAndRunOverride {
|
||||
defaultOnConnect(this)
|
||||
onConnected()
|
||||
}
|
||||
}
|
||||
|
||||
fun connectAndRunOverride(onConnected: () -> Unit) {
|
||||
override fun connect() {
|
||||
// If there is a connection, don't wait.
|
||||
if (connectingMutex.exchange(true)) {
|
||||
return
|
||||
@@ -132,52 +96,49 @@ open class BasicRelayClient(
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(logTag, "Connecting...")
|
||||
listener.onConnecting(this)
|
||||
|
||||
lastConnectTentativeInSeconds = TimeUtils.now()
|
||||
|
||||
socket = socketBuilder.build(url, MyWebsocketListener(onConnected))
|
||||
socket = socketBuilder.build(url, MyWebsocketListener())
|
||||
socket?.connect()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
Log.w(logTag, "Crash before connecting", e)
|
||||
stats.newError(e.message ?: "Error trying to connect: $e")
|
||||
|
||||
listener.onCannotConnect(this, "Error when trying to connect: ${e.message}")
|
||||
listener.onDisconnected(this)
|
||||
dontTryAgainForALongTime()
|
||||
markConnectionAsClosed()
|
||||
} finally {
|
||||
connectingMutex.store(false)
|
||||
}
|
||||
}
|
||||
|
||||
inner class MyWebsocketListener(
|
||||
val onConnected: () -> Unit,
|
||||
) : WebSocketListener {
|
||||
inner class MyWebsocketListener : WebSocketListener {
|
||||
override fun onOpen(
|
||||
pingMillis: Int,
|
||||
compression: Boolean,
|
||||
) {
|
||||
Log.d(logTag, "OnOpen (ping: ${pingMillis}ms${if (compression) ", using compression" else ""})")
|
||||
|
||||
markConnectionAsReady(pingMillis, compression)
|
||||
|
||||
onConnected()
|
||||
|
||||
listener.onRelayStateChange(this@BasicRelayClient, RelayState.CONNECTED)
|
||||
markConnectionAsReady(compression)
|
||||
listener.onConnected(this@BasicRelayClient, pingMillis, compression)
|
||||
}
|
||||
|
||||
override fun onMessage(text: String) {
|
||||
consumeIncomingMessage(text, onConnected)
|
||||
try {
|
||||
val msg = OptimizedJsonMapper.fromJsonTo<Message>(text)
|
||||
listener.onIncomingMessage(this@BasicRelayClient, text, msg)
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
// doesn't expose parsing errors to lib users as errors
|
||||
Log.e("BasicRelayClient", "Failure to parse message from Relay", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.w(logTag, "OnClosed $reason")
|
||||
|
||||
markConnectionAsClosed()
|
||||
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
|
||||
listener.onDisconnected(this@BasicRelayClient)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
@@ -190,192 +151,65 @@ open class BasicRelayClient(
|
||||
|
||||
if (socket == null) {
|
||||
// comes from the disconnect action.
|
||||
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
|
||||
listener.onDisconnected(this@BasicRelayClient)
|
||||
} else {
|
||||
socket?.disconnect()
|
||||
|
||||
val msg = t.message
|
||||
|
||||
// checks if this is an actual failure. Closing the socket generates an onFailure as well.
|
||||
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) {
|
||||
stats.newError(response ?: t.message ?: "onFailure event from server: $t")
|
||||
// ignore tor errors.
|
||||
if (msg == null ||
|
||||
(
|
||||
!msg.startsWith("failed to connect to /127.0.0.1") &&
|
||||
msg != "Socket closed" &&
|
||||
msg != "Socket is closed" &&
|
||||
msg != "Cancelled"
|
||||
)
|
||||
) {
|
||||
if (code != null || response != null) {
|
||||
listener.onCannotConnect(this@BasicRelayClient, "Server Misconfigured. Response: $code $response. Exception: ${t.message}")
|
||||
} else {
|
||||
listener.onCannotConnect(this@BasicRelayClient, "WebSocket Failure: ${t.message}")
|
||||
}
|
||||
} else {
|
||||
// ignore local disconnect requests and tor errors
|
||||
// listener.onServerError(this@BasicRelayClient, Error("Ignored Error $code $response. Exception: ${t.message}"))
|
||||
}
|
||||
|
||||
// Failures disconnect the relay.
|
||||
markConnectionAsClosed()
|
||||
|
||||
Log.w(logTag, "OnFailure $code $response ${t.message}")
|
||||
|
||||
if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") {
|
||||
if (code != null || t.message?.endsWith("Host unreachable") == true) {
|
||||
dontTryAgainForALongTime()
|
||||
}
|
||||
|
||||
listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED)
|
||||
listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t))
|
||||
listener.onDisconnected(this@BasicRelayClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeIncomingMessage(
|
||||
text: String,
|
||||
onConnected: () -> Unit,
|
||||
) {
|
||||
// Log.d(logTag, "Processing: $text")
|
||||
stats.addBytesReceived(text.bytesUsedInMemory())
|
||||
|
||||
try {
|
||||
when (val msg = OptimizedJsonMapper.fromJsonTo<Message>(text)) {
|
||||
is EventMessage -> processEvent(msg)
|
||||
is EoseMessage -> processEose(msg)
|
||||
is NoticeMessage -> processNotice(msg)
|
||||
is OkMessage -> processOk(msg, onConnected)
|
||||
is AuthMessage -> processAuth(msg)
|
||||
is NotifyMessage -> processNotify(msg)
|
||||
is ClosedMessage -> processClosed(msg)
|
||||
else -> processUnknownMessage(text)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
stats.newError("Error processing: $text")
|
||||
Log.e(logTag, "Error processing: $text", e)
|
||||
listener.onError(this@BasicRelayClient, "", Error("Error processing $text"))
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnectionAsReady(
|
||||
pingInMs: Int,
|
||||
usingCompression: Boolean,
|
||||
) {
|
||||
this.resetEOSEStatuses()
|
||||
fun markConnectionAsReady(usingCompression: Boolean) {
|
||||
this.isReady = true
|
||||
this.usingCompression = usingCompression
|
||||
|
||||
// resets any extra delays added during on offline state
|
||||
this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
|
||||
|
||||
stats.pingInMs = pingInMs
|
||||
}
|
||||
|
||||
fun markConnectionAsClosed() {
|
||||
this.socket = null
|
||||
this.isReady = false
|
||||
this.usingCompression = false
|
||||
this.resetEOSEStatuses()
|
||||
}
|
||||
|
||||
private fun processEvent(msg: EventMessage) {
|
||||
// Log.w(logTag, "Event ${msg.subId} ${msg.event.toJson()}")
|
||||
listener.onEvent(
|
||||
relay = this,
|
||||
subId = msg.subId,
|
||||
event = msg.event,
|
||||
arrivalTime = TimeUtils.now(),
|
||||
afterEOSE = afterEOSEPerSubscription[msg.subId] == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun processEose(msg: EoseMessage) {
|
||||
Log.d(logTag, "EOSE ${msg.subId}")
|
||||
afterEOSEPerSubscription[msg.subId] = true
|
||||
listener.onEOSE(this, msg.subId, TimeUtils.now())
|
||||
}
|
||||
|
||||
private fun processNotice(msg: NoticeMessage) {
|
||||
Log.w(logTag, "Notice ${msg.message}")
|
||||
stats.newNotice(msg.message)
|
||||
listener.onError(this@BasicRelayClient, msg.message, Error("Relay sent notice: $msg.message"))
|
||||
}
|
||||
|
||||
private fun processOk(
|
||||
msg: OkMessage,
|
||||
onConnected: () -> Unit,
|
||||
) {
|
||||
Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}")
|
||||
|
||||
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
|
||||
if (authResponseWatcher.containsKey(msg.eventId)) {
|
||||
val wasAlreadyAuthenticated = authResponseWatcher[msg.eventId]
|
||||
authResponseWatcher.put(msg.eventId, msg.success)
|
||||
if (wasAlreadyAuthenticated != true && msg.success) {
|
||||
onConnected()
|
||||
listener.onAuthed(this@BasicRelayClient, msg.eventId, msg.success, msg.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.success) {
|
||||
stats.newNotice("Rejected event ${msg.eventId}: ${msg.message}")
|
||||
}
|
||||
|
||||
listener.onSendResponse(this@BasicRelayClient, msg.eventId, msg.success, msg.message)
|
||||
}
|
||||
|
||||
private fun processAuth(msg: AuthMessage) {
|
||||
Log.d(logTag, "Auth ${msg.challenge}")
|
||||
listener.onAuth(this@BasicRelayClient, msg.challenge)
|
||||
}
|
||||
|
||||
private fun processNotify(msg: NotifyMessage) {
|
||||
// Log.w(logTag, "Notify $newMessage")
|
||||
listener.onNotify(this@BasicRelayClient, msg.message)
|
||||
}
|
||||
|
||||
private fun processClosed(msg: ClosedMessage) {
|
||||
Log.w(logTag, "Relay Closed Subscription ${msg.subId} ${msg.message}")
|
||||
stats.newNotice("Subscription closed: ${msg.subId} ${msg.message}")
|
||||
afterEOSEPerSubscription[msg.subId] = false
|
||||
listener.onClosed(this@BasicRelayClient, msg.subId, msg.message)
|
||||
}
|
||||
|
||||
private fun processUnknownMessage(newMessage: String) {
|
||||
stats.newError("Unsupported message: $newMessage")
|
||||
Log.w(logTag, "Unsupported message: $newMessage")
|
||||
listener.onError(this, "", Error("Unsupported message: $newMessage"))
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
Log.d(logTag, "Disconnecting...")
|
||||
lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested.
|
||||
delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
|
||||
socket?.disconnect()
|
||||
socket = null
|
||||
isReady = false
|
||||
usingCompression = false
|
||||
resetEOSEStatuses()
|
||||
}
|
||||
|
||||
fun resetEOSEStatuses() {
|
||||
afterEOSEPerSubscription = LinkedHashMap(afterEOSEPerSubscription.size)
|
||||
|
||||
authResponseWatcher.clear()
|
||||
authChallengesSent.clear()
|
||||
}
|
||||
|
||||
override fun sendRequest(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
if (isConnectionStarted()) {
|
||||
if (isReady) {
|
||||
if (filters.isNotEmpty()) {
|
||||
afterEOSEPerSubscription[subId] = false
|
||||
writeToSocket(ReqCmd(subId, filters))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connectAndSyncFiltersIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendCount(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
if (isConnectionStarted()) {
|
||||
if (isReady) {
|
||||
if (filters.isNotEmpty()) {
|
||||
afterEOSEPerSubscription[subId] = false
|
||||
writeToSocket(CountCmd(subId, filters))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connectAndSyncFiltersIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {
|
||||
@@ -398,62 +232,21 @@ open class BasicRelayClient(
|
||||
delayToConnectInSeconds = TimeUtils.ONE_DAY
|
||||
}
|
||||
|
||||
override fun send(event: Event) {
|
||||
listener.onBeforeSend(this@BasicRelayClient, event)
|
||||
|
||||
if (event is RelayAuthEvent) {
|
||||
sendAuth(event)
|
||||
} else {
|
||||
sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendAuth(signedEvent: RelayAuthEvent) {
|
||||
val challenge = signedEvent.challenge()
|
||||
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
if (challenge != null && challenge !in authChallengesSent) {
|
||||
authResponseWatcher[signedEvent.id] = false
|
||||
authChallengesSent.add(challenge)
|
||||
writeToSocket(AuthCmd(signedEvent))
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendEvent(event: Event) {
|
||||
override fun sendOrConnectAndSync(cmd: Command) {
|
||||
if (isConnectionStarted()) {
|
||||
if (isReady) {
|
||||
writeToSocket(EventCmd(event))
|
||||
if (isReady && cmd.isValid()) {
|
||||
sendIfConnected(cmd)
|
||||
}
|
||||
} else {
|
||||
connectAndSyncFiltersIfDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeToSocket(cmd: Command) {
|
||||
writeToSocket(OptimizedJsonMapper.toJson(cmd))
|
||||
}
|
||||
|
||||
private fun writeToSocket(str: String) {
|
||||
if (socket == null) {
|
||||
listener.onError(
|
||||
this@BasicRelayClient,
|
||||
"",
|
||||
Error("Failed to send $str. Relay is not connected."),
|
||||
)
|
||||
}
|
||||
override fun sendIfConnected(cmd: Command) {
|
||||
socket?.let {
|
||||
// Log.d(logTag, "Sending (${str.length} chars): $str")
|
||||
val result = it.send(str)
|
||||
listener.onSend(this@BasicRelayClient, str, result)
|
||||
stats.addBytesSent(str.bytesUsedInMemory())
|
||||
}
|
||||
}
|
||||
|
||||
override fun close(subscriptionId: String) {
|
||||
// avoids sending closes for subscriptions that were never sent to this relay.
|
||||
if (afterEOSEPerSubscription.containsKey(subscriptionId)) {
|
||||
writeToSocket(CloseCmd(subscriptionId))
|
||||
afterEOSEPerSubscription[subscriptionId] = false
|
||||
val msg = OptimizedJsonMapper.toJson(cmd)
|
||||
val success = it.send(msg)
|
||||
listener.onSent(this@BasicRelayClient, msg, cmd, success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single.simple
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
|
||||
class OutboxCache(
|
||||
listener: IRelayClientListener,
|
||||
) : RedirectRelayClientListener(listener) {
|
||||
/**
|
||||
* Auth procedures require us to keep track of the outgoing events
|
||||
* to make sure the relay waits for the auth to finish and send them.
|
||||
*/
|
||||
private val outboxCache = LargeCache<HexKey, Event>()
|
||||
|
||||
override fun onRelayStateChange(
|
||||
relay: IRelayClient,
|
||||
type: RelayState,
|
||||
) {
|
||||
if (type == RelayState.CONNECTED) {
|
||||
outboxCache.forEach { id, event ->
|
||||
relay.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
super.onRelayStateChange(relay, type)
|
||||
}
|
||||
|
||||
override fun onAuthed(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
super.onAuthed(relay, eventId, success, message)
|
||||
outboxCache.forEach { id, event ->
|
||||
relay.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBeforeSend(
|
||||
relay: IRelayClient,
|
||||
event: Event,
|
||||
) {
|
||||
if (event !is RelayAuthEvent) {
|
||||
outboxCache.put(event.id, event)
|
||||
}
|
||||
super.onBeforeSend(relay, event)
|
||||
}
|
||||
|
||||
override fun onSendResponse(
|
||||
relay: IRelayClient,
|
||||
eventId: String,
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
// remove from cache for any error that is not an auth required error.
|
||||
// for auth required, we will do the auth and try to send again.
|
||||
if (outboxCache.containsKey(eventId) && !message.startsWith("auth-required")) {
|
||||
outboxCache.remove(eventId)
|
||||
}
|
||||
|
||||
super.onSendResponse(relay, eventId, success, message)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.single.standalone
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RedirectRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
|
||||
/**
|
||||
* This relay client saves any event that will be sent in an outbox
|
||||
* waits for auth and sends it again to make sure it is delivered.
|
||||
*/
|
||||
class StandaloneRelayClient(
|
||||
url: NormalizedRelayUrl,
|
||||
socketBuilder: WebsocketBuilder,
|
||||
listener: IRelayClientListener = EmptyClientListener,
|
||||
) {
|
||||
private val outbox = LargeCache<HexKey, Event>()
|
||||
private val reqs = LargeCache<String, List<Filter>>()
|
||||
private val counts = LargeCache<String, List<Filter>>()
|
||||
|
||||
val client =
|
||||
BasicRelayClient(
|
||||
url,
|
||||
socketBuilder,
|
||||
object : RedirectRelayClientListener(listener) {
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
super.onConnected(relay, pingMillis, compressed)
|
||||
renewFilters()
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
if (msg is OkMessage) {
|
||||
// remove from cache for any error that is not an auth required error.
|
||||
// for auth required, we will do the auth and try to send again.
|
||||
if (outbox.containsKey(msg.eventId) && !msg.message.startsWith("auth-required")) {
|
||||
outbox.remove(msg.eventId)
|
||||
}
|
||||
}
|
||||
super.onIncomingMessage(relay, msgStr, msg)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun renewFilters() {
|
||||
outbox.forEach { id, event ->
|
||||
client.sendOrConnectAndSync(EventCmd(event))
|
||||
}
|
||||
reqs.forEach { subId, filters ->
|
||||
client.sendOrConnectAndSync(ReqCmd(subId, filters))
|
||||
}
|
||||
counts.forEach { subId, filters ->
|
||||
client.sendOrConnectAndSync(CountCmd(subId, filters))
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() = client.connect()
|
||||
|
||||
fun needsToReconnect() = client.needsToReconnect()
|
||||
|
||||
fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = client.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays)
|
||||
|
||||
fun isConnected() = client.isConnected()
|
||||
|
||||
fun sendRequest(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
reqs.put(subId, filters)
|
||||
client.sendOrConnectAndSync(ReqCmd(subId, filters))
|
||||
}
|
||||
|
||||
fun sendCount(
|
||||
queryId: String,
|
||||
filters: List<Filter>,
|
||||
) {
|
||||
counts.put(queryId, filters)
|
||||
client.sendOrConnectAndSync(CountCmd(queryId, filters))
|
||||
}
|
||||
|
||||
fun send(event: Event) {
|
||||
if (event !is RelayAuthEvent) {
|
||||
outbox.put(event.id, event)
|
||||
}
|
||||
client.sendOrConnectAndSync(EventCmd(event))
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
reqs.remove(subscriptionId)
|
||||
counts.remove(subscriptionId)
|
||||
client.sendIfConnected(CloseCmd(subscriptionId))
|
||||
}
|
||||
|
||||
fun disconnect() = client.disconnect()
|
||||
}
|
||||
+1
@@ -29,6 +29,7 @@ class RelayStat(
|
||||
var spamCounter: Int = 0,
|
||||
var errorCounter: Int = 0,
|
||||
var pingInMs: Int = 0,
|
||||
var compression: Boolean = false,
|
||||
) {
|
||||
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
|
||||
|
||||
|
||||
+69
-39
@@ -22,7 +22,17 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
|
||||
|
||||
class RelayStats(
|
||||
val client: INostrClient,
|
||||
@@ -32,47 +42,67 @@ class RelayStats(
|
||||
override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat()
|
||||
}
|
||||
|
||||
fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) ?: throw IllegalArgumentException("Should never happen")
|
||||
fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen")
|
||||
|
||||
fun addBytesReceived(
|
||||
url: NormalizedRelayUrl,
|
||||
bytesUsedInMemory: Int,
|
||||
) {
|
||||
get(url).addBytesReceived(bytesUsedInMemory)
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
with(get(relay.url)) {
|
||||
pingInMs = pingMillis
|
||||
compression = compressed
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
get(relay.url).newError(errorMessage)
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
get(relay.url).addBytesSent(cmdStr.bytesUsedInMemory())
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
val stat = get(relay.url)
|
||||
|
||||
stat.addBytesReceived(msgStr.bytesUsedInMemory())
|
||||
|
||||
when (msg) {
|
||||
is NoticeMessage -> stat.newNotice(msg.message)
|
||||
is NotifyMessage -> stat.newNotice("Notify user: " + msg.message)
|
||||
is OkMessage -> {
|
||||
if (!msg.success) {
|
||||
stat.newNotice("Rejected event ${msg.eventId}: ${msg.message}")
|
||||
}
|
||||
}
|
||||
is ClosedMessage -> stat.newNotice("Subscription closed: ${msg.subId} ${msg.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("RelayStats", "Init, Subscribe")
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun addBytesSent(
|
||||
url: NormalizedRelayUrl,
|
||||
bytesUsedInMemory: Int,
|
||||
) {
|
||||
get(url).addBytesSent(bytesUsedInMemory)
|
||||
}
|
||||
|
||||
fun newError(
|
||||
url: NormalizedRelayUrl,
|
||||
error: String?,
|
||||
) {
|
||||
get(url).newError(error)
|
||||
}
|
||||
|
||||
fun newNotice(
|
||||
url: NormalizedRelayUrl,
|
||||
notice: String?,
|
||||
) {
|
||||
get(url).newNotice(notice)
|
||||
}
|
||||
|
||||
fun setPing(
|
||||
url: NormalizedRelayUrl,
|
||||
pingInMs: Int,
|
||||
) {
|
||||
get(url).pingInMs = pingInMs
|
||||
}
|
||||
|
||||
fun newSpam(
|
||||
url: NormalizedRelayUrl,
|
||||
explanation: String,
|
||||
) {
|
||||
get(url).newSpam(explanation)
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
Log.d("RelayStats", "Destroy, Unsubscribe")
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-12
@@ -20,30 +20,24 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
data class Subscription(
|
||||
val id: String = newSubId(),
|
||||
val onEose: ((time: Long, relayUrl: NormalizedRelayUrl) -> Unit)? = null,
|
||||
val listener: IRequestListener,
|
||||
) {
|
||||
private var filters: Map<NormalizedRelayUrl, List<Filter>>? = null // Inactive when null
|
||||
private var currentVersion: Map<NormalizedRelayUrl, List<Filter>>? = null // Inactive when null
|
||||
|
||||
fun reset() {
|
||||
filters = null
|
||||
currentVersion = null
|
||||
}
|
||||
|
||||
fun updateFilters(newFilters: Map<NormalizedRelayUrl, List<Filter>>?) {
|
||||
filters = newFilters
|
||||
currentVersion = newFilters
|
||||
}
|
||||
|
||||
fun filters() = filters
|
||||
|
||||
fun callEose(
|
||||
time: Long,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
onEose?.let { it(time, relay) }
|
||||
}
|
||||
fun filters() = currentVersion
|
||||
}
|
||||
|
||||
+12
-48
@@ -20,10 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
@@ -51,47 +49,12 @@ class SubscriptionController(
|
||||
) {
|
||||
private val subscriptions = LargeCache<String, Subscription>()
|
||||
|
||||
private val clientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onEvent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
) {
|
||||
if (subscriptions.containsKey(subId)) {
|
||||
if (afterEOSE) {
|
||||
subscriptions.get(subId)?.callEose(arrivalTime, relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
) {
|
||||
if (subscriptions.containsKey(subId)) {
|
||||
subscriptions.get(subId)?.callEose(arrivalTime, relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
client.unsubscribe(clientListener)
|
||||
}
|
||||
|
||||
fun getSub(subId: String) = subscriptions.get(subId)
|
||||
|
||||
fun requestNewSubscription(
|
||||
subId: String,
|
||||
onEOSE: ((Long, NormalizedRelayUrl) -> Unit)? = null,
|
||||
): Subscription = Subscription(subId, onEose = onEOSE).also { subscriptions.put(it.id, it) }
|
||||
listener: IRequestListener,
|
||||
): Subscription = Subscription(subId, listener).also { subscriptions.put(it.id, it) }
|
||||
|
||||
fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) }
|
||||
|
||||
@@ -108,28 +71,29 @@ class SubscriptionController(
|
||||
}
|
||||
|
||||
subscriptions.forEach { id, sub ->
|
||||
updateRelaysIfNeeded(id, sub.filters(), currentFilters[id])
|
||||
updateRelaysIfNeeded(id, sub.listener, sub.filters(), currentFilters[id])
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRelaysIfNeeded(
|
||||
subId: String,
|
||||
updatedFilters: Map<NormalizedRelayUrl, List<Filter>>?,
|
||||
currentFilters: Map<NormalizedRelayUrl, List<Filter>>?,
|
||||
listener: IRequestListener,
|
||||
newFilters: Map<NormalizedRelayUrl, List<Filter>>?,
|
||||
oldFilters: Map<NormalizedRelayUrl, List<Filter>>?,
|
||||
) {
|
||||
if (currentFilters != null) {
|
||||
if (updatedFilters == null) {
|
||||
if (oldFilters != null) {
|
||||
if (newFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
client.close(subId)
|
||||
} else {
|
||||
client.openReqSubscription(subId, updatedFilters)
|
||||
client.openReqSubscription(subId, newFilters, listener)
|
||||
}
|
||||
} else {
|
||||
if (updatedFilters == null) {
|
||||
if (newFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the entire filter.
|
||||
client.openReqSubscription(subId, updatedFilters)
|
||||
client.openReqSubscription(subId, newFilters, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
class CountCmd(
|
||||
val subId: String,
|
||||
val queryId: String,
|
||||
val filters: List<Filter>,
|
||||
) : Command {
|
||||
override fun label(): String = LABEL
|
||||
|
||||
override fun isValid() = subId.isNotEmpty() && filters.isNotEmpty()
|
||||
override fun isValid() = queryId.isNotEmpty() && filters.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
const val LABEL = "COUNT"
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
}
|
||||
|
||||
CountCmd.LABEL -> {
|
||||
val subId = jp.nextTextValue()
|
||||
val queryId = jp.nextTextValue()
|
||||
val filters = mutableListOf<Filter>()
|
||||
|
||||
while (jp.nextToken() != JsonToken.END_ARRAY) {
|
||||
@@ -71,7 +71,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
}
|
||||
|
||||
CountCmd(
|
||||
subId = subId,
|
||||
queryId = queryId,
|
||||
filters = filters,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ class CommandSerializer : StdSerializer<Command>(Command::class.java) {
|
||||
}
|
||||
|
||||
is CountCmd -> {
|
||||
gen.writeString(cmd.subId)
|
||||
gen.writeString(cmd.queryId)
|
||||
cmd.filters.forEach {
|
||||
filterSerializer.serialize(it, gen, provider)
|
||||
}
|
||||
|
||||
+12
-21
@@ -23,9 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.relay
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -50,32 +50,24 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
|
||||
val mySubId = "test-sub-id-1"
|
||||
|
||||
val listener =
|
||||
object : IRelayClientListener {
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (mySubId == subId) {
|
||||
resultChannel.trySend(event.id)
|
||||
}
|
||||
resultChannel.trySend(event.id)
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (mySubId == subId) {
|
||||
resultChannel.trySend("EOSE")
|
||||
}
|
||||
resultChannel.trySend("EOSE")
|
||||
}
|
||||
}
|
||||
|
||||
client.subscribe(listener)
|
||||
|
||||
val filters =
|
||||
mapOf(
|
||||
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
|
||||
@@ -87,7 +79,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
|
||||
),
|
||||
)
|
||||
|
||||
client.openReqSubscription(mySubId, filters)
|
||||
client.openReqSubscription(mySubId, filters, listener)
|
||||
|
||||
withTimeoutOrNull(30000) {
|
||||
while (events.size < 101) {
|
||||
@@ -99,7 +91,6 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
|
||||
resultChannel.close()
|
||||
|
||||
client.close(mySubId)
|
||||
client.unsubscribe(listener)
|
||||
client.disconnect()
|
||||
|
||||
appScope.cancel()
|
||||
|
||||
+44
-28
@@ -20,11 +20,13 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
@@ -55,25 +57,21 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
|
||||
|
||||
val listener =
|
||||
object : IRelayClientListener {
|
||||
override fun onEvent(
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
event: Event,
|
||||
arrivalTime: Long,
|
||||
afterEOSE: Boolean,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
if (mySubId == subId) {
|
||||
resultChannel.trySend(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEOSE(
|
||||
relay: IRelayClient,
|
||||
subId: String,
|
||||
arrivalTime: Long,
|
||||
) {
|
||||
if (mySubId == subId) {
|
||||
resultChannel.trySend("EOSE")
|
||||
Log.d("Test", "Receiving message: $msgStr")
|
||||
when (msg) {
|
||||
is EventMessage ->
|
||||
if (mySubId == msg.subId) {
|
||||
resultChannel.trySend(msg.event.id)
|
||||
}
|
||||
is EoseMessage ->
|
||||
if (mySubId == msg.subId) {
|
||||
resultChannel.trySend("EOSE")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,13 +89,24 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
|
||||
),
|
||||
)
|
||||
|
||||
val filters2 =
|
||||
val filtersShouldIgnore =
|
||||
mapOf(
|
||||
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
limit = 100,
|
||||
limit = 500,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val filtersShouldSendAfterEOSE =
|
||||
mapOf(
|
||||
RelayUrlNormalizer.normalize("wss://relay.damus.io") to
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
limit = 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -105,14 +114,16 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
|
||||
coroutineScope {
|
||||
launch {
|
||||
withTimeoutOrNull(30000) {
|
||||
while (events.size < 202) {
|
||||
while (events.size < 112) {
|
||||
Log.d("Test", "Processing message ${events.size}")
|
||||
// simulates an update in the middle of the sub
|
||||
if (events.size == 1) {
|
||||
client.openReqSubscription(mySubId, filters2)
|
||||
client.openReqSubscription(mySubId, filtersShouldIgnore)
|
||||
}
|
||||
val event = resultChannel.receive()
|
||||
Log.d("OkHttpWebsocketListener", "Processing: ${events.size} $event")
|
||||
events.add(event)
|
||||
if (events.size == 5) {
|
||||
client.openReqSubscription(mySubId, filtersShouldSendAfterEOSE)
|
||||
}
|
||||
events.add(resultChannel.receive())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,10 +139,15 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
|
||||
|
||||
appScope.cancel()
|
||||
|
||||
assertEquals(202, events.size)
|
||||
// gets all 113 messages (100 events + 1 EOSE + 10 events + 1 EOSE)
|
||||
assertEquals(112, events.size)
|
||||
// checks if first 100 have Ids
|
||||
assertEquals(true, events.take(100).all { it.length == 64 })
|
||||
// checks if EOSE is after the first 100
|
||||
assertEquals("EOSE", events[100])
|
||||
assertEquals(true, events.drop(101).take(100).all { it.length == 64 })
|
||||
assertEquals("EOSE", events[201])
|
||||
// checks if next 10 have Ids
|
||||
assertEquals(true, events.drop(101).take(10).all { it.length == 64 })
|
||||
// checks if EOSE is after the next 10
|
||||
assertEquals("EOSE", events[111])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user