feat: add EventSync screen for relay event synchronization
- Add EventSyncScreen composable with UI for event sync configuration - Add EventSyncViewModel for managing sync state and business logic - Add route and navigation entry for EventSync screen - Add entry point in AllRelayListScreen - Add string resources for EventSync UI https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
This commit is contained in:
@@ -108,6 +108,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
||||
@@ -194,6 +195,7 @@ fun AppNavigation(
|
||||
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
|
||||
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
|
||||
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditRelays : Route()
|
||||
|
||||
@Serializable object EventSync : Route()
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
+15
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.model.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel
|
||||
@@ -346,6 +347,20 @@ fun MappedAllRelayListView(
|
||||
)
|
||||
}
|
||||
renderConnectedItems(connectedRelays, connectedViewModel, accountViewModel, newNav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
R.string.event_sync_section,
|
||||
R.string.event_sync_section_explainer,
|
||||
SettingsCategorySpacingModifier,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { newNav.nav(Route.EventSync) },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.event_sync_open_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun EventSyncScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val syncViewModel: EventSyncViewModel =
|
||||
viewModel(factory = EventSyncViewModel.Factory(accountViewModel.account))
|
||||
|
||||
val syncState by syncViewModel.syncState.collectAsStateWithLifecycle()
|
||||
val isMobileOrMetered by accountViewModel.settings.isMobileOrMeteredConnection.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
caption = stringRes(R.string.event_sync_title),
|
||||
popBack = {
|
||||
syncViewModel.cancel()
|
||||
nav.popBack()
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ---- Explanation card ----
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_what_happens_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_what_happens_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
StepRow(number = "1", text = stringRes(R.string.event_sync_step1))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
StepRow(number = "2", text = stringRes(R.string.event_sync_step2))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
StepRow(number = "3", text = stringRes(R.string.event_sync_step3))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- WiFi warning ----
|
||||
if (isMobileOrMetered) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_wifi_warning),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Progress / Status area ----
|
||||
when (val state = syncState) {
|
||||
is EventSyncViewModel.SyncState.Idle -> {
|
||||
// No progress shown — just the start button below
|
||||
}
|
||||
|
||||
is EventSyncViewModel.SyncState.Running -> {
|
||||
SyncProgressCard(state = state)
|
||||
}
|
||||
|
||||
is EventSyncViewModel.SyncState.Done -> {
|
||||
DoneCard(state = state)
|
||||
}
|
||||
|
||||
is EventSyncViewModel.SyncState.Error -> {
|
||||
ErrorCard(message = state.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Action buttons ----
|
||||
when (syncState) {
|
||||
is EventSyncViewModel.SyncState.Idle,
|
||||
is EventSyncViewModel.SyncState.Done,
|
||||
is EventSyncViewModel.SyncState.Error,
|
||||
-> {
|
||||
Button(
|
||||
onClick = { syncViewModel.start() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isMobileOrMetered || syncState !is EventSyncViewModel.SyncState.Idle,
|
||||
) {
|
||||
Text(stringRes(R.string.event_sync_start))
|
||||
}
|
||||
if (isMobileOrMetered && syncState is EventSyncViewModel.SyncState.Idle) {
|
||||
OutlinedButton(
|
||||
onClick = { syncViewModel.start() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringRes(R.string.event_sync_start_anyway))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is EventSyncViewModel.SyncState.Running -> {
|
||||
OutlinedButton(
|
||||
onClick = { syncViewModel.cancel() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text(stringRes(R.string.event_sync_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRow(
|
||||
number: String,
|
||||
text: String,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = number + ".",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncProgressCard(state: EventSyncViewModel.SyncState.Running) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = phaseLabel(state.phase),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_relay_of, state.relayIndex, state.totalRelays),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = state.currentRelay,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Overall progress across all phases and relays
|
||||
val overallProgress =
|
||||
((state.phase - 1).toFloat() / state.phaseTotal +
|
||||
(state.relayIndex.toFloat() / state.totalRelays) / state.phaseTotal)
|
||||
.coerceIn(0f, 1f)
|
||||
LinearProgressIndicator(
|
||||
progress = { overallProgress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_events_sent, state.eventsSent),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DoneCard(state: EventSyncViewModel.SyncState.Done) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_done_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text =
|
||||
stringRes(
|
||||
R.string.event_sync_done_body,
|
||||
state.totalEventsSent,
|
||||
(state.durationMs / 1000).toInt(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(message: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.event_sync_error_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: Int): String =
|
||||
when (phase) {
|
||||
1 -> stringRes(R.string.event_sync_phase1)
|
||||
2 -> stringRes(R.string.event_sync_phase2)
|
||||
3 -> stringRes(R.string.event_sync_phase3)
|
||||
else -> ""
|
||||
}
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* 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.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Syncs the user's events across all known relays:
|
||||
* 1. Downloads all events authored by the user and sends them to their outbox relays.
|
||||
* 2. Downloads all events that p-tag the user and sends them to their inbox relays.
|
||||
* 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays.
|
||||
*
|
||||
* The procedure iterates over every relay URL in LocalCache.relayHints.relayDB.
|
||||
* Events are deduplicated per destination to avoid redundant sends.
|
||||
*/
|
||||
class EventSyncViewModel(
|
||||
val account: Account,
|
||||
) : ViewModel() {
|
||||
sealed class SyncState {
|
||||
object Idle : SyncState()
|
||||
|
||||
data class Running(
|
||||
val phase: Int,
|
||||
val phaseTotal: Int,
|
||||
val currentRelay: String,
|
||||
val relayIndex: Int,
|
||||
val totalRelays: Int,
|
||||
val eventsSent: Int,
|
||||
) : SyncState()
|
||||
|
||||
data class Done(
|
||||
val totalEventsSent: Int,
|
||||
val durationMs: Long,
|
||||
) : SyncState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : SyncState()
|
||||
}
|
||||
|
||||
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
|
||||
val syncState: StateFlow<SyncState> = _syncState
|
||||
|
||||
private var syncJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (_syncState.value is SyncState.Running) return
|
||||
syncJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
syncJob?.cancel()
|
||||
_syncState.value = SyncState.Idle
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val myPubKey = account.signer.pubKey
|
||||
val allRelays = account.cache.relayHints.relayDB.keys().toList()
|
||||
|
||||
if (allRelays.isEmpty()) {
|
||||
_syncState.value = SyncState.Error("No known relays found. Browse some content first to discover relays.")
|
||||
return
|
||||
}
|
||||
|
||||
val outboxTargets = account.outboxRelays.flow.value
|
||||
val inboxTargets = account.nip65RelayList.inboxFlow.value
|
||||
val dmTargets = account.dmRelays.flow.value
|
||||
|
||||
// Deduplication sets — one per destination category
|
||||
val outboxSent = HashSet<String>(1024)
|
||||
val inboxSent = HashSet<String>(1024)
|
||||
val dmSent = HashSet<String>(1024)
|
||||
|
||||
var totalSent = 0
|
||||
val totalPhases = 3
|
||||
|
||||
try {
|
||||
// -------------------------
|
||||
// Phase 1: Author's events → Outbox relays
|
||||
// -------------------------
|
||||
if (outboxTargets.isNotEmpty()) {
|
||||
allRelays.forEachIndexed { index, relay ->
|
||||
if (!isActive) return
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Running(
|
||||
phase = 1,
|
||||
phaseTotal = totalPhases,
|
||||
currentRelay = relay.url,
|
||||
relayIndex = index + 1,
|
||||
totalRelays = allRelays.size,
|
||||
eventsSent = totalSent,
|
||||
)
|
||||
|
||||
downloadFromRelay(
|
||||
relay = relay,
|
||||
filter = Filter(authors = listOf(myPubKey)),
|
||||
) { event ->
|
||||
if (outboxSent.add(event.id)) {
|
||||
account.client.send(event, outboxTargets)
|
||||
totalSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Phase 2: P-tagged (non-DM) events → Inbox relays
|
||||
// -------------------------
|
||||
if (inboxTargets.isNotEmpty()) {
|
||||
allRelays.forEachIndexed { index, relay ->
|
||||
if (!isActive) return
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Running(
|
||||
phase = 2,
|
||||
phaseTotal = totalPhases,
|
||||
currentRelay = relay.url,
|
||||
relayIndex = index + 1,
|
||||
totalRelays = allRelays.size,
|
||||
eventsSent = totalSent,
|
||||
)
|
||||
|
||||
downloadFromRelay(
|
||||
relay = relay,
|
||||
filter = Filter(tags = mapOf("p" to listOf(myPubKey))),
|
||||
) { event ->
|
||||
// Skip DM kinds — handled in phase 3
|
||||
if (event.kind != 4 && event.kind != 1059) {
|
||||
if (inboxSent.add(event.id)) {
|
||||
account.client.send(event, inboxTargets)
|
||||
totalSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Phase 3: DM events (kind 4 & 1059) → DM relays
|
||||
// -------------------------
|
||||
if (dmTargets.isNotEmpty()) {
|
||||
allRelays.forEachIndexed { index, relay ->
|
||||
if (!isActive) return
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Running(
|
||||
phase = 3,
|
||||
phaseTotal = totalPhases,
|
||||
currentRelay = relay.url,
|
||||
relayIndex = index + 1,
|
||||
totalRelays = allRelays.size,
|
||||
eventsSent = totalSent,
|
||||
)
|
||||
|
||||
downloadFromRelay(
|
||||
relay = relay,
|
||||
filter = Filter(kinds = listOf(4, 1059), tags = mapOf("p" to listOf(myPubKey))),
|
||||
) { event ->
|
||||
if (dmSent.add(event.id)) {
|
||||
account.client.send(event, dmTargets)
|
||||
totalSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_syncState.value =
|
||||
SyncState.Done(
|
||||
totalEventsSent = totalSent,
|
||||
durationMs = System.currentTimeMillis() - startTime,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (isActive) {
|
||||
_syncState.value = SyncState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a REQ subscription on [relay] with [filter], delivers every event to [onEvent],
|
||||
* and suspends until the relay sends EOSE (or 90 seconds pass, whichever comes first).
|
||||
*/
|
||||
private suspend fun downloadFromRelay(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: Filter,
|
||||
onEvent: (Event) -> Unit,
|
||||
) {
|
||||
val done = Channel<Unit>(Channel.CONFLATED)
|
||||
val subId = newSubId()
|
||||
|
||||
val listener =
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
onEvent(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
done.trySend(Unit)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
done.trySend(Unit)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
done.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
account.client.openReqSubscription(subId, mapOf(relay to listOf(filter)), listener)
|
||||
|
||||
withTimeoutOrNull(90_000L) {
|
||||
done.receive()
|
||||
}
|
||||
|
||||
account.client.close(subId)
|
||||
done.close()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = EventSyncViewModel(account) as T
|
||||
}
|
||||
}
|
||||
@@ -1748,4 +1748,27 @@
|
||||
<string name="select_all">Select All</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="namecoin_settings">Namecoin Settings</string>
|
||||
|
||||
<!-- Event Sync Screen -->
|
||||
<string name="event_sync_title">Relay Sync</string>
|
||||
<string name="event_sync_section">Relay Sync</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_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_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_step3">Download all direct messages addressed to you and send them to your DM relays.</string>
|
||||
<string name="event_sync_wifi_warning">⚠ You appear to be on a metered or mobile connection. This operation can transfer a very large amount of data. Connect to Wi-Fi before starting.</string>
|
||||
<string name="event_sync_start">Start Sync</string>
|
||||
<string name="event_sync_start_anyway">Start Anyway (mobile data)</string>
|
||||
<string name="event_sync_cancel">Cancel</string>
|
||||
<string name="event_sync_phase1">Phase 1 of 3 — Syncing your posts to outbox relays</string>
|
||||
<string name="event_sync_phase2">Phase 2 of 3 — Syncing mentions to inbox relays</string>
|
||||
<string name="event_sync_phase3">Phase 3 of 3 — Syncing direct messages to DM relays</string>
|
||||
<string name="event_sync_relay_of">Relay %1$d of %2$d</string>
|
||||
<string name="event_sync_events_sent">Events redistributed: %1$d</string>
|
||||
<string name="event_sync_done_title">Sync complete</string>
|
||||
<string name="event_sync_done_body">Redistributed %1$d events in %2$d seconds.</string>
|
||||
<string name="event_sync_error_title">Sync error</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user