From bb796b71fb6f155437fe81100e329203148d5688 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 22:00:25 -0400 Subject: [PATCH] feat(marmot): notice a group this device fell out of sync with, and offer a way back A device that forked off a group's epoch chain (the own-commit echo bug fixed in #4232 left such groups behind) never recovers: the other members hold no copy of its epoch, it cannot apply theirs, and every message they send fails with 'decrypts on no canonical epoch'. Nothing told the user; the chat just went quiet. MarmotDesyncDetector flags a group when peer messages NEWER than the last event this device decrypted there keep failing that way (3 distinct events over at least 60s). History from before we joined or past retention fails the same way but is older than that baseline, so it never counts; one successful decrypt clears the flag. The baseline is seeded on join/create and from the newest persisted message on restore. A flagged chat shows a notice with Reset: after a confirmation it drops the local MLS state without publishing (a SelfRemove at a dead epoch decrypts for no one), keeps the decrypted history on disk, and republishes a KeyPackage if needed. The composer then asks the user to have an admin remove and re-add them. The next Welcome joins normally because the group is no longer held locally. An external join isn't possible, since nobody publishes a GroupInfo. The inbound error log now names its group. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 15 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 2 + .../loggedIn/DecryptAndIndexProcessor.kt | 12 +- .../chats/marmotGroup/MarmotGroupChatView.kt | 66 ++++++++++- .../commons/marmot/MarmotDesyncDetector.kt | 103 ++++++++++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 61 +++++++++-- .../model/marmotGroups/MarmotGroupChatroom.kt | 6 + .../marmot/MarmotDesyncDetectorTest.kt | 85 +++++++++++++++ .../commons/marmot/MarmotDisbandTest.kt | 33 ++++++ .../composeResources/values/strings.xml | 5 + 10 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index dbc7084b4f..3ba76a367c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -605,6 +605,21 @@ class AccountMarmotActions( lastOwnerCheck = null } + /** + * Drop this device's copy of a group it has fallen out of sync with, and make sure a + * fresh KeyPackage is out there for the admin's re-invite. See + * [MarmotManager.resetOutOfSyncGroup]. + */ + suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + manager.resetOutOfSyncGroup(nostrGroupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + chatroom.isOutOfSync.value = false + chatroom.awaitingReinvite.value = true + account.marmotGroupList.notifyGroupChanged(nostrGroupId) + ensureMarmotKeyPackagePublished() + } + /** * Ensure the local user has at least one active KeyPackage bundle and * a published KeyPackage event on relays. Called from [init] after 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 f49fcd97f4..d8bcd839b5 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 @@ -2556,6 +2556,8 @@ class AccountViewModel( /** True when this account has somewhere to upload a group's encrypted media. */ fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() + suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: String) = account.marmot.resetOutOfSyncMarmotGroup(nostrGroupId) + suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index af8f947214..36d274f59c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -421,6 +421,7 @@ private suspend fun processMarmotWelcomeFlow( // Sync MIP-01 metadata from group extensions to chatroom val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + chatroom.awaitingReinvite.value = false manager.syncMetadataTo(result.nostrGroupId, chatroom) Log.d("MarmotDbg") { "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + @@ -690,6 +691,10 @@ class GroupEventHandler( when (result) { is GroupEventResult.ApplicationMessage -> { + // A message that decrypts means this device is back in step. + account.marmotGroupList + .getOrCreateGroup(result.groupId) + .isOutOfSync.value = false // Parse the inner event JSON and index it val innerEvent = Event.fromJson(result.innerEventJson) Log.d("MarmotDbg") { @@ -896,7 +901,12 @@ class GroupEventHandler( } is GroupEventResult.Error -> { - Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } + Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR group=${result.groupId?.take(8)} ${result.message}" } + result.groupId?.let { groupId -> + val outOfSync = manager.isOutOfSync(groupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(groupId) + if (chatroom.isOutOfSync.value != outOfSync) chatroom.isOutOfSync.value = outOfSync + } } } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 399c647b78..53ad82a1a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -28,8 +28,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -53,11 +55,17 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_removed import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name import com.vitorpamplona.amethyst.commons.resources.marmot_not_a_member +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -189,10 +197,17 @@ fun MarmotGroupChatView( // stays readable either way, which is the point of a gate that is not // a terminal state. val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle() + val isOutOfSync by chatroom.isOutOfSync.collectAsStateWithLifecycle() + val awaitingReinvite by chatroom.awaitingReinvite.collectAsStateWithLifecycle() val gate = outboundGate - if (gate != null) { + if (awaitingReinvite) { + MarmotGroupNoticeRow(stringRes(Res.string.marmot_awaiting_reinvite)) + } else if (gate != null) { MarmotGroupClosedComposer(gate) } else { + if (isOutOfSync) { + MarmotOutOfSyncBanner(nostrGroupId, accountViewModel) + } MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, newMessageModel = newMessageModel, @@ -391,6 +406,50 @@ private fun MarmotGroupFileUploadDialog( ) } +/** + * This device has fallen off the group's epoch chain (MarmotDesyncDetector): nothing the + * other members send decrypts here, and it will not heal on its own. Offers the one way + * back that exists, dropping the local copy so an admin can add this member again. + */ +@Composable +private fun MarmotOutOfSyncBanner( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + var confirming by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { + Text( + text = stringRes(Res.string.marmot_out_of_sync_body), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { confirming = true }, modifier = Modifier.align(Alignment.End)) { + Text(stringRes(Res.string.marmot_out_of_sync_reset)) + } + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_title)) }, + text = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + confirming = false + scope.launch(Dispatchers.IO) { accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId) } + }, + ) { Text(stringRes(Res.string.marmot_out_of_sync_reset)) } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } +} + /** * Stands in for the composer when an outbound gate is up. * @@ -407,6 +466,11 @@ private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) { LocalOutboundGate.LEAVING -> stringRes(Res.string.marmot_group_composer_leaving) LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed) } + MarmotGroupNoticeRow(message) +} + +@Composable +private fun MarmotGroupNoticeRow(message: String) { Row( modifier = EditFieldModifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt new file mode 100644 index 0000000000..4806ba5c0f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt @@ -0,0 +1,103 @@ +/* + * 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.commons.marmot + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Notices when this device has fallen off a group's epoch chain: the other members + * keep talking and none of it decrypts here. + * + * A fork like that does not heal on its own. The members who moved on hold no copy of + * the epoch this device is stuck at, and this device cannot apply their commits, so + * every message they send fails with "decrypts on no canonical epoch". Groups broken + * this way before the own-commit echo fix never recovered, and nothing told the user. + * + * The same error is also routine: an event from BEFORE this device joined, or older + * than the retained epochs, fails exactly like that. So only failures NEWER than the + * last event this group decrypted here count, a single burst does not trip it (they + * must span [minSpanSec] of sender time), and one successful decrypt clears it. A + * group with no baseline (never decrypted anything here and not seeded) is never + * flagged: there is nothing to be behind. + */ +class MarmotDesyncDetector( + private val threshold: Int = 3, + private val minSpanSec: Long = 60, +) { + private class Tracker { + var lastSuccessAt: Long = 0 + val failures = mutableMapOf() + var desynced = false + } + + private val lock = KmpLock() + private val groups = mutableMapOf() + + /** Baseline for a group restored or joined with no decrypt yet this session. */ + fun seed( + groupId: HexKey, + createdAt: Long, + ) = lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + } + + /** Something at [createdAt] decrypted: the group is on the chain. Returns true if that cleared a desync. */ + fun onDecrypted( + groupId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + t.failures.clear() + val was = t.desynced + t.desynced = false + was + } + + /** An app message that decrypts on no epoch here. Returns true when this call newly flags the group. */ + fun onUndecryptable( + groupId: HexKey, + eventId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups[groupId] ?: return@withLock false + if (t.lastSuccessAt == 0L || createdAt <= t.lastSuccessAt || t.desynced) return@withLock false + t.failures[eventId] = createdAt + val span = t.failures.values.max() - t.failures.values.min() + if (t.failures.size >= threshold && span >= minSpanSec) { + t.desynced = true + true + } else { + false + } + } + + fun isDesynced(groupId: HexKey): Boolean = lock.withLock { groups[groupId]?.desynced == true } + + fun forget(groupId: HexKey) { + lock.withLock { groups.remove(groupId) } + } +} 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 da70cac22b..c6704b525d 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 @@ -178,6 +178,8 @@ class MarmotManager( * Restore all Marmot state from persistent storage. * Call once during Account initialization. */ + private val desync = MarmotDesyncDetector() + suspend fun restoreAll() { Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" } try { @@ -187,8 +189,13 @@ class MarmotManager( // syncWithGroupManager fills in default (since = null) entries, // so even the first filter set sent to relays skips the // already-processed kind:445 backlog. - subscriptionSinceFromStoredMessages(activeIds).forEach { (groupId, since) -> - subscriptionManager.subscribeGroup(groupId, since) + newestStoredMessageTimes(activeIds).forEach { (groupId, newest) -> + // The newest event this group decrypted before the restart is where "behind" + // starts counting (see MarmotDesyncDetector). + desync.seed(groupId, newest) + if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { + subscriptionManager.subscribeGroup(groupId, newest - GROUP_EVENT_REFETCH_OVERLAP_SEC) + } } subscriptionManager.syncWithGroupManager(activeIds) // Seed convergence with each restored state. A commit that arrives @@ -392,7 +399,7 @@ class MarmotManager( * are still fetched; replays inside the window are deduplicated by the * message store and by note identity in the chatroom. */ - private suspend fun subscriptionSinceFromStoredMessages(groupIds: Set): Map { + private suspend fun newestStoredMessageTimes(groupIds: Set): Map { if (messageStore == null) return emptyMap() val result = mutableMapOf() for (groupId in groupIds) { @@ -409,10 +416,7 @@ class MarmotManager( // and a single future-dated message must not push `since` past // the present — that would skip genuinely new events on every // restart until a fresher message arrives. - val newest = minOf(newestStored, TimeUtils.now()) - if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { - result[groupId] = newest - GROUP_EVENT_REFETCH_OVERLAP_SEC - } + result[groupId] = minOf(newestStored, TimeUtils.now()) } return result } @@ -443,14 +447,17 @@ class MarmotManager( when (result) { is GroupEventResult.ApplicationMessage -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitProcessed -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.ProposalStaged -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitPending, @@ -458,13 +465,43 @@ class MarmotManager( is GroupEventResult.UndecryptableOuterLayer, is GroupEventResult.AppMessageOnCandidateBranch, is GroupEventResult.RefusedByLifecycle, - is GroupEventResult.Error, -> {} + + is GroupEventResult.Error -> { + val groupId = result.groupId + if (groupId != null && result.message.startsWith(NO_CANONICAL_EPOCH_ERROR)) { + if (desync.onUndecryptable(groupId, groupEvent.id, groupEvent.createdAt)) { + Log.w("MarmotManager") { "group ${groupId.take(8)}… is out of sync: newer peer messages decrypt on no epoch here" } + } + } + } } return result } + /** Whether the other members of [nostrGroupId] have moved on to epochs this device can't follow. */ + fun isOutOfSync(nostrGroupId: HexKey): Boolean = desync.isDesynced(nostrGroupId) + + /** + * Drop this device's copy of a group it has fallen out of sync with, so it can be + * invited back. + * + * A fork cannot be repaired from this side: the peers hold no copy of our epoch and + * we cannot apply theirs, and an external join needs a GroupInfo nobody publishes. + * What does work is the ordinary invite path. With the MLS state gone, a new Welcome + * is not "already a member" and joins; an admin removes this member and adds it back. + * Nothing is published: a SelfRemove at our stale epoch would decrypt for no one. + * The decrypted history stays on disk, so it is back when the group is. + */ + suspend fun resetOutOfSyncGroup(nostrGroupId: HexKey) { + subscriptionManager.unsubscribeGroup(nostrGroupId) + publishGate.forget(nostrGroupId) + groupManager.removeGroupState(nostrGroupId) + desync.forget(nostrGroupId) + Log.w("MarmotManager") { "reset out-of-sync group ${nostrGroupId.take(8)}…; waiting for a new Welcome" } + } + /** * Process a WelcomeEvent (kind:444) after NIP-59 unwrapping. * Returns the result including whether KeyPackage rotation is needed. @@ -479,6 +516,8 @@ class MarmotManager( val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId) if (result is WelcomeResult.Joined) { + // Joined now: only what is sent from here on has to decrypt. + desync.seed(result.nostrGroupId, TimeUtils.now()) // An authenticated re-join is what clears a departure gate — the // rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it // just as much: a member who left and was invited back holds a gate @@ -1072,6 +1111,7 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } return nostrGroupId @@ -1114,6 +1154,7 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) return nostrGroupId } @@ -2685,6 +2726,7 @@ class MarmotManager( // events the UI never sees directly — a disband request resolving, a // removal being realized. chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId) + chatroom.isOutOfSync.value = desync.isDesynced(nostrGroupId) // A group we created is ours: keep it out of "New Requests" across restarts. // `markAsKnown` at creation is in-memory only, and a creator who has not posted // yet has nothing else that says so. The creator holds leaf 0 (RFC 9420 adds a @@ -2711,6 +2753,9 @@ class MarmotManager( */ internal val GROUP_EVENT_REFETCH_OVERLAP_SEC: Long = TimeUtils.ONE_DAY.toLong() + /** Prefix of the inbound error for an app message no epoch here can open. */ + internal const val NO_CANONICAL_EPOCH_ERROR = "Application message decrypts on no canonical epoch" + /** * How often the settler re-checks an open pass. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 05aa779b29..c95d460266 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -90,6 +90,12 @@ class MarmotGroupChatroom( */ var isCurrentProfile = MutableStateFlow(true) + /** This device can no longer read what the other members send (see MarmotDesyncDetector). */ + val isOutOfSync = MutableStateFlow(false) + + /** This device dropped its copy of the group and waits for an admin to add it back. */ + val awaitingReinvite = MutableStateFlow(false) + /** * True once the group carries the `encrypted-media-v2` policy (`0x800b`). * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt new file mode 100644 index 0000000000..c2797f5491 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt @@ -0,0 +1,85 @@ +/* + * 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.commons.marmot + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MarmotDesyncDetectorTest { + private val g = "a".repeat(64) + + private fun id(n: Int) = n.toString().padStart(64, '0') + + @Test + fun `peer messages newer than our last decrypt that keep failing flag the group`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + assertFalse(d.onUndecryptable(g, id(1), 1_010)) + assertFalse(d.onUndecryptable(g, id(2), 1_030)) + assertTrue(d.onUndecryptable(g, id(3), 1_080)) + assertTrue(d.isDesynced(g)) + } + + @Test + fun `history from before our last decrypt never counts`() { + // Old epochs (before we joined, or past retention) fail the same way. + val d = MarmotDesyncDetector() + d.seed(g, 5_000) + (1..20).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_000L + it * 100)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a burst from one moment is not enough`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + (1..10).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_010L + it)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a replayed event counts once`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + repeat(5) { d.onUndecryptable(g, id(1), 1_010) } + d.onUndecryptable(g, id(2), 1_100) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `one decrypt clears the flag`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + d.onUndecryptable(g, id(1), 1_010) + d.onUndecryptable(g, id(2), 1_040) + d.onUndecryptable(g, id(3), 1_090) + assertTrue(d.onDecrypted(g, 1_100)) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a group with no baseline is never flagged`() { + val d = MarmotDesyncDetector() + (1..5).forEach { d.onUndecryptable(g, id(it), 1_000L + it * 100) } + assertFalse(d.isDesynced(g)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt index 20532128d5..4bd030c460 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -282,6 +283,38 @@ class MarmotDisbandTest { assertTrue(!bobsRoom.isKnown(emptySet()), "an invitation from someone we don't follow") } + @Test + fun `a member reset after falling out of sync is back in once re-added`() = + runBlocking { + // The recovery for a device stuck on a dead epoch: it drops its copy without + // publishing anything, an admin removes and re-adds it, and the new Welcome + // joins instead of being taken for a replay of a group it still holds. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + bob.manager.resetOutOfSyncGroup(nostrGroupId) + assertNull(bob.manager.groupState(nostrGroupId), "the stale copy is gone") + + val bobLeaf = + alice.manager + .memberPubkeys(nostrGroupId) + .first { it.pubkey == bob.signer.pubKey } + .leafIndex + alice.manager.removeMember(nostrGroupId, bobLeaf) + val freshKp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, reinvite) = alice.manager.addMember(nostrGroupId, freshKp, emptyList()) + val joined = bob.manager.ingest(reinvite!!.giftWrapEvent) + assertTrue(joined is MarmotIngestResult.JoinedGroup, "re-invite joins, got $joined") + + val hello = alice.manager.buildTextMessage(nostrGroupId, "welcome back") + val received = bob.manager.processGroupEvent(hello.outbound.signedEvent) + assertTrue(received is GroupEventResult.ApplicationMessage, "and reads the group again, got $received") + } + @Test fun `rejoining a group we left clears the departure gate`() = runBlocking { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 50aec281b5..09f612ceb6 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4172,6 +4172,11 @@ Admin privileges granted Admin privileges revoked Change photo + Messages from the other members can't be read on this device anymore. Reset your copy of the group, then ask an admin to remove you and add you back. + Reset + Reset this group? + This device stops being a member until an admin adds you back. The messages you already have stay here. + Waiting to be added back. Ask an admin to remove you and add you again. Add a Blossom media server in Settings first — the group needs somewhere to upload attachments to. This group now uses encrypted attachments Failed to add %1$s: %2$s