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.relay.RelayFeedScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
|
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.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.search.SearchScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
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.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
|
||||||
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
|
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
|
||||||
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object EditRelays : Route()
|
@Serializable object EditRelays : Route()
|
||||||
|
|
||||||
|
@Serializable object EventSync : Route()
|
||||||
|
|
||||||
@Serializable object EditMediaServers : Route()
|
@Serializable object EditMediaServers : Route()
|
||||||
|
|
||||||
@Serializable object UpdateReactionType : 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.DefaultIndexerRelayList
|
||||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.navigation.topbars.SavingTopBar
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel
|
||||||
@@ -346,6 +347,20 @@ fun MappedAllRelayListView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
renderConnectedItems(connectedRelays, connectedViewModel, accountViewModel, newNav)
|
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="select_all">Select All</string>
|
||||||
<string name="uptime">%1$d%% uptime</string>
|
<string name="uptime">%1$d%% uptime</string>
|
||||||
<string name="namecoin_settings">Namecoin Settings</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user