Finalizes the RelaySync utility

This commit is contained in:
Vitor Pamplona
2026-03-18 17:35:05 -04:00
parent e57023cad9
commit 3e3adaff10
13 changed files with 959 additions and 570 deletions
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
}
@@ -35,6 +35,7 @@ import coil3.asDrawable
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
@@ -94,8 +95,10 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -145,8 +148,10 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -175,7 +180,66 @@ class AccountViewModel(
val toastManager = ToastManager() val toastManager = ToastManager()
val broadcastTracker = BroadcastTracker() val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope) val feedStates = AccountFeedContentStates(account, viewModelScope)
val eventSync = EventSync(account, viewModelScope)
val eventSync =
EventSync(
accountPubKey = account.signer.pubKey,
relayDb = {
val stats = Amethyst.instance.relayStats.snapshot()
val relays =
account.cache.relayHints.relayDB
.keys()
.filter { url ->
val relayStat = stats[url]
// has connected at least once OR never tried.
if (relayStat != null) {
relayStat.connectionCompleted > 0 || relayStat.connectionTentatives == 0
} else {
true
}
}
val sortMap = relays.associateWith { stats.get(it)?.receivedBytes }
relays.sortedByDescending { sortMap[it] }
},
outboxTargets = { account.nip65RelayList.outboxFlow.value },
inboxTargets = { account.nip65RelayList.inboxFlow.value },
dmTargets = { account.dmRelayList.flow.value },
clientBuilder = {
// creates a new client to make sure these events don't end up polluting the local cache.
// Create a new scope that inherits the ViewModel's lifecycle
// but uses a SupervisorJob so child failures are independent.
val customScope = CoroutineScope(viewModelScope.coroutineContext + SupervisorJob())
// Provides a relay pool
val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope)
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
customScope,
signWithAllLoggedInUsers = { authTemplate ->
if (account.signer.isWriteable()) {
try {
listOf(account.signer.sign(authTemplate))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
emptyList()
}
} else {
emptyList()
}
},
)
newClient
},
scope = viewModelScope,
)
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5) val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
@@ -20,17 +20,23 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import com.vitorpamplona.amethyst.model.Account import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_ACTIVITY_LOG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.Companion.MAX_CONCURRENT_RELAYS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync.LiveSyncActivity.SourceRelayInfo
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.reqBypassingRelayLimits
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener 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.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message 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.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.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -38,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
@@ -65,18 +72,21 @@ import java.util.concurrent.atomic.AtomicLong
* Live activity is emitted via [liveActivity] so the UI can show a per-relay log of events * Live activity is emitted via [liveActivity] so the UI can show a per-relay log of events
* received and events accepted by destination relays. * received and events accepted by destination relays.
* *
* The sync is pausable: calling [cancel] transitions to [SyncState.Paused] so the user can
* [resume] from the last completed relay index rather than starting over.
*
* Scoped to the AccountViewModel so the sync survives navigation within the same session. * Scoped to the AccountViewModel so the sync survives navigation within the same session.
*/ */
@Stable
class EventSync( class EventSync(
val account: Account, private val accountPubKey: HexKey,
private val relayDb: () -> List<NormalizedRelayUrl>,
private val outboxTargets: () -> Set<NormalizedRelayUrl>,
private val inboxTargets: () -> Set<NormalizedRelayUrl>,
private val dmTargets: () -> Set<NormalizedRelayUrl>,
private val clientBuilder: () -> INostrClient,
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
companion object { companion object {
/** Maximum number of relays queried at the same time. */ /** Maximum number of relays queried at the same time. */
const val MAX_CONCURRENT_RELAYS = 50 const val MAX_CONCURRENT_RELAYS = 10
/** How long (ms) to wait for a single relay to reply per page before giving up. */ /** How long (ms) to wait for a single relay to reply per page before giving up. */
const val RELAY_TIMEOUT_MS = 30_000L const val RELAY_TIMEOUT_MS = 30_000L
@@ -98,13 +108,6 @@ class EventSync(
val eventsSent: Int, val eventsSent: Int,
) : SyncState() ) : SyncState()
/** Cancelled mid-run; can be resumed from [nextRelayIndex]. */
data class Paused(
val nextRelayIndex: Int,
val totalRelays: Int,
val eventsSent: Int,
) : SyncState()
data class Done( data class Done(
val totalEventsSent: Int, val totalEventsSent: Int,
val totalEventsAccepted: Int, val totalEventsAccepted: Int,
@@ -119,39 +122,83 @@ class EventSync(
/** /**
* Per-relay activity snapshot emitted continuously while the sync runs. * Per-relay activity snapshot emitted continuously while the sync runs.
* *
* @param recentCompletions Last [MAX_ACTIVITY_LOG] relays that finished, newest first. * @param completedRelays Last [MAX_ACTIVITY_LOG] relays that finished, sorted by most events
* found (descending) so the most productive sources appear first.
* @param outboxTargets Relays receiving events authored by the user. * @param outboxTargets Relays receiving events authored by the user.
* @param inboxTargets Relays receiving events that mention the user. * @param inboxTargets Relays receiving events that mention the user.
* @param dmTargets Relays receiving DMs addressed to the user. * @param dmTargets Relays receiving DMs addressed to the user.
*/ */
@Stable
data class LiveSyncActivity( data class LiveSyncActivity(
val recentCompletions: List<CompletedRelayInfo> = emptyList(), val runningRelays: Map<NormalizedRelayUrl, SourceRelayInfo> = emptyMap(),
val outboxTargets: List<DestinationRelayInfo> = emptyList(), val completedRelays: Map<NormalizedRelayUrl, SourceRelayInfo> = emptyMap(),
val inboxTargets: List<DestinationRelayInfo> = emptyList(), val outboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val dmTargets: List<DestinationRelayInfo> = emptyList(), val inboxTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
val dmTargets: Map<NormalizedRelayUrl, DestinationRelayInfo> = emptyMap(),
) { ) {
companion object {
val DefaultOrder = compareByDescending<SourceRelayInfo> { it.eventsFound.value }.thenByDescending { it.status.value == ConnectionStatus.Completed }
}
val sortedCompletedRelays = completedRelays.values.sortedWith(DefaultOrder)
constructor(
runningRelays: List<SourceRelayInfo>,
completedRelays: List<SourceRelayInfo>,
outboxTargets: List<DestinationRelayInfo>,
inboxTargets: List<DestinationRelayInfo>,
dmTargets: List<DestinationRelayInfo>,
) : this(
runningRelays.associateBy { it.relay },
completedRelays.associateBy { it.relay },
outboxTargets.associateBy { it.relay },
inboxTargets.associateBy { it.relay },
dmTargets.associateBy { it.relay },
)
sealed interface ConnectionStatus {
object Connecting : ConnectionStatus
object Querying : ConnectionStatus
class Error(
val msg: String,
) : ConnectionStatus
object Completed : ConnectionStatus
}
/** /**
* @param eventsFound Total events received from this relay across all pages. * @param eventsFound Total events received from this relay across all pages.
* @param eventsAccepted Events from this relay that destination relays accepted as new * @param eventsAccepted Events from this relay that destination relays accepted as new
* (OK true). Reflects the count at relay-completion time; late * (OK true). Reflects the count at relay-completion time; late
* OK responses may not be included. * OK responses may not be included.
*/ */
data class CompletedRelayInfo( @Stable
data class SourceRelayInfo(
val relay: NormalizedRelayUrl, val relay: NormalizedRelayUrl,
val eventsFound: Int, val status: MutableStateFlow<ConnectionStatus>,
val eventsAccepted: Int, val eventsFound: MutableStateFlow<Int>,
) val eventsAccepted: MutableStateFlow<Int>,
) {
constructor(relay: NormalizedRelayUrl, status: ConnectionStatus, eventsFound: Int, eventsAccepted: Int) :
this(relay, MutableStateFlow(status), MutableStateFlow(eventsFound), MutableStateFlow(eventsAccepted))
}
/** /**
* @param relay The destination relay URL. * @param relay The destination relay URL.
* @param eventsSent Number of events sent to this relay. * @param eventsSent Number of events sent to this relay.
* @param eventsAccepted Number of OK=true responses received from this relay. * @param eventsAccepted Number of OK=true responses received from this relay.
*/ */
@Stable
data class DestinationRelayInfo( data class DestinationRelayInfo(
val relay: NormalizedRelayUrl, val relay: NormalizedRelayUrl,
val eventsSent: Int, val eventsSent: MutableStateFlow<Int>,
val eventsAccepted: Int, val eventsAccepted: MutableStateFlow<Int>,
) ) {
constructor(relay: NormalizedRelayUrl, eventsSent: Int, eventsAccepted: Int) :
this(relay, MutableStateFlow(eventsSent), MutableStateFlow(eventsAccepted))
}
} }
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle) private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
@@ -160,42 +207,57 @@ class EventSync(
private val _liveActivity = MutableStateFlow(LiveSyncActivity()) private val _liveActivity = MutableStateFlow(LiveSyncActivity())
val liveActivity: StateFlow<LiveSyncActivity> = _liveActivity val liveActivity: StateFlow<LiveSyncActivity> = _liveActivity
// ------------------------------------------------------------------------- private fun emitLiveSnapshot(
// Live activity tracking (written from worker threads) runningRelays: Set<NormalizedRelayUrl> = emptySet(),
// ------------------------------------------------------------------------- completedRelays: Set<NormalizedRelayUrl> = emptySet(),
liveOutboxTargets: Set<NormalizedRelayUrl> = emptySet(),
private val trackingCompletions = ArrayDeque<LiveSyncActivity.CompletedRelayInfo>() liveInboxTargets: Set<NormalizedRelayUrl> = emptySet(),
private val trackingLock = Any() liveDmTargets: Set<NormalizedRelayUrl> = emptySet(),
) {
/** Destination relay sets, set at the start of each sync run. */
@Volatile private var liveOutboxTargets: Set<NormalizedRelayUrl> = emptySet()
@Volatile private var liveInboxTargets: Set<NormalizedRelayUrl> = emptySet()
@Volatile private var liveDmTargets: Set<NormalizedRelayUrl> = emptySet()
/** Per-destination-relay counters, updated atomically during a sync run. */
private val liveSentCountPerDestRelay = ConcurrentHashMap<NormalizedRelayUrl, AtomicInteger>()
private val liveAcceptedCountPerDestRelay = ConcurrentHashMap<NormalizedRelayUrl, AtomicInteger>()
private fun emitLiveSnapshot() {
val completions = synchronized(trackingLock) { trackingCompletions.toList() }
fun buildDestInfo(relays: Set<NormalizedRelayUrl>) =
relays.map { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = liveSentCountPerDestRelay[relay]?.get() ?: 0,
eventsAccepted = liveAcceptedCountPerDestRelay[relay]?.get() ?: 0,
)
}
_liveActivity.value = _liveActivity.value =
LiveSyncActivity( LiveSyncActivity(
recentCompletions = completions, runningRelays =
outboxTargets = buildDestInfo(liveOutboxTargets), runningRelays.associateWith {
inboxTargets = buildDestInfo(liveInboxTargets), SourceRelayInfo(
dmTargets = buildDestInfo(liveDmTargets), relay = it,
status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting),
eventsFound = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
completedRelays =
completedRelays.associateWith {
SourceRelayInfo(
relay = it,
status = MutableStateFlow(LiveSyncActivity.ConnectionStatus.Connecting),
eventsFound = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
outboxTargets =
liveOutboxTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
inboxTargets =
liveInboxTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
dmTargets =
liveDmTargets.associateWith { relay ->
LiveSyncActivity.DestinationRelayInfo(
relay = relay,
eventsSent = MutableStateFlow<Int>(0),
eventsAccepted = MutableStateFlow<Int>(0),
)
},
) )
} }
@@ -207,82 +269,42 @@ class EventSync(
fun start() { fun start() {
if (_syncState.value is SyncState.Running) return if (_syncState.value is SyncState.Running) return
synchronized(trackingLock) { trackingCompletions.clear() }
liveSentCountPerDestRelay.clear()
liveAcceptedCountPerDestRelay.clear()
_liveActivity.value = LiveSyncActivity() _liveActivity.value = LiveSyncActivity()
syncJob = syncJob =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runSync(startRelayIndex = 0, initialEventsSent = 0) runSync()
}
}
fun resume() {
val paused = _syncState.value as? SyncState.Paused ?: return
if (_syncState.value is SyncState.Running) return
syncJob =
scope.launch(Dispatchers.IO) {
runSync(
startRelayIndex = paused.nextRelayIndex,
initialEventsSent = paused.eventsSent,
)
} }
} }
fun cancel() { fun cancel() {
syncJob?.cancel() syncJob?.cancel()
val current = _syncState.value
_syncState.value =
if (current is SyncState.Running) {
SyncState.Paused(
nextRelayIndex = maxOf(0, current.relaysCompleted - MAX_CONCURRENT_RELAYS),
totalRelays = current.totalRelays,
eventsSent = current.eventsSent,
)
} else {
SyncState.Idle
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Sync logic // Sync logic
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
suspend fun runSync() {
private suspend fun runSync(
startRelayIndex: Int,
initialEventsSent: Int,
) {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val myPubKey = account.signer.pubKey
val allRelays = listOf("wss://relay.damus.io".normalizeRelayUrl()) _liveActivity.value = LiveSyncActivity()
/* val myPubKey = accountPubKey
account.cache.relayHints.relayDB
.keys()
.toList()
*/
if (allRelays.isEmpty()) { val relaysToProcess = relayDb()
if (relaysToProcess.isEmpty()) {
_syncState.value = _syncState.value =
SyncState.Error("No known relays found. Browse some content first to discover relays.") SyncState.Error("No known relays found. Browse some content first to discover relays.")
return return
} }
val relaysToProcess = if (startRelayIndex > 0) allRelays.drop(startRelayIndex) else allRelays val totalRelays = relaysToProcess.size
val totalRelays = allRelays.size
val outboxTargets = account.outboxRelays.flow.value val outboxTargets = outboxTargets()
val inboxTargets = account.nip65RelayList.inboxFlow.value val inboxTargets = inboxTargets()
val dmTargets = account.dmRelays.flow.value val dmTargets = dmTargets()
// Publish destination relays so the UI can show them before events arrive. val defaultFilters =
liveOutboxTargets = outboxTargets
liveInboxTargets = inboxTargets
liveDmTargets = dmTargets
emitLiveSnapshot()
val baseFilters =
buildList { buildList {
if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey))) if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey)))
if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) { if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) {
@@ -290,43 +312,125 @@ class EventSync(
} }
} }
if (baseFilters.isEmpty()) { if (defaultFilters.isEmpty()) {
_syncState.value = _syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.")
SyncState.Error("No outbox, inbox, or DM relays configured.")
return return
} }
// Thread-safe dedup sets and counters — relay workers run concurrently. val usersRelays = outboxTargets + inboxTargets + dmTargets
val outboxSent = ConcurrentHashMap.newKeySet<String>()
val inboxSent = ConcurrentHashMap.newKeySet<String>() val perRelayFilters =
val dmSent = ConcurrentHashMap.newKeySet<String>() relaysToProcess.associateWith {
val totalSent = AtomicLong(initialEventsSent.toLong()) if (it !in usersRelays) {
defaultFilters
} else {
buildList {
if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey)))
if (it !in inboxTargets && it !in dmTargets) {
add(Filter(tags = mapOf("p" to listOf(myPubKey))))
}
}
}
}
emitLiveSnapshot(
emptySet(),
emptySet(),
outboxTargets,
inboxTargets,
dmTargets,
)
// Thread-safe dedup sets — prevent the same event from being forwarded twice when
// multiple source relays return the same event concurrently.
val outboxDedup = ConcurrentHashMap.newKeySet<String>()
val inboxDedup = ConcurrentHashMap.newKeySet<String>()
val dmDedup = ConcurrentHashMap.newKeySet<String>()
val totalSent = AtomicLong(0)
// OK (true) tracking: maps each sent event ID to its source relay. // OK (true) tracking: maps each sent event ID to its source relay.
// The first OK true for an event atomically removes it from this map, // The first OK true for an event atomically removes it from this map,
// crediting the acceptance to the source relay and preventing double-counting. // crediting the acceptance to the source relay and preventing double-counting.
val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>() val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>()
val acceptedCountPerRelay = ConcurrentHashMap<NormalizedRelayUrl, AtomicInteger>() val acceptedCountPerSourceRelay = ConcurrentHashMap<NormalizedRelayUrl, AtomicInteger>()
val totalAccepted = AtomicLong(0) val totalAccepted = AtomicLong(0)
val okListener = val okListener =
object : IRelayClientListener { object : IRelayClientListener {
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
super.onCannotConnect(relay, errorMessage)
val currentStatus = liveActivity.value.runningRelays[relay.url]?.status
if (currentStatus?.value !is LiveSyncActivity.ConnectionStatus.Error) {
currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Error(errorMessage))
}
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
super.onSent(relay, cmdStr, cmd, success)
if (cmd is EventCmd) {
if (outboxDedup.contains(cmd.event.id)) {
liveActivity.value.outboxTargets[relay.url]
?.eventsSent
?.update { it + 1 }
}
if (inboxDedup.contains(cmd.event.id)) {
liveActivity.value.inboxTargets[relay.url]
?.eventsSent
?.update { it + 1 }
}
if (dmDedup.contains(cmd.event.id)) {
liveActivity.value.dmTargets[relay.url]
?.eventsSent
?.update { it + 1 }
}
} else if (cmd is ReqCmd) {
val currentStatus = liveActivity.value.runningRelays[relay.url]?.status
if (currentStatus?.value != LiveSyncActivity.ConnectionStatus.Querying) {
currentStatus?.tryEmit(LiveSyncActivity.ConnectionStatus.Querying)
}
}
}
override fun onIncomingMessage( override fun onIncomingMessage(
relay: IRelayClient, relay: IRelayClient,
msgStr: String, msgStr: String,
msg: Message, msg: Message,
) { ) {
if (msg is OkMessage && msg.success) { if (msg is OkMessage && msg.success && msg.message.isBlank()) {
// remove() is atomic: returns non-null only for the first OK per event. // remove() is atomic: returns non-null only for the first OK per event.
val sourceRelay = sourceRelayOfEvent.remove(msg.eventId) ?: return val sourceRelay = sourceRelayOfEvent.remove(msg.eventId) ?: return
acceptedCountPerRelay.getOrPut(sourceRelay) { AtomicInteger(0) }.incrementAndGet() acceptedCountPerSourceRelay.getOrPut(sourceRelay) { AtomicInteger(0) }.incrementAndGet()
totalAccepted.incrementAndGet() totalAccepted.incrementAndGet()
liveAcceptedCountPerDestRelay.getOrPut(relay.url) { AtomicInteger(0) }.incrementAndGet()
if (outboxDedup.contains(msg.eventId)) {
liveActivity.value.outboxTargets[relay.url]
?.eventsAccepted
?.update { it + 1 }
}
if (dmDedup.contains(msg.eventId)) {
liveActivity.value.dmTargets[relay.url]
?.eventsAccepted
?.update { it + 1 }
}
if (inboxDedup.contains(msg.eventId)) {
liveActivity.value.inboxTargets[relay.url]
?.eventsAccepted
?.update { it + 1 }
}
} }
} }
} }
val relaysCompleted = AtomicInteger(startRelayIndex) val relaysCompleted = AtomicInteger(0)
_syncState.value = _syncState.value =
SyncState.Running( SyncState.Running(
@@ -335,57 +439,83 @@ class EventSync(
eventsSent = totalSent.get().toInt(), eventsSent = totalSent.get().toInt(),
) )
account.client.subscribe(okListener) clientBuilder().use { client ->
client.subscribe(okListener)
try { try {
downloadFromPool( client.downloadFromPool(
relays = relaysToProcess, relays = relaysToProcess,
baseFilters = baseFilters, filters = perRelayFilters,
onEvent = { event, sourceRelay -> onEvent = { event, sourceRelay ->
if (event.pubKey == myPubKey && outboxTargets.isNotEmpty()) { val isMyEvent = event.pubKey == myPubKey
if (outboxSent.add(event.id)) { val mentionsMe = event.tags.isTaggedUser(myPubKey)
val isDmKind = event.kind == 4 || event.kind == 1059
val live = liveActivity.value
// Each routing rule is independent: an event can match more than one.
if (isMyEvent && outboxTargets.isNotEmpty()) {
if (outboxDedup.add(event.id)) {
sourceRelayOfEvent[event.id] = sourceRelay sourceRelayOfEvent[event.id] = sourceRelay
account.client.send(event, outboxTargets) client.send(event, outboxTargets)
totalSent.incrementAndGet() totalSent.incrementAndGet()
outboxTargets.forEach { dest ->
liveSentCountPerDestRelay.getOrPut(dest) { AtomicInteger(0) }.incrementAndGet() live.runningRelays[sourceRelay]?.eventsFound?.update { it + 1 }
live.completedRelays[sourceRelay]?.eventsFound?.update { it + 1 }
} }
} }
} if (mentionsMe && isDmKind && dmTargets.isNotEmpty()) {
val pTagsMe = event.tags.isTaggedUser(myPubKey) if (dmDedup.add(event.id)) {
if (pTagsMe) {
if (event.kind == 4 || event.kind == 1059) {
if (dmTargets.isNotEmpty() && dmSent.add(event.id)) {
sourceRelayOfEvent[event.id] = sourceRelay sourceRelayOfEvent[event.id] = sourceRelay
account.client.send(event, dmTargets) client.send(event, dmTargets)
totalSent.incrementAndGet() totalSent.incrementAndGet()
dmTargets.forEach { dest ->
liveSentCountPerDestRelay.getOrPut(dest) { AtomicInteger(0) }.incrementAndGet() live.runningRelays[sourceRelay]?.eventsFound?.update { it + 1 }
live.completedRelays[sourceRelay]?.eventsFound?.update { it + 1 }
} }
} }
} else { if (mentionsMe && !isDmKind && inboxTargets.isNotEmpty()) {
if (inboxTargets.isNotEmpty() && inboxSent.add(event.id)) { if (inboxDedup.add(event.id)) {
sourceRelayOfEvent[event.id] = sourceRelay sourceRelayOfEvent[event.id] = sourceRelay
account.client.send(event, inboxTargets) client.send(event, inboxTargets)
totalSent.incrementAndGet() totalSent.incrementAndGet()
inboxTargets.forEach { dest ->
liveSentCountPerDestRelay.getOrPut(dest) { AtomicInteger(0) }.incrementAndGet() live.runningRelays[sourceRelay]?.eventsFound?.update { it + 1 }
} live.completedRelays[sourceRelay]?.eventsFound?.update { it + 1 }
}
} }
} }
}, },
onRelayComplete = { relay, eventsFound -> onRelayStart = { relay ->
val eventsAccepted = acceptedCountPerRelay[relay]?.get() ?: 0 _liveActivity.update {
val info = LiveSyncActivity.CompletedRelayInfo(relay, eventsFound, eventsAccepted) it.copy(
synchronized(trackingLock) { runningRelays =
trackingCompletions.addFirst(info) it.runningRelays +
while (trackingCompletions.size > MAX_ACTIVITY_LOG) trackingCompletions.removeLast() Pair(relay, SourceRelayInfo(relay, LiveSyncActivity.ConnectionStatus.Connecting, 0, 0)),
)
} }
val completed = relaysCompleted.incrementAndGet() },
emitLiveSnapshot() onRelayComplete = { relay ->
_liveActivity.update {
val newCompleted = it.runningRelays[relay]
it.copy(
runningRelays = it.runningRelays.minus(relay),
completedRelays =
if (newCompleted != null) {
it.completedRelays.plus(relay to newCompleted)
} else {
it.completedRelays
},
)
}
val status = _liveActivity.value.completedRelays[relay]?.status
if (status?.value !is LiveSyncActivity.ConnectionStatus.Error) {
status?.tryEmit(LiveSyncActivity.ConnectionStatus.Completed)
}
_syncState.value = _syncState.value =
SyncState.Running( SyncState.Running(
relaysCompleted = completed, relaysCompleted = relaysCompleted.incrementAndGet(),
totalRelays = totalRelays, totalRelays = totalRelays,
eventsSent = totalSent.get().toInt(), eventsSent = totalSent.get().toInt(),
) )
@@ -399,11 +529,18 @@ class EventSync(
durationMs = System.currentTimeMillis() - startTime, durationMs = System.currentTimeMillis() - startTime,
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
_syncState.value =
SyncState.Done(
totalEventsSent = totalSent.get().toInt(),
totalEventsAccepted = totalAccepted.get().toInt(),
durationMs = System.currentTimeMillis() - startTime,
)
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
_syncState.value = SyncState.Error(e.message ?: "Unknown error") _syncState.value = SyncState.Error(e.message ?: "Unknown error")
} finally { } finally {
account.client.unsubscribe(okListener) client.unsubscribe(okListener)
}
} }
} }
@@ -414,11 +551,12 @@ class EventSync(
* *
* [onEvent] receives the event and the URL of the relay it came from. * [onEvent] receives the event and the URL of the relay it came from.
*/ */
private suspend fun downloadFromPool( private suspend fun INostrClient.downloadFromPool(
relays: List<NormalizedRelayUrl>, relays: List<NormalizedRelayUrl>,
baseFilters: List<Filter>, filters: Map<NormalizedRelayUrl, List<Filter>>,
onEvent: (Event, NormalizedRelayUrl) -> Unit, onEvent: (Event, NormalizedRelayUrl) -> Unit,
onRelayComplete: (NormalizedRelayUrl, Int) -> Unit, onRelayStart: (NormalizedRelayUrl) -> Unit,
onRelayComplete: (NormalizedRelayUrl) -> Unit,
) { ) {
val semaphore = Semaphore(MAX_CONCURRENT_RELAYS) val semaphore = Semaphore(MAX_CONCURRENT_RELAYS)
supervisorScope { supervisorScope {
@@ -427,8 +565,11 @@ class EventSync(
semaphore.acquire() semaphore.acquire()
launch { launch {
try { try {
val eventsFound = downloadFromRelay(relay, baseFilters) { event -> onEvent(event, relay) } onRelayStart(relay)
onRelayComplete(relay, eventsFound) filters[relay]?.let { filtersForRelay ->
downloadFromRelay(relay, filtersForRelay) { event -> onEvent(event, relay) }
} ?: 0
onRelayComplete(relay)
} finally { } finally {
semaphore.release() semaphore.release()
} }
@@ -443,9 +584,9 @@ class EventSync(
* *
* @return total number of events received across all pages. * @return total number of events received across all pages.
*/ */
private suspend fun downloadFromRelay( private suspend fun INostrClient.downloadFromRelay(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
baseFilters: List<Filter>, filters: List<Filter>,
onEvent: (Event) -> Unit, onEvent: (Event) -> Unit,
): Int = account.client.reqBypassingRelayLimits(relay, baseFilters, RELAY_TIMEOUT_MS, onEvent) ): Int = reqBypassingRelayLimits(relay, filters, RELAY_TIMEOUT_MS, onEvent)
} }
@@ -31,10 +31,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -56,6 +55,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable @Composable
@@ -74,11 +75,9 @@ fun EventSyncScreen(
nav: INav, nav: INav,
) { ) {
val syncViewModel = accountViewModel.eventSync val syncViewModel = accountViewModel.eventSync
val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
val syncState by syncViewModel.syncState.collectAsStateWithLifecycle() val syncState by syncViewModel.syncState.collectAsStateWithLifecycle()
val liveActivity by syncViewModel.liveActivity.collectAsStateWithLifecycle() val liveActivity by syncViewModel.liveActivity.collectAsStateWithLifecycle()
val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
var showMobileDataDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -88,18 +87,150 @@ fun EventSyncScreen(
) )
}, },
) { padding -> ) { padding ->
Column( Column(Modifier.fillMaxSize().padding(padding)) {
EventScreenBody(
syncState = syncState,
liveActivity = liveActivity,
isMobileOrMetered = isMobileOrMetered,
onStart = syncViewModel::start,
onCancel = syncViewModel::cancel,
)
}
}
}
@Composable
fun EventScreenBody(
syncState: EventSync.SyncState,
liveActivity: EventSync.LiveSyncActivity,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = {},
onCancel: () -> Unit = {},
) {
LazyColumn(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(horizontal = 16.dp),
.padding(horizontal = 16.dp) verticalArrangement = Arrangement.spacedBy(2.dp),
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
item {
// ---- Progress / Status area ---- // ---- Progress / Status area ----
when (val state = syncState) { when (syncState) {
is EventSync.SyncState.Idle -> { is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onStart)
is EventSync.SyncState.Running -> SyncProgressCard(state = syncState, onCancel)
is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onStart)
is EventSync.SyncState.Error -> ErrorCard(syncState.message, isMobileOrMetered, onStart)
}
}
// ---- Live relay activity (shown during and after sync) ----
if (liveActivity.outboxTargets.isNotEmpty() ||
liveActivity.inboxTargets.isNotEmpty() ||
liveActivity.dmTargets.isNotEmpty()
) {
item {
Spacer(Modifier.height(16.dp))
DestinationRelaysCard(activity = liveActivity)
}
}
val runningSize = liveActivity.runningRelays.size
if (runningSize > 0) {
item {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.event_sync_activity_log, runningSize),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(5.dp))
}
itemsIndexed(liveActivity.runningRelays.values.toList(), key = { _, item -> item.relay.url }) { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ActivityLogRow(info = info)
}
}
val completedSize = liveActivity.completedRelays.size
if (completedSize > 0) {
item {
Spacer(Modifier.height(16.dp))
Text(
text = stringRes(R.string.event_sync_activity_log_finished, completedSize),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(5.dp))
}
itemsIndexed(liveActivity.sortedCompletedRelays, key = { _, item -> item.relay.url }) { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ActivityLogRow(info = info)
}
}
}
}
@Composable
private fun StartSyncButton(
isMobileOrMetered: Boolean,
onClick: () -> Unit,
) {
var showMobileDataDialog by remember { mutableStateOf(false) }
Button(
onClick = {
if (isMobileOrMetered) {
showMobileDataDialog = true
} else {
onClick()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_start))
}
// ---- Mobile-data confirmation dialog ----
if (showMobileDataDialog) {
AlertDialog(
onDismissRequest = { showMobileDataDialog = false },
title = { Text(stringRes(R.string.event_sync_mobile_data_dialog_title)) },
text = { Text(stringRes(R.string.event_sync_wifi_warning)) },
confirmButton = {
Button(
onClick = {
showMobileDataDialog = false
onClick()
},
) {
Text(stringRes(R.string.event_sync_start_anyway))
}
},
dismissButton = {
TextButton(onClick = { showMobileDataDialog = false }) {
Text(stringRes(R.string.event_sync_cancel))
}
},
)
}
}
@Composable
private fun ExplanationCard(
isMobileOrMetered: Boolean,
onStart: () -> Unit,
) {
// ---- Explanation card ---- // ---- Explanation card ----
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -122,9 +253,7 @@ fun EventSyncScreen(
StepRow(number = "2", text = stringRes(R.string.event_sync_step2)) StepRow(number = "2", text = stringRes(R.string.event_sync_step2))
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
StepRow(number = "3", text = stringRes(R.string.event_sync_step3)) StepRow(number = "3", text = stringRes(R.string.event_sync_step3))
} Spacer(Modifier.height(10.dp))
}
// ---- WiFi warning ---- // ---- WiFi warning ----
if (isMobileOrMetered) { if (isMobileOrMetered) {
Card( Card(
@@ -141,119 +270,9 @@ fun EventSyncScreen(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
} }
Spacer(Modifier.height(10.dp))
} }
} StartSyncButton(isMobileOrMetered = isMobileOrMetered, onStart)
is EventSync.SyncState.Running -> {
SyncProgressCard(state = state)
}
is EventSync.SyncState.Paused -> {
PausedCard(state = state)
}
is EventSync.SyncState.Done -> {
DoneCard(state = state)
}
is EventSync.SyncState.Error -> {
ErrorCard(message = state.message)
}
}
// ---- Live relay activity (shown during and after sync) ----
if (liveActivity.outboxTargets.isNotEmpty() ||
liveActivity.inboxTargets.isNotEmpty() ||
liveActivity.dmTargets.isNotEmpty()
) {
DestinationRelaysCard(activity = liveActivity)
}
if (liveActivity.recentCompletions.isNotEmpty()) {
ActivityLogCard(completions = liveActivity.recentCompletions)
}
// ---- Action buttons ----
when (val state = syncState) {
is EventSync.SyncState.Idle,
is EventSync.SyncState.Done,
is EventSync.SyncState.Error,
-> {
Button(
onClick = {
if (isMobileOrMetered) {
showMobileDataDialog = true
} else {
syncViewModel.start()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_start))
}
}
is EventSync.SyncState.Paused -> {
Button(
onClick = {
if (isMobileOrMetered) {
showMobileDataDialog = true
} else {
syncViewModel.resume()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_resume))
}
OutlinedButton(
onClick = { syncViewModel.start() },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.event_sync_start_over))
}
}
is EventSync.SyncState.Running -> {
OutlinedButton(
onClick = { syncViewModel.cancel() },
modifier = Modifier.fillMaxWidth(),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.event_sync_pause))
}
}
}
// ---- Mobile-data confirmation dialog ----
if (showMobileDataDialog) {
val isPaused = syncState is EventSync.SyncState.Paused
AlertDialog(
onDismissRequest = { showMobileDataDialog = false },
title = { Text(stringRes(R.string.event_sync_mobile_data_dialog_title)) },
text = { Text(stringRes(R.string.event_sync_wifi_warning)) },
confirmButton = {
Button(
onClick = {
showMobileDataDialog = false
if (isPaused) syncViewModel.resume() else syncViewModel.start()
},
) {
Text(stringRes(R.string.event_sync_start_anyway))
}
},
dismissButton = {
TextButton(onClick = { showMobileDataDialog = false }) {
Text(stringRes(R.string.event_sync_cancel))
}
},
)
}
Spacer(Modifier.height(16.dp))
} }
} }
} }
@@ -263,7 +282,10 @@ fun EventSyncScreen(
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Composable @Composable
private fun SyncProgressCard(state: EventSync.SyncState.Running) { private fun SyncProgressCard(
state: EventSync.SyncState.Running,
onCancel: () -> Unit,
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
@@ -292,44 +314,27 @@ private fun SyncProgressCard(state: EventSync.SyncState.Running) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} Spacer(Modifier.height(10.dp))
} OutlinedButton(
} onClick = onCancel,
@Composable
private fun PausedCard(state: EventSync.SyncState.Paused) {
Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.error,
), ),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Text(stringRes(R.string.event_sync_cancel))
Text( }
text = stringRes(R.string.event_sync_paused_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.height(4.dp))
Text(
text =
stringRes(
R.string.event_sync_paused_body,
state.nextRelayIndex,
state.totalRelays,
state.eventsSent,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
} }
} }
} }
@Composable @Composable
private fun DoneCard(state: EventSync.SyncState.Done) { private fun DoneCard(
state: EventSync.SyncState.Done,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = { },
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -363,12 +368,18 @@ private fun DoneCard(state: EventSync.SyncState.Done) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
) )
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
} }
} }
} }
@Composable @Composable
private fun ErrorCard(message: String) { private fun ErrorCard(
message: String,
isMobileOrMetered: Boolean = false,
onStart: () -> Unit = {},
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
@@ -389,6 +400,8 @@ private fun ErrorCard(message: String) {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
) )
Spacer(Modifier.height(10.dp))
StartSyncButton(isMobileOrMetered, onStart)
} }
} }
} }
@@ -418,7 +431,7 @@ private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
DestinationSection( DestinationSection(
label = stringRes(R.string.event_sync_outbox_relays), label = stringRes(R.string.event_sync_outbox_relays),
relays = activity.outboxTargets, relays = activity.outboxTargets.values,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
@@ -429,7 +442,7 @@ private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
DestinationSection( DestinationSection(
label = stringRes(R.string.event_sync_inbox_relays), label = stringRes(R.string.event_sync_inbox_relays),
relays = activity.inboxTargets, relays = activity.inboxTargets.values,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
) )
} }
@@ -440,7 +453,7 @@ private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
DestinationSection( DestinationSection(
label = stringRes(R.string.event_sync_dm_relays), label = stringRes(R.string.event_sync_dm_relays),
relays = activity.dmTargets, relays = activity.dmTargets.values,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
) )
} }
@@ -451,7 +464,7 @@ private fun DestinationRelaysCard(activity: EventSync.LiveSyncActivity) {
@Composable @Composable
private fun DestinationSection( private fun DestinationSection(
label: String, label: String,
relays: List<EventSync.LiveSyncActivity.DestinationRelayInfo>, relays: Collection<EventSync.LiveSyncActivity.DestinationRelayInfo>,
color: androidx.compose.ui.graphics.Color, color: androidx.compose.ui.graphics.Color,
) { ) {
Text( Text(
@@ -478,6 +491,8 @@ private fun DestinationRelayRow(
info: EventSync.LiveSyncActivity.DestinationRelayInfo, info: EventSync.LiveSyncActivity.DestinationRelayInfo,
color: androidx.compose.ui.graphics.Color, color: androidx.compose.ui.graphics.Color,
) { ) {
val eventsSent by info.eventsSent.collectAsStateWithLifecycle()
Row( Row(
modifier = modifier =
Modifier Modifier
@@ -498,70 +513,39 @@ private fun DestinationRelayRow(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (info.eventsSent > 0) { if (eventsSent > 0) {
val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle()
Text( Text(
text = stringRes(R.string.event_sync_log_recv, formatCount(info.eventsSent)), text = stringRes(R.string.event_sync_log_sent, formatCount(eventsSent)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.3f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
) )
Spacer(Modifier.width(8.dp))
Text( Text(
text = stringRes(R.string.event_sync_log_new, formatCount(info.eventsAccepted)), text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = if (info.eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (info.eventsAccepted > 0) color else MaterialTheme.colorScheme.onSurfaceVariant, color = if (eventsAccepted > 0) color else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.3f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
) )
} }
} }
} }
/**
* Scrollable log of recently completed relays, newest at the top.
* Uses a fixed-height inner scroll area so it doesn't compete with the outer scroll.
*/
@Composable @Composable
private fun ActivityLogCard(completions: List<EventSync.LiveSyncActivity.CompletedRelayInfo>) { private fun ActivityLogRow(info: EventSync.LiveSyncActivity.SourceRelayInfo) {
Card( val eventsFound by info.eventsFound.collectAsStateWithLifecycle()
modifier = Modifier.fillMaxWidth(), val status by info.status.collectAsStateWithLifecycle()
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), val hasEvents = eventsFound > 0
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringRes(R.string.event_sync_activity_log, completions.size),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.height(260.dp)
.verticalScroll(rememberScrollState()),
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
completions.forEachIndexed { index, info ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ActivityLogRow(info = info)
}
}
}
}
}
}
@Composable
private fun ActivityLogRow(info: EventSync.LiveSyncActivity.CompletedRelayInfo) {
val hasEvents = info.eventsFound > 0
val dotColor = val dotColor =
if (hasEvents) { if (hasEvents) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
@@ -595,32 +579,51 @@ private fun ActivityLogRow(info: EventSync.LiveSyncActivity.CompletedRelayInfo)
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(0.6f),
) )
if (hasEvents) { if (hasEvents) {
val eventsAccepted by info.eventsAccepted.collectAsStateWithLifecycle()
Text( Text(
text = stringRes(R.string.event_sync_log_recv, formatCount(info.eventsFound)), text = stringRes(R.string.event_sync_log_recv, formatCount(eventsFound)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = textColor, color = textColor,
modifier = Modifier.weight(0.2f),
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.StartEllipsis,
) )
Spacer(Modifier.width(8.dp))
Text( Text(
text = stringRes(R.string.event_sync_log_new, formatCount(info.eventsAccepted)), text = stringRes(R.string.event_sync_log_new, formatCount(eventsAccepted)),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = if (info.eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (eventsAccepted > 0) FontWeight.SemiBold else FontWeight.Normal,
color = color =
if (info.eventsAccepted > 0) { if (eventsAccepted > 0) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}, },
maxLines = 1,
textAlign = TextAlign.End,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(0.2f),
) )
} else { } else {
val status by info.status.collectAsStateWithLifecycle()
Text( Text(
text = stringRes(R.string.event_sync_no_events), text =
when (status) {
EventSync.LiveSyncActivity.ConnectionStatus.Connecting -> stringRes(R.string.event_sync_status_connecting)
EventSync.LiveSyncActivity.ConnectionStatus.Querying -> stringRes(R.string.event_sync_status_downloading)
is EventSync.LiveSyncActivity.ConnectionStatus.Error -> (status as EventSync.LiveSyncActivity.ConnectionStatus.Error).msg.ifBlank { stringRes(R.string.event_sync_status_error) }
EventSync.LiveSyncActivity.ConnectionStatus.Completed -> stringRes(R.string.event_sync_status_completed)
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = textColor, color = textColor,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
modifier = Modifier.weight(0.45f),
) )
} }
} }
@@ -662,8 +665,7 @@ private fun NormalizedRelayUrl.displayHost(): String =
/** Formats a count with K/M suffix for large numbers. */ /** Formats a count with K/M suffix for large numbers. */
private fun formatCount(n: Int): String = private fun formatCount(n: Int): String =
when { when {
n >= 1_000_000 -> "${n / 1_000_000}M" n >= 1_000_000 -> "${n / 1_000}K"
n >= 1_000 -> "${n / 1_000}K"
else -> n.toString() else -> n.toString()
} }
@@ -671,19 +673,25 @@ private fun formatCount(n: Int): String =
// Preview data // Preview data
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private val previewRunning =
listOf(
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 1247, 891),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Querying, 892, 45),
EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nos2.lol"), EventSync.LiveSyncActivity.ConnectionStatus.Connecting, 0, 0),
)
private val previewCompletions = private val previewCompletions =
listOf( listOf(
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://relay.damus.io"), 1247, 891), EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://relay.nostr.band"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 3500, 3498),
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://nos.lol"), 892, 45), EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://slow.relay.example.com"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 0, 0),
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://relay.nostr.band"), 3500, 3498), EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://nostr.bitcoiner.social"), EventSync.LiveSyncActivity.ConnectionStatus.Completed, 15, 0),
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://slow.relay.example.com"), 0, 0), EventSync.LiveSyncActivity.SourceRelayInfo(NormalizedRelayUrl("wss://unreachable.relay.xyz"), EventSync.LiveSyncActivity.ConnectionStatus.Error("connection failed"), 0, 0),
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://nostr.bitcoiner.social"), 15, 0),
EventSync.LiveSyncActivity.CompletedRelayInfo(NormalizedRelayUrl("wss://unreachable.relay.xyz"), 0, 0),
) )
private val previewActivity = private val previewActivity =
EventSync.LiveSyncActivity( EventSync.LiveSyncActivity(
recentCompletions = previewCompletions, runningRelays = previewRunning,
completedRelays = previewCompletions,
outboxTargets = outboxTargets =
listOf( listOf(
EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://outbox.nostr.com"), 1247, 891), EventSync.LiveSyncActivity.DestinationRelayInfo(NormalizedRelayUrl("wss://outbox.nostr.com"), 1247, 891),
@@ -704,6 +712,22 @@ private val previewActivity =
// Previews // Previews
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Composable
@Preview
fun IdleCardWifiPreview() {
ThemeComparisonColumn {
ExplanationCard(false, {})
}
}
@Composable
@Preview
fun IdleCardMobilePreview() {
ThemeComparisonColumn {
ExplanationCard(true, {})
}
}
@Composable @Composable
@Preview @Preview
fun SyncProgressCardPreview() { fun SyncProgressCardPreview() {
@@ -715,21 +739,7 @@ fun SyncProgressCardPreview() {
totalRelays = 1024, totalRelays = 1024,
eventsSent = 4821, eventsSent = 4821,
), ),
) onCancel = {},
}
}
@Composable
@Preview
fun PausedCardPreview() {
ThemeComparisonColumn {
PausedCard(
state =
EventSync.SyncState.Paused(
nextRelayIndex = 260,
totalRelays = 1024,
eventsSent = 3200,
),
) )
} }
} }
@@ -766,9 +776,23 @@ fun DestinationRelaysCardPreview() {
} }
@Composable @Composable
@Preview @Preview(device = "spec:width=1800px,height=2340px,dpi=440")
fun ActivityLogCardPreview() { fun EventScreenBodyPreview() {
ThemeComparisonColumn { ThemeComparisonRow {
ActivityLogCard(completions = previewCompletions) EventScreenBody(
EventSync.SyncState.Idle,
EventSync.LiveSyncActivity(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()),
)
}
}
@Composable
@Preview(device = "spec:width=1800px,height=2340px,dpi=440")
fun EventScreenBody2Preview() {
ThemeComparisonRow {
EventScreenBody(
EventSync.SyncState.Running(1247, 1024, 4821),
previewActivity,
)
} }
} }
+9 -2
View File
@@ -1816,7 +1816,7 @@
<string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string> <string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string>
<string name="event_sync_open_button">Open Relay Sync…</string> <string name="event_sync_open_button">Open Relay Sync…</string>
<string name="event_sync_what_happens_title">What this does</string> <string name="event_sync_what_happens_title">What this does</string>
<string name="event_sync_what_happens_body">This tool scans every relay your app has seen and redistributes your events to the correct destinations. It may transfer a large amount of data, so it is best run on Wi-Fi.</string> <string name="event_sync_what_happens_body">This tool scans every relay your app has seen and redistributes your events to the correct destinations: </string>
<string name="event_sync_step1">Download all events you authored and send them to your outbox relays.</string> <string name="event_sync_step1">Download all events you authored and send them to your outbox relays.</string>
<string name="event_sync_step2">Download all events that mention you and send them to your inbox relays.</string> <string name="event_sync_step2">Download all events that mention you and send them to your inbox relays.</string>
<string name="event_sync_step3">Download all direct messages addressed to you and send them to your DM relays.</string> <string name="event_sync_step3">Download all direct messages addressed to you and send them to your DM relays.</string>
@@ -1841,7 +1841,9 @@
<string name="event_sync_outbox_relays">Outbox</string> <string name="event_sync_outbox_relays">Outbox</string>
<string name="event_sync_inbox_relays">Inbox</string> <string name="event_sync_inbox_relays">Inbox</string>
<string name="event_sync_dm_relays">DMs</string> <string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_activity_log">Activity (%1$d relays)</string> <string name="event_sync_activity_log">Currently Checking (%1$d relays)</string>
<string name="event_sync_activity_log_finished">Finished (%1$d relays)</string>
<string name="event_sync_log_sent">sent %1$s</string>
<string name="event_sync_log_recv">recv %1$s</string> <string name="event_sync_log_recv">recv %1$s</string>
<string name="event_sync_log_new">new %1$s</string> <string name="event_sync_log_new">new %1$s</string>
<string name="event_sync_no_events">no events</string> <string name="event_sync_no_events">no events</string>
@@ -1851,4 +1853,9 @@
<string name="profiles">profiles</string> <string name="profiles">profiles</string>
<string name="relay_settings_lower">relay settings</string> <string name="relay_settings_lower">relay settings</string>
<string name="last_seen">Last seen %1$s ago</string> <string name="last_seen">Last seen %1$s ago</string>
<string name="event_sync_status_connecting">Connecting</string>
<string name="event_sync_status_downloading">Downloading</string>
<string name="event_sync_status_error">Error</string>
<string name="event_sync_status_completed">Completed</string>
</resources> </resources>
@@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface INostrClient { interface INostrClient : AutoCloseable {
fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> fun connectedRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>> fun availableRelaysFlow(): StateFlow<Set<NormalizedRelayUrl>>
@@ -136,4 +136,6 @@ object EmptyNostrClient : INostrClient {
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap() override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet() override fun activeOutboxCache(url: NormalizedRelayUrl): Set<HexKey> = emptySet()
override fun close() {}
} }
@@ -79,7 +79,8 @@ class NostrClient(
private val websocketBuilder: WebsocketBuilder, private val websocketBuilder: WebsocketBuilder,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
) : INostrClient, ) : INostrClient,
IRelayClientListener { IRelayClientListener,
AutoCloseable {
private val relayPool: RelayPool = RelayPool(websocketBuilder, this) private val relayPool: RelayPool = RelayPool(websocketBuilder, this)
private val activeRequests: PoolRequests = PoolRequests() private val activeRequests: PoolRequests = PoolRequests()
@@ -326,4 +327,8 @@ class NostrClient(
override fun connectedRelaysFlow() = relayPool.connectedRelays override fun connectedRelaysFlow() = relayPool.connectedRelays
override fun availableRelaysFlow() = relayPool.availableRelays override fun availableRelaysFlow() = relayPool.availableRelays
override fun close() {
disconnect()
}
} }
@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@@ -63,6 +62,8 @@ suspend fun INostrClient.reqBypassingRelayLimits(
// Track how many matching events each filter has received so far. // Track how many matching events each filter has received so far.
val matchCountPerFilter = IntArray(filters.size) val matchCountPerFilter = IntArray(filters.size)
val subId = newSubId()
while (true) { while (true) {
coroutineContext.ensureActive() coroutineContext.ensureActive()
@@ -75,9 +76,7 @@ suspend fun INostrClient.reqBypassingRelayLimits(
if (remainingFilters.isEmpty()) break if (remainingFilters.isEmpty()) break
val eventChannel = Channel<Event>(UNLIMITED)
val doneChannel = Channel<Unit>(Channel.CONFLATED) val doneChannel = Channel<Unit>(Channel.CONFLATED)
val subId = newSubId()
val activeFilters = val activeFilters =
if (until == null) { if (until == null) {
@@ -86,51 +85,17 @@ suspend fun INostrClient.reqBypassingRelayLimits(
remainingFilters.map { it.copy(until = until) } remainingFilters.map { it.copy(until = until) }
} }
var pageCount = 0
var pageMinTs = Long.MAX_VALUE
val listener = val listener =
object : IRequestListener { object : IRequestListener {
override fun onEvent( override fun onEvent(
event: Event, event: Event,
isLive: Boolean, isLive: Boolean,
relay: NormalizedRelayUrl, relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
eventChannel.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
println("AABBCC $message")
doneChannel.trySend(Unit)
}
}
openReqSubscription(subId, mapOf(relay to activeFilters), listener)
withTimeoutOrNull(timeoutMs) { doneChannel.receive() }
close(subId)
eventChannel.close()
doneChannel.close()
var pageCount = 0
var pageMinTs = Long.MAX_VALUE
for (event in eventChannel) {
onEvent(event) onEvent(event)
pageCount++ pageCount++
if (event.createdAt < pageMinTs) pageMinTs = event.createdAt if (event.createdAt < pageMinTs) pageMinTs = event.createdAt
@@ -149,6 +114,39 @@ suspend fun INostrClient.reqBypassingRelayLimits(
} }
} }
override fun onEose(
relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onClosed(
message: String,
relayInner: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
override fun onCannotConnect(
relayInner: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
doneChannel.trySend(Unit)
}
}
openReqSubscription(subId, mapOf(relay to activeFilters), listener)
withTimeoutOrNull(timeoutMs) {
doneChannel.receive()
}
close(subId)
doneChannel.close()
if (pageCount == 0) break if (pageCount == 0) break
totalEvents += pageCount totalEvents += pageCount
@@ -67,7 +67,7 @@ suspend fun INostrClient.downloadFirstEvent(
subscriptionId: String = newSubId(), subscriptionId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>, filters: Map<NormalizedRelayUrl, List<Filter>>,
): Event? { ): Event? {
val resultChannel = Channel<Event>(UNLIMITED) val resultChannel = Channel<Event?>(UNLIMITED)
val listener = val listener =
object : IRequestListener { object : IRequestListener {
@@ -79,6 +79,29 @@ suspend fun INostrClient.downloadFirstEvent(
) { ) {
resultChannel.trySend(event) resultChannel.trySend(event)
} }
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
resultChannel.trySend(null)
}
} }
openReqSubscription(subscriptionId, filters, listener) openReqSubscription(subscriptionId, filters, listener)
@@ -28,26 +28,63 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlin.compareTo
class PoolEventOutbox { class PoolEventOutbox {
private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>() private var eventOutbox = mapOf<HexKey, PoolEventOutboxState>()
val relays = MutableStateFlow(setOf<NormalizedRelayUrl>()) val relays = MutableStateFlow(setOf<NormalizedRelayUrl>())
fun updateRelays() { fun needsToUpdateRelays(): Boolean {
val myRelays = mutableSetOf<NormalizedRelayUrl>() val currentRelays = relays.value
eventOutbox.values.forEach {
myRelays.addAll(it.relaysLeft()) var relaysToRemoveCounter = 0
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
relaysToRemoveCounter++
}
} }
if (relays.value != myRelays) { var relaysToAddCounter = 0
relays.tryEmit(myRelays) eventOutbox.values.forEach { outboxState ->
if (outboxState.relaysRemaining.any { it !in currentRelays }) {
relaysToAddCounter++
}
}
return relaysToRemoveCounter > 0 || relaysToAddCounter > 0
}
fun updateRelays() {
if (needsToUpdateRelays()) {
relays.update { currentRelays ->
val relaysToRemove = mutableSetOf<NormalizedRelayUrl>()
currentRelays.forEach { currentRelay ->
if (eventOutbox.values.none { currentRelay in it.relaysRemaining }) {
relaysToRemove.add(currentRelay)
}
}
val relaysToAdd = mutableSetOf<NormalizedRelayUrl>()
eventOutbox.values.forEach { outboxState ->
outboxState.relaysRemaining.forEach { relay ->
if (relay !in relaysToAdd && relay !in currentRelays) {
relaysToAdd.add(relay)
}
}
}
(currentRelays - relaysToRemove) + relaysToAdd
}
} }
} }
fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> { fun activeOutboxCacheFor(url: NormalizedRelayUrl): Set<HexKey> {
val myEvents = mutableSetOf<HexKey>() val myEvents = mutableSetOf<HexKey>()
eventOutbox.forEach { (eventId, outboxCache) -> eventOutbox.forEach { (eventId, outboxCache) ->
if (url in outboxCache.relays) { if (url in outboxCache.relaysRemaining) {
myEvents.add(eventId) myEvents.add(eventId)
} }
} }
@@ -72,7 +109,12 @@ class PoolEventOutbox {
id: HexKey, id: HexKey,
url: NormalizedRelayUrl, url: NormalizedRelayUrl,
) { ) {
eventOutbox[id]?.newTry(url) val waiting = eventOutbox[id]
waiting?.newTry(url)
if (waiting?.isDone() == true) {
eventOutbox = eventOutbox - waiting.event.id
updateRelays()
}
} }
fun newResponse( fun newResponse(
@@ -84,14 +126,12 @@ class PoolEventOutbox {
val waiting = eventOutbox[id] val waiting = eventOutbox[id]
if (waiting != null) { if (waiting != null) {
waiting.newResponse(url, success, message) waiting.newResponse(url, success, message)
clear() if (waiting.isDone()) {
} eventOutbox = eventOutbox - waiting.event.id
}
fun clear() {
eventOutbox = eventOutbox.filter { !it.value.isDone() }
updateRelays() updateRelays()
} }
}
}
// -------------------------- // --------------------------
// State management functions // State management functions
@@ -143,7 +183,7 @@ class PoolEventOutbox {
errorMessage: String, errorMessage: String,
) { ) {
eventOutbox.forEach { eventOutbox.forEach {
if (relay in it.value.relays) { if (relay in it.value.relaysRemaining) {
newResponse(it.key, relay, false, errorMessage) newResponse(it.key, relay, false, errorMessage)
} }
} }
@@ -26,35 +26,37 @@ import com.vitorpamplona.quartz.utils.TimeUtils
class PoolEventOutboxState( class PoolEventOutboxState(
val event: Event, val event: Event,
var relays: Set<NormalizedRelayUrl>, var relaysRemaining: Set<NormalizedRelayUrl>,
) { ) {
private var tries = mapOf<NormalizedRelayUrl, Tries>() private var failures = mapOf<NormalizedRelayUrl, Tries>()
fun updateRelays(newRelays: Set<NormalizedRelayUrl>) { fun updateRelays(newRelays: Set<NormalizedRelayUrl>) {
relays = newRelays relaysRemaining = newRelays
} }
fun isDone(url: NormalizedRelayUrl) = tries[url]?.isDone() ?: false fun isDone() = relaysRemaining.isEmpty()
fun isDone() = relays.all { isDone(it) } fun relaysLeft(): Set<NormalizedRelayUrl> = relaysRemaining
fun relaysLeft(): Set<NormalizedRelayUrl> = relays.filterTo(mutableSetOf()) { !isDone(it) } fun isSupposedToGo(url: NormalizedRelayUrl) = url in relaysRemaining
fun isSupposedToGo(url: NormalizedRelayUrl) = url in relays && !isDone(url)
fun forEachUnsentEvent( fun forEachUnsentEvent(
url: NormalizedRelayUrl, url: NormalizedRelayUrl,
run: (url: Event) -> Unit, run: (url: Event) -> Unit,
) = if (isSupposedToGo(url)) run(event) else null ) = if (isSupposedToGo(url)) run(event) else null
fun remainingRelays() = relays.filterTo(mutableSetOf(), ::isSupposedToGo) fun remainingRelays() = relaysRemaining
fun newTry(url: NormalizedRelayUrl) { fun newTry(url: NormalizedRelayUrl) {
val currentTries = tries[url] val currentTries = failures[url]
if (currentTries != null) { if (currentTries != null) {
currentTries.addTriedTime(TimeUtils.now()) currentTries.addTriedTime(TimeUtils.now())
if (currentTries.isDone()) {
relaysRemaining = relaysRemaining - url
failures = failures - url
}
} else { } else {
tries = tries + (url to Tries(listOf(TimeUtils.now()))) failures = failures + (url to Tries(listOf(TimeUtils.now())))
} }
} }
@@ -63,38 +65,44 @@ class PoolEventOutboxState(
success: Boolean, success: Boolean,
message: String, message: String,
) { ) {
val currentTries = tries[url] val currentTries = failures[url]
if (currentTries != null) { if (success || message.shouldDiscard()) {
currentTries.addResponse(Response(success, message)) relaysRemaining = relaysRemaining - url
failures = failures - url
} else { } else {
tries = tries + ( if (currentTries != null) {
currentTries.addResponse(message)
} else {
failures = failures + (
url to url to
Tries( Tries(
listOf(TimeUtils.now() - 1), listOf(TimeUtils.now() - 1),
listOf(Response(success, message)), listOf(message),
) )
) )
} }
} }
}
fun String.shouldDiscard() =
this.startsWith("replaced:") ||
this.startsWith("pow:") ||
this.startsWith("deleted:") ||
this.startsWith("invalid:")
// Tries 3 times // Tries 3 times
class Tries( class Tries(
var tries: List<Long> = listOf(), var tries: List<Long> = listOf(),
var responses: List<Response> = listOf(), var responses: List<String> = listOf(),
) { ) {
fun isDone() = responses.any { it.success } || responses.size > 2 || tries.size > 3 fun isDone() = responses.size > 2 || tries.size > 3
fun addResponse(r: Response) { fun addResponse(msg: String) {
responses += r responses += msg
} }
fun addTriedTime(tried: Long) { fun addTriedTime(tried: Long) {
tries += tried tries += tried
} }
} }
class Response(
val success: Boolean,
val message: String,
)
} }
@@ -42,6 +42,8 @@ class RelayStats(
override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat() override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat()
} }
fun snapshot(): Map<NormalizedRelayUrl, RelayStat> = innerCache.snapshot()
fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen") fun get(url: NormalizedRelayUrl): RelayStat = innerCache[url] ?: throw IllegalArgumentException("Should never happen")
private val clientListener = private val clientListener =
@@ -101,6 +101,8 @@ private class TrackingNostrClient : INostrClient {
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap() override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet() override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet()
override fun close() {}
} }
/** /**