diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 27b0c5717..350736804 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2334,6 +2334,29 @@ class Account( client.publish(outbound.signedEvent, groupRelays) } + /** + * User-initiated "nuclear" reset for the Marmot subsystem. + * + * Wipes every MLS group, every retained epoch secret, every persisted + * KeyPackage bundle, every relay subscription and every in-memory + * chatroom associated with this account. Does NOT broadcast any + * SelfRemove/leave commits to peers — if the user is in this flow at + * all, local state may already be unusable and a graceful leave is + * probably not possible. Peers will see the user as unresponsive until + * their next commit evicts the stale leaf. + * + * A fresh KeyPackage will be republished lazily on the next + * `ensureMarmotKeyPackagePublished` cycle, so the account remains + * reachable for future group invites. + */ + suspend fun resetMarmotState() { + Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${signer.pubKey.take(8)}…" } + marmotManager?.resetAllState() + for (groupId in marmotGroupList.allGroupIds()) { + marmotGroupList.removeGroup(groupId) + } + } + /** * Remove a member from a Marmot MLS group. * Publishes the commit GroupEvent to group relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 90c454594..f6989dd7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1536,6 +1536,10 @@ class AccountViewModel( account.leaveMarmotGroup(nostrGroupId, relays) } + suspend fun resetMarmotState() { + account.resetMarmotState() + } + /** * Get the relay set for a Marmot group from MLS GroupContext metadata. * Falls back to outbox relays if the group has no configured relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index acc80fe97..a61e3d9c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,6 +41,7 @@ import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Settings @@ -47,17 +49,29 @@ import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.VideoSettings +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -71,6 +85,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Preview @Composable @@ -89,6 +105,10 @@ fun AllSettingsScreen( nav: INav, ) { val tint = MaterialTheme.colorScheme.onBackground + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showResetMarmotDialog by remember { mutableStateOf(false) } + var isResettingMarmot by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -249,8 +269,85 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.VanishEvents) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.reset_marmot_state, + icon = Icons.Outlined.RestartAlt, + tint = tint, + onClick = { if (!isResettingMarmot) showResetMarmotDialog = true }, + ) } } + + if (showResetMarmotDialog) { + ResetMarmotStateDialog( + onConfirm = { + showResetMarmotDialog = false + isResettingMarmot = true + scope.launch(Dispatchers.IO) { + val successMessage = stringRes(context, R.string.reset_marmot_success) + try { + accountViewModel.resetMarmotState() + launch(Dispatchers.Main) { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + val failureMessage = + stringRes(context, R.string.reset_marmot_failure, e.message ?: "") + launch(Dispatchers.Main) { + Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show() + } + } finally { + isResettingMarmot = false + } + } + }, + onDismiss = { showResetMarmotDialog = false }, + ) + } +} + +@Composable +private fun ResetMarmotStateDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text( + text = stringRes(R.string.reset_marmot_confirm_title), + textAlign = TextAlign.Center, + ) + }, + text = { + Text(text = stringRes(R.string.reset_marmot_confirm_body)) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringRes(R.string.reset_marmot_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d9eb8d736..4bbb8f3e0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1122,6 +1122,12 @@ Account Settings App Settings Danger Zone + Reset Marmot State + Reset Marmot State? + This will permanently delete every Marmot group chat, message history, and MLS key on this device for the current account. Peers will not be notified and may still see you in groups until their next commit. This cannot be undone. A new KeyPackage will be published the next time the app syncs. + Reset + Marmot state reset. + Failed to reset Marmot state: %1$s Always Wifi-only Unmetered WiFi diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index c1fc14d85..aa3a561ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -295,6 +295,36 @@ class MarmotManager( return nostrGroupId } + /** + * Nuke all local Marmot state — every MLS group, every retained epoch + * secret, every persisted KeyPackage bundle, and every relay + * subscription. Does NOT publish any leave/SelfRemove commits: the + * reset path is specifically for recovering from corrupted or + * unrecoverable local state where graceful teardown may be impossible. + * + * The caller is responsible for wiping any higher-level in-memory + * structures (e.g. `MarmotGroupList`) and for re-publishing a fresh + * KeyPackage once the reset completes, if the account is still active. + */ + suspend fun resetAllState() { + Log.w("MarmotManager") { "resetAllState(): wiping all Marmot local state for ${signer.pubKey.take(8)}…" } + try { + groupManager.clearAllState() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): groupManager.clearAllState failed: ${e.message}") + } + try { + keyPackageRotationManager.clearAllState() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): keyPackageRotationManager.clearAllState failed: ${e.message}") + } + try { + subscriptionManager.clear() + } catch (e: Exception) { + Log.w("MarmotManager", "resetAllState(): subscriptionManager.clear failed: ${e.message}") + } + } + /** * Leave a group. * Returns proposal bytes to publish (as a GroupEvent). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index 972b66274..8cbbef310 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -157,6 +157,26 @@ class KeyPackageRotationManager( } } + /** + * Wipe all in-memory KeyPackage bundles plus the persisted snapshot. + * Intended for user-initiated Marmot resets — the caller is responsible + * for re-publishing KeyPackages afterwards (e.g. via + * `Account.ensureMarmotKeyPackagePublished`). + */ + suspend fun clearAllState() = + mutex.withLock { + activeBundles.clear() + pendingRotations.clear() + eventIdToSlot.clear() + namedSlotDTags.clear() + val store = store ?: return@withLock + try { + store.delete() + } catch (e: Exception) { + Log.w("KeyPackageRotationManager", "clearAllState(): failed to delete snapshot: ${e.message}") + } + } + private data class Snapshot( val bundles: Map, val pending: Set, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index cc7ef8e2f..cfb77d9e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -520,6 +520,32 @@ class MlsGroupManager( store.delete(nostrGroupId) } + /** + * Wipe all MLS group state, both in-memory and on-disk. Intended as a + * user-initiated "nuclear" reset for the Marmot subsystem — does NOT + * publish any SelfRemove/leave events, because the reset path is meant + * to recover from unusable local state where graceful leave may not + * even be possible. + * + * The union of in-memory group IDs and [MlsGroupStateStore.listGroups] + * is used so any orphaned on-disk state (e.g. a prior restore that + * failed to decode into memory) is also removed. + */ + suspend fun clearAllState() = + mutex.withLock { + val ids = (groups.keys + store.listGroups().toSet()).toList() + Log.d(TAG) { "clearAllState(): wiping ${ids.size} group(s): $ids" } + for (id in ids) { + try { + removeGroupStateUnlocked(id) + } catch (e: Exception) { + Log.w(TAG) { "clearAllState(): failed to wipe $id: ${e.message}" } + } + } + groups.clear() + retainedEpochs.clear() + } + // --- Key Export --- /**