diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 7457de1a..c45885a3 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -169,6 +169,13 @@ class EncryptionService(private val context: Context) { return noiseService.hasEstablishedSession(peerID) } + /** + * Get session state for a peer (for UI state display) + */ + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return noiseService.getSessionState(peerID) + } + /** * Get encryption icon state for UI */ diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index cc29c747..22c53443 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -863,6 +863,13 @@ class BluetoothMeshService(private val context: Context) { return encryptionService.hasEstablishedSession(peerID) } + /** + * Get session state for a peer (for UI state display) + */ + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return encryptionService.getSessionState(peerID) + } + /** * Initiate Noise handshake with a specific peer (public API) */ diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 0e1fbc60..21c872f0 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -145,6 +145,13 @@ class NoiseEncryptionService(private val context: Context) { return sessionManager.hasEstablishedSession(peerID) } + /** + * Get session state for a peer (for UI state display) + */ + fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { + return sessionManager.getSessionState(peerID) + } + // MARK: - Encryption/Decryption /** diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index dc493c28..27f1847a 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -157,6 +157,13 @@ class NoiseSessionManager( return hasSession } + /** + * Get session state for a peer (for UI state display) + */ + fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { + return getSession(peerID)?.getState() ?: NoiseSession.NoiseSessionState.Uninitialized + } + /** * Get remote static public key for a peer (if session established) */ 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 a4919293..fda00233 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -29,6 +29,61 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable * Extracted from ChatScreen.kt for better organization */ +/** + * Reactive helper to compute favorite state from fingerprint mapping + * This eliminates the need for static isFavorite parameters and makes + * the UI reactive to fingerprint manager changes + */ +@Composable +fun isFavoriteReactive( + peerID: String, + peerFingerprints: Map, + favoritePeers: Set +): Boolean { + return remember(peerID, peerFingerprints, favoritePeers) { + val fingerprint = peerFingerprints[peerID] + fingerprint != null && favoritePeers.contains(fingerprint) + } +} + +@Composable +fun NoiseSessionIcon( + sessionState: String?, + modifier: Modifier = Modifier +) { + val (icon, color, contentDescription) = when (sessionState) { + "uninitialized" -> Triple( + Icons.Outlined.NoEncryption, + Color(0x87878700), // Grey - ready to establish + "Ready for handshake" + ) + "handshaking" -> Triple( + Icons.Outlined.Sync, + Color(0x87878700), // Grey - in progress + "Handshake in progress" + ) + "established" -> Triple( + Icons.Filled.Lock, + Color(0xFFFF9500), // Orange - secure + "End-to-end encrypted" + ) + else -> { // "failed" or any other state + Triple( + Icons.Outlined.Warning, + Color(0xFFFF4444), // Red - error + "Handshake failed" + ) + } + } + + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = modifier, + tint = color + ) +} + @Composable fun NicknameEditor( value: String, @@ -152,19 +207,26 @@ fun ChatHeaderContent( when { selectedPrivatePeer != null -> { - // Private chat header - ensure state synchronization + // Private chat header - Fully reactive state tracking val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val fingerprint = viewModel.privateChatManager.getPeerFingerprint(selectedPrivatePeer) - val isFavorite = favoritePeers.contains(fingerprint) - val hasEncryption = viewModel.meshService.shouldShowEncryptionIcon(selectedPrivatePeer) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, fingerprint=$fingerprint, isFav=$isFavorite, encrypted=$hasEncryption") + // Reactive favorite computation - no more static lookups! + val isFavorite = isFavoriteReactive( + peerID = selectedPrivatePeer, + peerFingerprints = peerFingerprints, + favoritePeers = favoritePeers + ) + val sessionState = peerSessionStates[selectedPrivatePeer] + + Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") PrivateChatHeader( peerID = selectedPrivatePeer, peerNicknames = viewModel.meshService.getPeerNicknames(), isFavorite = isFavorite, - hasEncryption = hasEncryption, + sessionState = sessionState, onBackClick = onBackClick, onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } ) @@ -197,7 +259,7 @@ private fun PrivateChatHeader( peerID: String, peerNicknames: Map, isFavorite: Boolean, - hasEncryption: Boolean, + sessionState: String?, onBackClick: () -> Unit, onToggleFavorite: () -> Unit ) { @@ -240,23 +302,12 @@ private fun PrivateChatHeader( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.Center) ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = "Private chat", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // Orange to match private message theme - ) - // Show encryption status icon if session is established - if (hasEncryption) { - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.Security, - contentDescription = "End-to-end encrypted", - modifier = Modifier.size(14.dp), - tint = Color(0xFF00C851) // Green to indicate verified encryption - ) - } + // Reactive Noise session status icon + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(14.dp) + ) Spacer(modifier = Modifier.width(4.dp)) Text( @@ -278,7 +329,7 @@ private fun PrivateChatHeader( imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) // Yellow for filled, green for outlined + tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 9b32ea9b..a5a6e17a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -83,6 +83,14 @@ class ChatState { private val _favoritePeers = MutableLiveData>(emptySet()) val favoritePeers: LiveData> = _favoritePeers + // Noise session states for peers (for reactive UI updates) + private val _peerSessionStates = MutableLiveData>(emptyMap()) + val peerSessionStates: LiveData> = _peerSessionStates + + // Peer fingerprint state for reactive favorites (for reactive UI updates) + private val _peerFingerprints = MutableLiveData>(emptyMap()) + val peerFingerprints: LiveData> = _peerFingerprints + // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state @@ -122,6 +130,8 @@ class ChatState { fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() + fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() + fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() fun getShowAppInfoValue() = _showAppInfo.value ?: false // Setters for state updates @@ -207,6 +217,14 @@ class ChatState { Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}") } + fun setPeerSessionStates(states: Map) { + _peerSessionStates.value = states + } + + fun setPeerFingerprints(fingerprints: Map) { + _peerFingerprints.value = fingerprints + } + fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c09789d3..68f0f683 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -80,6 +80,8 @@ class ChatViewModel( val showCommandSuggestions: LiveData = state.showCommandSuggestions val commandSuggestions: LiveData> = state.commandSuggestions val favoritePeers: LiveData> = state.favoritePeers + val peerSessionStates: LiveData> = state.peerSessionStates + val peerFingerprints: LiveData> = state.peerFingerprints val showAppInfo: LiveData = state.showAppInfo init { @@ -115,6 +117,9 @@ class ChatViewModel( dataManager.logAllFavorites() logCurrentFavoriteState() + // Initialize session state monitoring + initializeSessionStateMonitoring() + // Note: Mesh service is now started by MainActivity // Show welcome message if no peers after delay @@ -279,6 +284,35 @@ class ChatViewModel( Log.i("ChatViewModel", "==============================") } + /** + * Initialize session state monitoring for reactive UI updates + */ + private fun initializeSessionStateMonitoring() { + viewModelScope.launch { + while (true) { + delay(1000) // Check session states every second + updateReactiveStates() + } + } + } + + /** + * Update reactive states for all connected peers (session states and fingerprints) + */ + private fun updateReactiveStates() { + val currentPeers = state.getConnectedPeersValue() + + // Update session states + val sessionStates = currentPeers.associateWith { peerID -> + meshService.getSessionState(peerID).toString() + } + state.setPeerSessionStates(sessionStates) + + // Update fingerprint mappings from centralized manager + val fingerprints = privateChatManager.getAllPeerFingerprints() + state.setPeerFingerprints(fingerprints) + } + // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 651e234c..70592b15 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -270,22 +270,24 @@ fun PeopleSection( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) ) } else { - // Get unread private messages and private chat history for sorting + // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - - // Pre-calculate all favorite states to ensure proper state synchronization - val peerFavoriteStates = remember(favoritePeers, connectedPeers) { + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + + // Reactive favorite computation for all peers + val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { connectedPeers.associateWith { peerID -> - val fingerprint = viewModel.privateChatManager.getPeerFingerprint(peerID) - favoritePeers.contains(fingerprint) + // Reactive favorite computation - same as ChatHeader + val fingerprint = peerFingerprints[peerID] + fingerprint != null && favoritePeers.contains(fingerprint) } } Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") - // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical + // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical val sortedPeers = connectedPeers.sortedWith( compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) @@ -376,7 +378,7 @@ private fun PeerItem( imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.primary + tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) ) } }