diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f8820e91..6b76a2bf 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -475,6 +475,19 @@ fun ConversationHeaderAction( content = content ) +/** A read-only status slot matching the footprint of [ConversationHeaderAction]. */ +@Composable +fun ConversationHeaderStatus( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box( + modifier = modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center, + content = { content() } + ) +} + @Composable fun NicknameEditor( value: String, diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt new file mode 100644 index 00000000..5bbdbfe3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSecurityState.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.ui + +/** + * Resolves the Noise session shown for a private conversation. + * + * Persistent conversations use a canonical contact ID, while live Noise sessions are keyed by + * the currently connected mesh peer ID. Prefer that live identity and retain the conversation ID + * as a fallback for peers whose IDs are already identical. + */ +internal fun resolveConversationSessionState( + conversationID: String, + activeMeshPeerID: String?, + peerSessionStates: Map +): String? { + return activeMeshPeerID?.let(peerSessionStates::get) + ?: peerSessionStates[conversationID] +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 89746a56..88ec6b6f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1806,7 +1806,11 @@ fun PrivateChatSheet( val conversationID = contactResolution.conversationID val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList() - val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID] + val sessionState = resolveConversationSessionState( + conversationID = peerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] } ?: peerFingerprints[peerID] ?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID) @@ -1998,8 +2002,21 @@ fun PrivateChatSheet( ) } - // Encryption state, and the verification badge that qualifies it. Both are - // read-only for Nostr peers, which have no Noise session at all. + if (isVerified) { + ConversationHeaderStatus { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource( + R.string.fingerprint_verified_label + ), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.primary + ) + } + } + + // Keep the lock nearest the close action: from right to left the security + // cluster reads close, encryption, verification, then favorite. if (!isNostrPeer && !isNostrReachableFavorite) { ConversationHeaderAction( onClick = { viewModel.showSecurityVerificationSheet() }, @@ -2014,20 +2031,6 @@ fun PrivateChatSheet( } } - if (isVerified) { - Box( - modifier = Modifier.size(HeaderIconSize), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_check), - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(HeaderIconSize), - tint = colorScheme.primary - ) - } - } - val dismiss = LocalSheetDismiss.current CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() }) } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 40da30be..71e59f0d 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -48,6 +48,7 @@ import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.services.ContactDirectory private data class SecurityStatusInfo( val text: String, @@ -100,7 +101,12 @@ fun SecurityVerificationSheet( val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) - val sessionState = peerSessionStates[selectedPeerID] + val activeMeshPeerID = ContactDirectory.resolve(selectedPeerID).meshPeerID + val sessionState = resolveConversationSessionState( + conversationID = selectedPeerID, + activeMeshPeerID = activeMeshPeerID, + peerSessionStates = peerSessionStates + ) val statusInfo = buildStatusInfo( isVerified = isVerified, sessionState = sessionState, diff --git a/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt b/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt new file mode 100644 index 00000000..c7de0885 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ConversationSecurityStateTest.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationSecurityStateTest { + @Test + fun `active mesh peer session wins for canonical conversation`() { + val result = resolveConversationSessionState( + conversationID = "contact-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf( + "contact-alice" to "uninitialized", + "mesh-alice" to "established" + ) + ) + + assertEquals("established", result) + } + + @Test + fun `conversation session is fallback when live identity has no state`() { + val result = resolveConversationSessionState( + conversationID = "contact-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf("contact-alice" to "handshaking") + ) + + assertEquals("handshaking", result) + } + + @Test + fun `conversation session resolves when mesh and conversation IDs are identical`() { + val result = resolveConversationSessionState( + conversationID = "mesh-alice", + activeMeshPeerID = "mesh-alice", + peerSessionStates = mapOf("mesh-alice" to "established") + ) + + assertEquals("established", result) + } +}