From 40c45fb3a13842db379e6966490e18d9c383a9b4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 22 Mar 2026 11:18:28 -0400 Subject: [PATCH] Adds a RelaySync filter by since/until dates. --- .../relays/eventsync/EventSyncTest.kt | 39 ++++ .../loggedIn/relays/eventsync/EventSync.kt | 44 ++++- .../relays/eventsync/EventSyncScreen.kt | 173 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 8 + 4 files changed, 245 insertions(+), 19 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt index 387d40572..f892f6740 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -23,9 +23,13 @@ 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.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -38,6 +42,7 @@ import org.junit.runner.RunWith class EventSyncTest { companion object { val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl() + val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val rootClient = @@ -70,4 +75,38 @@ class EventSyncTest { sync.runSync() } + + @Test + fun testFiatjafSync() = + runBlocking { + val sync = + EventSync( + accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + relayDb = { listOf(fiatjaf) }, + outboxTargets = { setOf(vitor) }, + inboxTargets = { setOf(vitor) }, + dmTargets = { setOf(vitor) }, + clientBuilder = { + val newClient = NostrClient(socketBuilder, appScope) + val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false) + + val signer = NostrSignerInternal(KeyPair()) + + // Authenticates with relays. + val auth = + RelayAuthenticator( + newClient, + appScope, + signWithAllLoggedInUsers = { authTemplate -> + listOf(signer.sign(authTemplate)) + }, + ) + + newClient + }, + scope = appScope, + ) + + sync.runSync() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt index e83d70d4d..a03e6d619 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSync.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync 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.Companion.RELAY_TIMEOUT_MS 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.HexKey @@ -106,14 +107,18 @@ class EventSync( val eventsSent: MutableStateFlow, val eventsReceived: MutableStateFlow, val eventsAccepted: MutableStateFlow, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, ) : SyncState() { - constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int) : + constructor(relaysCompleted: Int, totalRelays: Int, eventsSent: Int, eventsReceived: Int, eventsAccepted: Int, sinceFilter: Long? = null, untilFilter: Long? = null) : this( relaysCompleted = MutableStateFlow(relaysCompleted), totalRelays = MutableStateFlow(totalRelays), eventsSent = MutableStateFlow(eventsSent), eventsReceived = MutableStateFlow(eventsReceived), eventsAccepted = MutableStateFlow(eventsAccepted), + sinceFilter = sinceFilter, + untilFilter = untilFilter, ) } @@ -122,10 +127,14 @@ class EventSync( val totalEventsSent: Int, val totalEventsAccepted: Int, val durationMs: Long, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, ) : SyncState() data class Error( val message: String, + val sinceFilter: Long? = null, + val untilFilter: Long? = null, ) : SyncState() } @@ -282,14 +291,20 @@ class EventSync( // Control functions // ------------------------------------------------------------------------- + /** User-selected date range for the sync filter (epoch seconds). */ + val sinceSecs = MutableStateFlow(null) + val untilSecs = MutableStateFlow(null) + private var syncJob: Job? = null fun start() { if (_syncState.value is SyncState.Running) return _liveActivity.value = LiveSyncActivity() + val capturedSince = sinceSecs.value + val capturedUntil = untilSecs.value syncJob = scope.launch(Dispatchers.IO) { - runSync() + runSync(capturedSince, capturedUntil) } } @@ -300,7 +315,10 @@ class EventSync( // ------------------------------------------------------------------------- // Sync logic // ------------------------------------------------------------------------- - suspend fun runSync() { + suspend fun runSync( + filterSince: Long? = null, + filterUntil: Long? = null, + ) { val startTime = System.currentTimeMillis() _liveActivity.value = LiveSyncActivity() @@ -311,7 +329,7 @@ class EventSync( if (relaysToProcess.isEmpty()) { _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.", filterSince, filterUntil) return } @@ -323,14 +341,14 @@ class EventSync( val defaultFilters = buildList { - if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey))) + if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil)) if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) { - add(Filter(tags = mapOf("p" to listOf(myPubKey)))) + add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil)) } } if (defaultFilters.isEmpty()) { - _syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.") + _syncState.value = SyncState.Error("No outbox, inbox, or DM relays configured.", filterSince, filterUntil) return } @@ -342,9 +360,9 @@ class EventSync( defaultFilters } else { buildList { - if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey))) + if (it !in outboxTargets) add(Filter(authors = listOf(myPubKey), since = filterSince, until = filterUntil)) if (it !in inboxTargets && it !in dmTargets) { - add(Filter(tags = mapOf("p" to listOf(myPubKey)))) + add(Filter(tags = mapOf("p" to listOf(myPubKey)), since = filterSince, until = filterUntil)) } } } @@ -373,6 +391,8 @@ class EventSync( eventsSent = 0, eventsReceived = 0, eventsAccepted = 0, + sinceFilter = filterSince, + untilFilter = filterUntil, ) val okListener = @@ -563,6 +583,8 @@ class EventSync( totalEventsSent = runningState.eventsSent.value, totalEventsAccepted = runningState.eventsAccepted.value, durationMs = System.currentTimeMillis() - startTime, + sinceFilter = filterSince, + untilFilter = filterUntil, ) } catch (e: Exception) { _syncState.value = @@ -571,10 +593,12 @@ class EventSync( totalEventsSent = runningState.eventsSent.value, totalEventsAccepted = runningState.eventsAccepted.value, durationMs = System.currentTimeMillis() - startTime, + sinceFilter = filterSince, + untilFilter = filterUntil, ) if (e is CancellationException) throw e - _syncState.value = SyncState.Error(e.message ?: "Unknown error") + _syncState.value = SyncState.Error(e.message ?: "Unknown error", filterSince, filterUntil) } finally { client.unsubscribe(okListener) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt index bcb958755..77b2cafdf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,10 +44,12 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RangeSlider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,6 +57,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -70,6 +75,9 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun EventSyncScreen( @@ -94,6 +102,8 @@ fun EventSyncScreen( syncState = syncState, liveActivity = liveActivity, isMobileOrMetered = isMobileOrMetered, + onSinceChanged = { syncViewModel.sinceSecs.value = it }, + onUntilChanged = { syncViewModel.untilSecs.value = it }, onStart = syncViewModel::start, onCancel = syncViewModel::cancel, ) @@ -106,6 +116,8 @@ fun EventScreenBody( syncState: EventSync.SyncState, liveActivity: EventSync.LiveSyncActivity, isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, onStart: () -> Unit = {}, onCancel: () -> Unit = {}, ) { @@ -119,10 +131,10 @@ fun EventScreenBody( item { // ---- Progress / Status area ---- when (syncState) { - is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onStart) + is EventSync.SyncState.Idle -> ExplanationCard(isMobileOrMetered, onSinceChanged, onUntilChanged, 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) + is EventSync.SyncState.Done -> DoneCard(state = syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart) + is EventSync.SyncState.Error -> ErrorCard(syncState, isMobileOrMetered, onSinceChanged, onUntilChanged, onStart) } } @@ -231,7 +243,9 @@ private fun StartSyncButton( @Composable private fun ExplanationCard( isMobileOrMetered: Boolean, - onStart: () -> Unit, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, + onStart: () -> Unit = {}, ) { // ---- Explanation card ---- Card( @@ -256,6 +270,7 @@ private fun ExplanationCard( Spacer(Modifier.height(4.dp)) StepRow(number = "3", text = stringRes(R.string.event_sync_step3)) Spacer(Modifier.height(10.dp)) + // ---- WiFi warning ---- if (isMobileOrMetered) { Card( @@ -274,6 +289,12 @@ private fun ExplanationCard( } Spacer(Modifier.height(10.dp)) } + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) + Spacer(Modifier.height(14.dp)) StartSyncButton(isMobileOrMetered = isMobileOrMetered, onStart) } } @@ -352,6 +373,8 @@ private fun RelayStatement(state: EventSync.SyncState.Running) { private fun DoneCard( state: EventSync.SyncState.Done, isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, onStart: () -> Unit = { }, ) { Card( @@ -387,6 +410,16 @@ private fun DoneCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), ) + Spacer(Modifier.height(14.dp)) + + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.primaryContainer, + onSinceStartValue = state.sinceFilter, + onUntilStartValue = state.untilFilter, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) + Spacer(Modifier.height(10.dp)) StartSyncButton(isMobileOrMetered, onStart) } @@ -395,8 +428,10 @@ private fun DoneCard( @Composable private fun ErrorCard( - message: String, + state: EventSync.SyncState.Error, isMobileOrMetered: Boolean = false, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, onStart: () -> Unit = {}, ) { Card( @@ -415,10 +450,19 @@ private fun ErrorCard( ) Spacer(Modifier.height(4.dp)) Text( - text = message, + text = state.message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer, ) + Spacer(Modifier.height(14.dp)) + + DateRangeFilterCard( + backgroundColor = MaterialTheme.colorScheme.errorContainer, + onSinceStartValue = state.sinceFilter, + onUntilStartValue = state.untilFilter, + onSinceChanged = onSinceChanged, + onUntilChanged = onUntilChanged, + ) Spacer(Modifier.height(10.dp)) StartSyncButton(isMobileOrMetered, onStart) } @@ -660,6 +704,117 @@ private fun ActivityLogRow(info: EventSync.LiveSyncActivity.SourceRelayInfo) { } } +/** Oldest possible "since" — roughly 2020-01-01 in epoch seconds. */ +private const val EPOCH_2023 = 1_672_549_201L + +@Composable +private fun DateRangeFilterCard( + backgroundColor: Color, + onSinceStartValue: Long? = null, + onUntilStartValue: Long? = null, + onSinceChanged: (Long?) -> Unit = {}, + onUntilChanged: (Long?) -> Unit = {}, +) { + val now = remember { System.currentTimeMillis() / 1000 } + + // Slider range: 0f = EPOCH_2020, 1f = now + val range = (now - EPOCH_2023).toFloat() + + // Initialise from lastSyncTimestamp when available, otherwise full range + var sliderValues by remember(onSinceStartValue, onUntilStartValue) { + val initialSince = + if (onSinceStartValue != null) { + ((onSinceStartValue - EPOCH_2023) / range).coerceIn(0f, 1f) + } else { + 0f + } + + val finalSince = + if (onUntilStartValue != null) { + ((onUntilStartValue - EPOCH_2023) / range).coerceIn(0f, 1f) + } else { + 1f + } + mutableStateOf(initialSince..finalSince) + } + + // Derive actual timestamps + val sinceEpoch = (EPOCH_2023 + (sliderValues.start * range).toLong()) + val untilEpoch = (EPOCH_2023 + (sliderValues.endInclusive * range).toLong()) + + // Fire callbacks + val effectiveSince = if (sliderValues.start <= 0.001f) null else sinceEpoch + val effectiveUntil = if (sliderValues.endInclusive >= 0.999f) null else untilEpoch + + // Notify parent on each change + LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) } + LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) } + + Card( + modifier = + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringRes(R.string.event_sync_date_filter_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(Modifier.height(12.dp)) + + // Labels for current selection + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = + if (effectiveSince == null) { + stringRes(R.string.event_sync_date_filter_all_time) + } else { + stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = + if (effectiveUntil == null) { + stringRes(R.string.event_sync_date_filter_now) + } else { + stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Box( + // block left drawer and back gestures when sliding + Modifier.pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + } + }, + ) { + RangeSlider( + value = sliderValues, + onValueChange = { sliderValues = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +private fun formatEpochDate(epochSecs: Long): String { + val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + return sdf.format(Date(epochSecs * 1000)) +} + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -747,7 +902,7 @@ private val previewActivity = @Preview fun IdleCardWifiPreview() { ThemeComparisonColumn { - ExplanationCard(false, {}) + ExplanationCard(false) } } @@ -755,7 +910,7 @@ fun IdleCardWifiPreview() { @Preview fun IdleCardMobilePreview() { ThemeComparisonColumn { - ExplanationCard(true, {}) + ExplanationCard(true) } } @@ -797,7 +952,7 @@ fun DoneCardPreview() { @Preview fun ErrorCardPreview() { ThemeComparisonColumn { - ErrorCard(message = "No outbox, inbox, or DM relays configured.") + ErrorCard(state = EventSync.SyncState.Error("No outbox, inbox, or DM relays configured.")) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 21af8fa53..78c36fdbb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1912,4 +1912,12 @@ Proficient in verifying kinds: %1$s Attests to Requests attestation to + + Date Range + Since + Until + Now + All time + Last sync: %1$s + Since Last Sync