diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 87df49bde..2dd346b8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -44,7 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.random.Random +import java.security.SecureRandom @Composable fun CreateGroupScreen( @@ -54,6 +56,7 @@ fun CreateGroupScreen( var groupName by remember { mutableStateOf("") } var isCreating by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val context = LocalContext.current Scaffold( topBar = { @@ -62,14 +65,25 @@ fun CreateGroupScreen( onPost = { isCreating = true scope.launch(Dispatchers.IO) { - val nostrGroupId = Random.nextBytes(32).toHexKey() - accountViewModel.createMarmotGroup(nostrGroupId) - if (groupName.isNotBlank()) { - accountViewModel.account.marmotGroupList - .getOrCreateGroup(nostrGroupId) - .displayName.value = groupName + try { + val nostrGroupId = ByteArray(32).also { SecureRandom().nextBytes(it) }.toHexKey() + accountViewModel.createMarmotGroup(nostrGroupId) + if (groupName.isNotBlank()) { + accountViewModel.account.marmotGroupList + .getOrCreateGroup(nostrGroupId) + .displayName.value = groupName + } + nav.nav(Route.MarmotGroupChat(nostrGroupId)) + } catch (e: Exception) { + isCreating = false + launch(Dispatchers.Main) { + Toast.makeText( + context, + "Failed to create group: ${e.message}", + Toast.LENGTH_LONG, + ).show() + } } - nav.nav(Route.MarmotGroupChat(nostrGroupId)) } }, isActive = { !isCreating }, 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 e356d3d39..39b05b7ac 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -110,6 +112,7 @@ fun MarmotGroupMessageComposer( val scope = rememberCoroutineScope() val messageState = remember { TextFieldState() } val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val context = LocalContext.current Column(modifier = EditFieldModifier) { ThinPaddingTextField( @@ -130,9 +133,19 @@ fun MarmotGroupMessageComposer( val text = messageState.text.toString().trim() if (text.isNotEmpty()) { scope.launch(Dispatchers.IO) { - accountViewModel.sendMarmotGroupMessage(nostrGroupId, text) - messageState.clearText() - onMessageSent() + try { + accountViewModel.sendMarmotGroupMessage(nostrGroupId, text) + messageState.clearText() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText( + context, + "Failed to send message: ${e.message}", + Toast.LENGTH_SHORT, + ).show() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 4124df326..ea7e46be5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -52,10 +52,12 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import android.widget.Toast import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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 3719d92f8..88fd01393 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 @@ -105,6 +105,7 @@ class MarmotManager( } is GroupEventResult.CommitPending, + is GroupEventResult.Duplicate, is GroupEventResult.Error, -> {} } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index e70f72831..c6eb34378 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -59,6 +59,11 @@ class MarmotGroupList { } } + fun removeGroup(nostrGroupId: HexKey) { + rooms.remove(nostrGroupId) + _groupListChanges.tryEmit(nostrGroupId) + } + fun allGroupIds(): List { val result = mutableListOf() rooms.forEach { key, _ -> result.add(key) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 8049e1321..c3b10c887 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -71,6 +71,13 @@ sealed class GroupEventResult { val epoch: Long, ) : GroupEventResult() + /** + * The event was already processed (duplicate). + */ + data class Duplicate( + val groupId: HexKey, + ) : GroupEventResult() + /** * The event could not be processed. */ @@ -119,6 +126,16 @@ class MarmotInboundProcessor( private val keyPackageRotationManager: KeyPackageRotationManager, ) { private val commitTracker = CommitOrdering.EpochCommitTracker() + private val processedEventIds = LinkedHashSet() + + companion object { + private const val MAX_PROCESSED_IDS = 10_000 + + /** + * Check if an unwrapped event is a Marmot WelcomeEvent. + */ + fun isWelcomeEvent(event: Event): Boolean = event.kind == WelcomeEvent.KIND + } /** * Process an inbound GroupEvent (kind:445). @@ -136,6 +153,13 @@ class MarmotInboundProcessor( * @return the processing result */ suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult { + // Deduplicate already-processed events + val eventId = groupEvent.id + if (eventId in processedEventIds) { + val gId = groupEvent.groupId() + return GroupEventResult.Duplicate(gId ?: "") + } + val groupId = groupEvent.groupId() ?: return GroupEventResult.Error(null, "GroupEvent missing h tag (group ID)") @@ -144,22 +168,39 @@ class MarmotInboundProcessor( return GroupEventResult.Error(groupId, "Not a member of group $groupId") } - return try { - // Step 1: Outer ChaCha20-Poly1305 decryption - val exporterKey = groupManager.exporterSecret(groupId) - val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey) + val result = + try { + // Step 1: Outer ChaCha20-Poly1305 decryption + val exporterKey = groupManager.exporterSecret(groupId) + val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey) - // Step 2: Parse the MLS message - val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) + // Step 2: Parse the MLS message + val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) - when (mlsMessage.wireFormat) { - WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent) - WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent) - else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}") + when (mlsMessage.wireFormat) { + WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent) + WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent) + else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}") + } + } catch (e: Exception) { + GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e) + } + + // Track successfully processed events for deduplication + if (result !is GroupEventResult.Error) { + processedEventIds.add(eventId) + // Trim the set if it exceeds the max size + if (processedEventIds.size > MAX_PROCESSED_IDS) { + val iterator = processedEventIds.iterator() + val toRemove = processedEventIds.size - MAX_PROCESSED_IDS + repeat(toRemove) { + iterator.next() + iterator.remove() + } } - } catch (e: Exception) { - GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e) } + + return result } /** @@ -364,11 +405,4 @@ class MarmotInboundProcessor( if (hex == null) return ByteArray(0) return hex.hexToByteArray() } - - companion object { - /** - * Check if an unwrapped event is a Marmot WelcomeEvent. - */ - fun isWelcomeEvent(event: Event): Boolean = event.kind == WelcomeEvent.KIND - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt index b1a74df2d..7dbbe9ae3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt @@ -52,6 +52,7 @@ data class GroupSubscriptionState( class MarmotSubscriptionManager( private val userPubKey: HexKey, ) { + private val mutex = Mutex() private val groupSubscriptions = mutableMapOf() private var giftWrapSince: Long? = null @@ -62,10 +63,10 @@ class MarmotSubscriptionManager( * @param nostrGroupId hex-encoded Nostr group ID * @param since optional timestamp to resume from (e.g., last seen event) */ - fun subscribeGroup( + suspend fun subscribeGroup( nostrGroupId: HexKey, since: Long? = null, - ) { + ) = mutex.withLock { groupSubscriptions[nostrGroupId] = GroupSubscriptionState( nostrGroupId = nostrGroupId, @@ -78,27 +79,29 @@ class MarmotSubscriptionManager( * Unsubscribe from a group's events. * Call this when leaving a group. */ - fun unsubscribeGroup(nostrGroupId: HexKey) { - groupSubscriptions.remove(nostrGroupId) - } + suspend fun unsubscribeGroup(nostrGroupId: HexKey) = + mutex.withLock { + groupSubscriptions.remove(nostrGroupId) + } /** * Update the `since` timestamp for a group after processing events. * This ensures reconnections only fetch newer events. */ - fun updateGroupSince( + suspend fun updateGroupSince( nostrGroupId: HexKey, since: Long, - ) { + ) = mutex.withLock { groupSubscriptions[nostrGroupId]?.since = since } /** * Update the `since` timestamp for gift wrap subscriptions. */ - fun updateGiftWrapSince(since: Long) { - giftWrapSince = since - } + suspend fun updateGiftWrapSince(since: Long) = + mutex.withLock { + giftWrapSince = since + } /** * Returns all active group IDs being tracked. @@ -185,26 +188,32 @@ class MarmotSubscriptionManager( * * @param activeGroupIds the set of group IDs from [MlsGroupManager.activeGroupIds] */ - fun syncWithGroupManager(activeGroupIds: Set) { - // Add new groups - for (groupId in activeGroupIds) { - if (!groupSubscriptions.containsKey(groupId)) { - subscribeGroup(groupId) + suspend fun syncWithGroupManager(activeGroupIds: Set) = + mutex.withLock { + // Add new groups + for (groupId in activeGroupIds) { + if (!groupSubscriptions.containsKey(groupId)) { + groupSubscriptions[groupId] = + GroupSubscriptionState( + nostrGroupId = groupId, + active = true, + ) + } + } + + // Remove stale groups + val staleGroups = groupSubscriptions.keys - activeGroupIds + for (groupId in staleGroups) { + groupSubscriptions.remove(groupId) } } - // Remove stale groups - val staleGroups = groupSubscriptions.keys - activeGroupIds - for (groupId in staleGroups) { - unsubscribeGroup(groupId) - } - } - /** * Clear all subscription state. */ - fun clear() { - groupSubscriptions.clear() - giftWrapSince = null - } + suspend fun clear() = + mutex.withLock { + groupSubscriptions.clear() + giftWrapSince = null + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 3ae6abb5b..8313c9cca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock * The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets * for the current and previous epoch are kept for late-message decryption. * - * Thread safety: All public methods are suspending and should be called - * from a single coroutine context (e.g., the Account's scope). + * Thread safety: All suspending mutation methods are guarded by a [Mutex] + * to prevent concurrent state corruption. Non-suspending read methods + * ([getGroup], [isMember], [activeGroupIds], [encrypt], [decrypt], + * [decryptOrNull], [exporterSecret]) must be called from the same + * coroutine context that owns this manager (e.g., the Account's scope). * * @see MlsGroup The low-level MLS state machine * @see MlsGroupStateStore Storage abstraction for group state persistence diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt index f46fdb7f9..f94f9e668 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt @@ -171,6 +171,7 @@ data class RetainedEpochSecrets( val senderDataSecret: ByteArray, val encryptionSecret: ByteArray, val leafCount: Int, + val exporterSecret: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -185,15 +186,29 @@ data class RetainedEpochSecrets( writer.putOpaqueVarInt(senderDataSecret) writer.putOpaqueVarInt(encryptionSecret) writer.putUint32(leafCount.toLong()) + writer.putOpaqueVarInt(exporterSecret) } companion object { - fun decodeTls(reader: TlsReader): RetainedEpochSecrets = - RetainedEpochSecrets( - epoch = reader.readUint64(), - senderDataSecret = reader.readOpaqueVarInt(), - encryptionSecret = reader.readOpaqueVarInt(), - leafCount = reader.readUint32().toInt(), + fun decodeTls(reader: TlsReader): RetainedEpochSecrets { + val epoch = reader.readUint64() + val senderDataSecret = reader.readOpaqueVarInt() + val encryptionSecret = reader.readOpaqueVarInt() + val leafCount = reader.readUint32().toInt() + // exporterSecret was added later; tolerate its absence in older serialized data + val exporterSecret = + if (reader.hasRemaining) { + reader.readOpaqueVarInt() + } else { + ByteArray(0) + } + return RetainedEpochSecrets( + epoch = epoch, + senderDataSecret = senderDataSecret, + encryptionSecret = encryptionSecret, + leafCount = leafCount, + exporterSecret = exporterSecret, ) + } } }