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 dca98057b..84541b490 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -214,6 +214,10 @@ class BluetoothMeshService(private val context: Context) { override fun onReadReceiptReceived(receipt: ReadReceipt) { delegate?.didReceiveReadReceipt(receipt) } + + override fun markChannelAsPasswordProtected(channel: String) { + delegate?.markChannelAsPasswordProtected(channel) + } } // PacketProcessor delegates @@ -597,5 +601,6 @@ interface BluetoothMeshDelegate { fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun getNickname(): String? fun isFavorite(peerID: String): Boolean + fun markChannelAsPasswordProtected(channel: String) fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) } diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 2cff0e6c6..9032d125a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -108,6 +108,11 @@ class MessageHandler(private val myPeerID: String) { timestamp = Date() // Use current time instead of original timestamp ) + // Auto-detect password-protected channels + if (message.channel != null && message.isEncrypted && message.encryptedContent != null) { + delegate?.markChannelAsPasswordProtected(message.channel) + } + delegate?.onMessageReceived(messageWithCurrentTime) } @@ -354,6 +359,7 @@ interface MessageHandlerDelegate { // Callbacks fun onMessageReceived(message: BitchatMessage) + fun markChannelAsPasswordProtected(channel: String) fun onChannelLeave(channel: String, fromPeer: String) fun onPeerDisconnected(nickname: String) fun onDeliveryAckReceived(ack: DeliveryAck) diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 641c65efa..793f3742e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -301,6 +301,27 @@ class ChannelManager( ) } + fun markChannelAsPasswordProtected(channel: String) { + // Mark as password protected if not already + if (!state.getPasswordProtectedChannelsValue().contains(channel)) { + state.setPasswordProtectedChannels( + state.getPasswordProtectedChannelsValue().plus(channel) + ) + } + + // Track as discovered if not joined + if (!state.getJoinedChannelsValue().contains(channel)) { + state.setDiscoveredChannels( + state.getDiscoveredChannelsValue().plus(channel) + ) + } + + // Save state + saveChannelData() + } + + fun getDiscoveredChannels(): Set = state.getDiscoveredChannelsValue() + // MARK: - Emergency Clear fun clearAllChannels() { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0a2027a53..e27630504 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -67,17 +67,11 @@ fun ChatScreen(viewModel: ChatViewModel) { val showAppInfo by viewModel.showAppInfo.observeAsState(false) var messageText by remember { mutableStateOf(TextFieldValue("")) } - var showPasswordPrompt by remember { mutableStateOf(false) } - var showPasswordDialog by remember { mutableStateOf(false) } + val showPasswordPrompt by viewModel.showPasswordPrompt.observeAsState(false) + val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) var passwordInput by remember { mutableStateOf("") } - // Show password dialog when needed - LaunchedEffect(showPasswordPrompt) { - showPasswordDialog = showPasswordPrompt - } - val isConnected by viewModel.isConnected.observeAsState(false) - val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) // Determine what messages to show val displayMessages = when { @@ -173,21 +167,22 @@ fun ChatScreen(viewModel: ChatViewModel) { // Dialogs ChatDialogs( - showPasswordDialog = showPasswordDialog, + showPasswordPrompt = showPasswordPrompt, passwordPromptChannel = passwordPromptChannel, passwordInput = passwordInput, onPasswordChange = { passwordInput = it }, onPasswordConfirm = { - if (passwordInput.isNotEmpty()) { - val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput) + val channel = passwordPromptChannel + if (passwordInput.isNotEmpty() && channel != null) { + val success = viewModel.joinChannel(channel, passwordInput) if (success) { - showPasswordDialog = false + viewModel.hidePasswordPrompt() passwordInput = "" } } }, onPasswordDismiss = { - showPasswordDialog = false + viewModel.hidePasswordPrompt() passwordInput = "" }, showAppInfo = showAppInfo, @@ -298,7 +293,7 @@ private fun ChatFloatingHeader( @Composable private fun ChatDialogs( - showPasswordDialog: Boolean, + showPasswordPrompt: Boolean, passwordPromptChannel: String?, passwordInput: String, onPasswordChange: (String) -> Unit, @@ -309,7 +304,7 @@ private fun ChatDialogs( ) { // Password dialog PasswordPromptDialog( - show = showPasswordDialog, + show = showPasswordPrompt, channelName = passwordPromptChannel, passwordInput = passwordInput, onPasswordChange = onPasswordChange, 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 4a2e9bdae..cdfe5bb9f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -62,6 +62,9 @@ class ChatState { private val _passwordProtectedChannels = MutableLiveData>(emptySet()) val passwordProtectedChannels: LiveData> = _passwordProtectedChannels + private val _discoveredChannels = MutableLiveData>(emptySet()) + val discoveredChannels: LiveData> = _discoveredChannels + private val _showPasswordPrompt = MutableLiveData(false) val showPasswordPrompt: LiveData = _showPasswordPrompt @@ -116,6 +119,7 @@ class ChatState { fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() + fun getDiscoveredChannelsValue() = _discoveredChannels.value ?: emptySet() fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false fun getPasswordPromptChannelValue() = _passwordPromptChannel.value fun getShowSidebarValue() = _showSidebar.value ?: false @@ -173,6 +177,10 @@ class ChatState { _passwordProtectedChannels.value = channels } + fun setDiscoveredChannels(channels: Set) { + _discoveredChannels.value = channels + } + fun setShowPasswordPrompt(show: Boolean) { _showPasswordPrompt.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 d8fc16344..e8489da1e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -61,6 +61,7 @@ class ChatViewModel( val channelMessages: LiveData>> = state.channelMessages val unreadChannelMessages: LiveData> = state.unreadChannelMessages val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels + val discoveredChannels: LiveData> = state.discoveredChannels val showPasswordPrompt: LiveData = state.showPasswordPrompt val passwordPromptChannel: LiveData = state.passwordPromptChannel val showSidebar: LiveData = state.showSidebar @@ -149,6 +150,24 @@ class ChatViewModel( meshService.sendMessage("left $channel") } + fun onChannelClick(channel: String) { + if (state.getJoinedChannelsValue().contains(channel)) { + // Already joined, just switch + switchToChannel(channel) + } else if (state.getPasswordProtectedChannelsValue().contains(channel)) { + // Need password + state.setPasswordPromptChannel(channel) + state.setShowPasswordPrompt(true) + } else { + // Regular join + joinChannel(channel, null) + } + } + + fun hidePasswordPrompt() { + channelManager.hidePasswordPrompt() + } + // MARK: - Private Chat Management (delegated) fun startPrivateChat(peerID: String) { @@ -348,6 +367,10 @@ class ChatViewModel( privateChatManager.registerPeerPublicKey(peerID, publicKeyData) } + override fun markChannelAsPasswordProtected(channel: String) { + meshDelegateHandler.markChannelAsPasswordProtected(channel) + } + // MARK: - Emergency Clear fun panicClearAllData() { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index ce37c4774..2cf2c4228 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -156,4 +156,10 @@ class MeshDelegateHandler( override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) { privateChatManager.registerPeerPublicKey(peerID, publicKeyData) } + + override fun markChannelAsPasswordProtected(channel: String) { + coroutineScope.launch { + channelManager.markChannelAsPasswordProtected(channel) + } + } } 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 651e234c0..b1ebc079b 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -36,6 +36,8 @@ fun SidebarOverlay( val colorScheme = MaterialTheme.colorScheme val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) + val discoveredChannels by viewModel.discoveredChannels.observeAsState(emptySet()) + val passwordProtectedChannels by viewModel.passwordProtectedChannels.observeAsState(emptySet()) val currentChannel by viewModel.currentChannel.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val nickname by viewModel.nickname.observeAsState("") @@ -96,10 +98,30 @@ fun SidebarOverlay( onLeaveChannel = { channel -> viewModel.leaveChannel(channel) }, - unreadChannelMessages = unreadChannelMessages + unreadChannelMessages = unreadChannelMessages, + passwordProtectedChannels = passwordProtectedChannels ) } - + } + + // Discovered channels section + val notJoinedDiscoveredChannels = discoveredChannels.filter { !joinedChannels.contains(it) } + if (notJoinedDiscoveredChannels.isNotEmpty()) { + item { + DiscoveredChannelsSection( + channels = notJoinedDiscoveredChannels.toList(), + passwordProtectedChannels = passwordProtectedChannels, + colorScheme = colorScheme, + onChannelClick = { channel -> + viewModel.onChannelClick(channel) + onDismiss() + } + ) + } + } + + // Divider between channels and people + if (joinedChannels.isNotEmpty() || notJoinedDiscoveredChannels.isNotEmpty()) { item { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) } @@ -158,7 +180,8 @@ fun ChannelsSection( colorScheme: ColorScheme, onChannelClick: (String) -> Unit, onLeaveChannel: (String) -> Unit, - unreadChannelMessages: Map = emptyMap() + unreadChannelMessages: Map = emptyMap(), + passwordProtectedChannels: Set = emptySet() ) { Column { Row( @@ -212,6 +235,15 @@ fun ChannelsSection( modifier = Modifier.weight(1f) ) + // Lock icon for password-protected channels + if (passwordProtectedChannels.contains(channel)) { + Text( + text = "🔒", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + // Leave channel button IconButton( onClick = { onLeaveChannel(channel) }, @@ -229,6 +261,63 @@ fun ChannelsSection( } } +@Composable +fun DiscoveredChannelsSection( + channels: List, + passwordProtectedChannels: Set, + colorScheme: ColorScheme, + onChannelClick: (String) -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Person, // Using Person icon as placeholder + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(id = R.string.discovered_channels).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold + ) + } + + channels.forEach { channel -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onChannelClick(channel) } + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = channel, // Channel already contains the # prefix + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + + // Lock icon for password-protected channels + if (passwordProtectedChannels.contains(channel)) { + Text( + text = "🔒", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } +} + @Composable fun PeopleSection( connectedPeers: List, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23bd354c7..3b57c264e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Back People Channels + Discovered Channels Online Users No one connected Triple tap to clear all data