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 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-27 23:43:32 -04:00
co-authored by Claude Opus 5.5
parent a2ad02f58c
commit bb796b71fb
10 changed files with 378 additions and 10 deletions
@@ -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
@@ -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)
}
@@ -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) {
@@ -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,
@@ -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<HexKey, Long>()
var desynced = false
}
private val lock = KmpLock()
private val groups = mutableMapOf<HexKey, Tracker>()
/** 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) }
}
}
@@ -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<HexKey>): Map<HexKey, Long> {
private suspend fun newestStoredMessageTimes(groupIds: Set<HexKey>): Map<HexKey, Long> {
if (messageStore == null) return emptyMap()
val result = mutableMapOf<HexKey, Long>()
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.
*
@@ -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`).
*
@@ -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))
}
}
@@ -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 {
@@ -4172,6 +4172,11 @@
<string name="marmot_admin_granted">Admin privileges granted</string>
<string name="marmot_admin_revoked">Admin privileges revoked</string>
<string name="marmot_change_photo">Change photo</string>
<string name="marmot_out_of_sync_body">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.</string>
<string name="marmot_out_of_sync_reset">Reset</string>
<string name="marmot_out_of_sync_confirm_title">Reset this group?</string>
<string name="marmot_out_of_sync_confirm_body">This device stops being a member until an admin adds you back. The messages you already have stay here.</string>
<string name="marmot_awaiting_reinvite">Waiting to be added back. Ask an admin to remove you and add you again.</string>
<string name="marmot_enable_encrypted_media_needs_server">Add a Blossom media server in Settings first — the group needs somewhere to upload attachments to.</string>
<string name="marmot_encrypted_media_enabled_toast">This group now uses encrypted attachments</string>
<string name="marmot_failed_to_add_user">Failed to add %1$s: %2$s</string>