diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt index 7ee711fd5..e607df23d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -54,7 +54,7 @@ class AndroidMlsGroupStateStore( ) = withContext(Dispatchers.IO) { val file = stateFile(nostrGroupId) file.parentFile?.mkdirs() - file.writeBytes(encryption.encrypt(state)) + atomicWrite(file, encryption.encrypt(state)) } override suspend fun load(nostrGroupId: String): ByteArray? = @@ -112,7 +112,7 @@ class AndroidMlsGroupStateStore( offset += len } - file.writeBytes(encryption.encrypt(buffer)) + atomicWrite(file, encryption.encrypt(buffer)) } override suspend fun loadRetainedEpochs(nostrGroupId: String): List = @@ -144,4 +144,21 @@ class AndroidMlsGroupStateStore( } result } + + /** + * Write data atomically: write to a temp file first, then rename. + * This avoids corrupted state if the app crashes mid-write. + */ + private fun atomicWrite( + target: File, + data: ByteArray, + ) { + val tempFile = File(target.parentFile, "${target.name}.tmp") + tempFile.writeBytes(data) + if (!tempFile.renameTo(target)) { + // Fallback: if rename fails (e.g., cross-filesystem), copy and delete + tempFile.copyTo(target, overwrite = true) + tempFile.delete() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index ec828d736..4cc225f10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,10 +58,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -220,6 +224,7 @@ fun MarmotGroupListItem( onClick: () -> Unit, ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() + val unread by chatroom.unreadCount.collectAsStateWithLifecycle() val newestMessage = chatroom.newestMessage Row( @@ -235,7 +240,7 @@ fun MarmotGroupListItem( Text( text = displayName ?: "Group ${groupId.take(8)}...", style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, + fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -258,11 +263,30 @@ fun MarmotGroupListItem( } } Column(horizontalAlignment = Alignment.End) { - Text( - text = "${chatroom.messages.size} msgs", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (unread > 0) { + Box( + modifier = + Modifier + .size(22.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (unread > 99) "99+" else unread.toString(), + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + } else { + Text( + text = "${chatroom.messages.size} msgs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } 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 c3b10c887..3bd21665e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -171,8 +171,7 @@ class MarmotInboundProcessor( val result = try { // Step 1: Outer ChaCha20-Poly1305 decryption - val exporterKey = groupManager.exporterSecret(groupId) - val mlsBytes = GroupEventEncryption.decrypt(groupEvent.encryptedContent(), exporterKey) + val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent()) // Step 2: Parse the MLS message val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) @@ -362,8 +361,7 @@ class MarmotInboundProcessor( commitEvent: GroupEvent, ): GroupEventResult = try { - val exporterKey = groupManager.exporterSecret(groupId) - val mlsBytes = GroupEventEncryption.decrypt(commitEvent.encryptedContent(), exporterKey) + val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent()) val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) when (mlsMessage.wireFormat) { @@ -401,6 +399,41 @@ class MarmotInboundProcessor( GroupEventResult.Error(groupId, "Failed to apply commit: ${e.message}", e) } + /** + * Decrypt the outer ChaCha20-Poly1305 layer, trying the current epoch's + * exporter key first and falling back to retained epoch exporter keys. + * + * After a commit advances the epoch, late-arriving messages encrypted + * with the previous epoch's exporter key would fail without this fallback. + */ + private fun decryptOuterLayer( + groupId: HexKey, + encryptedContent: String, + ): ByteArray { + // Try current epoch key first + try { + val exporterKey = groupManager.exporterSecret(groupId) + return GroupEventEncryption.decrypt(encryptedContent, exporterKey) + } catch (_: Exception) { + // Current epoch key failed — try retained epoch keys + } + + // Try retained epoch exporter keys (most recent first) + val retainedKeys = groupManager.retainedExporterSecrets(groupId) + for (retainedKey in retainedKeys) { + try { + return GroupEventEncryption.decrypt(encryptedContent, retainedKey) + } catch (_: Exception) { + // This retained key didn't work — try the next one + } + } + + // All keys exhausted — throw to let callers produce an error result + throw IllegalStateException( + "Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)", + ) + } + private fun hexToBytes(hex: HexKey?): ByteArray { if (hex == null) return ByteArray(0) return hex.hexToByteArray() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index e0c8e82e1..f2aad0abb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Manages KeyPackage creation and rotation lifecycle (MIP-00). @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * KeyPackage slots, rotating consumed ones promptly. */ class KeyPackageRotationManager { + private val mutex = Mutex() private val activeBundles = mutableMapOf() private val pendingRotations = mutableSetOf() 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 161db3d32..52b0843fd 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 @@ -118,8 +118,13 @@ class MlsGroupManager( .map { RetainedEpochSecrets.decodeTls(TlsReader(it)) } .toMutableList() } - } catch (_: Exception) { - // Corrupted state — remove it so it doesn't block future joins + } catch (e: Exception) { + // Corrupted state — log and remove it so it doesn't block future joins + Log.e( + "MlsGroupManager", + "Corrupted state for group $nostrGroupId, deleting: ${e.message}", + e, + ) store.delete(nostrGroupId) } }