feat(marmot): add Reset Marmot State safety valve in settings

Gives users a last-resort "nuclear" option when Marmot local state is
corrupted or otherwise unrecoverable — wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, subscription and in-memory
chatroom for the current account. Does not broadcast leave/SelfRemove
commits, since a graceful teardown may be impossible in exactly the
scenarios where users reach for this option. The KeyPackage is
republished lazily on the next sync so the account stays reachable.

Adds clearAllState() helpers on MlsGroupManager and
KeyPackageRotationManager, a resetAllState() orchestrator on
MarmotManager, an Account.resetMarmotState() entry point, and a
destructive row + confirm dialog in the AllSettingsScreen Danger Zone
that mirrors the existing Request-to-Vanish pattern.

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
This commit is contained in:
Claude
2026-04-23 20:08:26 +00:00
parent 647d6f9841
commit 61c01981cc
7 changed files with 206 additions and 0 deletions
@@ -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.
@@ -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.
@@ -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
+6
View File
@@ -1122,6 +1122,12 @@
<string name="account_settings">Account Settings</string>
<string name="app_settings">App Settings</string>
<string name="danger_zone">Danger Zone</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_confirm_title">Reset Marmot State?</string>
<string name="reset_marmot_confirm_body">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.</string>
<string name="reset_marmot_confirm_action">Reset</string>
<string name="reset_marmot_success">Marmot state reset.</string>
<string name="reset_marmot_failure">Failed to reset Marmot state: %1$s</string>
<string name="connectivity_type_always">Always</string>
<string name="connectivity_type_wifi_only">Wifi-only</string>
<string name="connectivity_type_unmetered_wifi_only">Unmetered WiFi</string>
@@ -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).
@@ -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<String, KeyPackageBundle>,
val pending: Set<String>,
@@ -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 ---
/**