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