diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index dd60ea1b..80966be1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -41,12 +41,23 @@ class BluetoothConnectionManager( // Delegate for component managers to call back to main manager private val componentDelegate = object : BluetoothConnectionManagerDelegate { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) { + device?.let { bluetoothDevice -> + // Get current RSSI for this device and update if available + val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address) + if (currentRSSI != null) { + delegate?.onRSSIUpdated(bluetoothDevice.address, currentRSSI) + } + } delegate?.onPacketReceived(packet, peerID, device) } override fun onDeviceConnected(device: BluetoothDevice) { delegate?.onDeviceConnected(device) } + + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { + delegate?.onRSSIUpdated(deviceAddress, rssi) + } } private val serverManager = BluetoothGattServerManager( @@ -231,4 +242,5 @@ class BluetoothConnectionManager( interface BluetoothConnectionManagerDelegate { fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) fun onDeviceConnected(device: BluetoothDevice) + fun onRSSIUpdated(deviceAddress: String, rssi: Int) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index aa7c23c2..5e91507e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -31,6 +31,9 @@ class BluetoothConnectionTracker( private val subscribedDevices = CopyOnWriteArrayList() val addressPeerMap = ConcurrentHashMap() + // RSSI tracking from scan results (for devices we discover but may connect as servers) + private val scanRSSI = ConcurrentHashMap() + // Connection attempt tracking with automatic cleanup private val pendingConnections = ConcurrentHashMap() @@ -117,6 +120,31 @@ class BluetoothConnectionTracker( return subscribedDevices.toList() } + /** + * Get current RSSI for a device address + */ + fun getDeviceRSSI(deviceAddress: String): Int? { + return connectedDevices[deviceAddress]?.rssi?.takeIf { it != Int.MIN_VALUE } + } + + /** + * Store RSSI from scan results + */ + fun updateScanRSSI(deviceAddress: String, rssi: Int) { + scanRSSI[deviceAddress] = rssi + } + + /** + * Get best available RSSI for a device (connection RSSI preferred, then scan RSSI) + */ + fun getBestRSSI(deviceAddress: String): Int? { + // Prefer connection RSSI if available and valid + connectedDevices[deviceAddress]?.rssi?.takeIf { it != Int.MIN_VALUE }?.let { return it } + + // Fall back to scan RSSI + return scanRSSI[deviceAddress] + } + /** * Add a subscribed device */ @@ -249,6 +277,7 @@ class BluetoothConnectionTracker( subscribedDevices.clear() addressPeerMap.clear() pendingConnections.clear() + scanRSSI.clear() } /** @@ -300,6 +329,11 @@ class BluetoothConnectionTracker( val elapsed = (now - attempt.lastAttempt) / 1000 appendLine(" - $address: ${attempt.attempts} attempts, last ${elapsed}s ago") } + appendLine() + appendLine("Scan RSSI Cache: ${scanRSSI.size}") + scanRSSI.forEach { (address, rssi) -> + appendLine(" - $address: $rssi dBm") + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 3b112478..f60c68ce 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* +import kotlinx.coroutines.Job /** * Manages GATT client operations, scanning, and client-side connections @@ -31,6 +32,9 @@ class BluetoothGattClientManager( // Use exact same UUIDs as iOS version private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") + + // RSSI monitoring constants + private const val RSSI_UPDATE_INTERVAL = 5000L // 5 seconds } // Core Bluetooth components @@ -48,6 +52,9 @@ class BluetoothGattClientManager( private var isCurrentlyScanning = false private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts + // RSSI monitoring state + private var rssiMonitoringJob: Job? = null + // State management private var isActive = false @@ -78,6 +85,9 @@ class BluetoothGattClientManager( } else { startScanning() } + + // Start RSSI monitoring + startRSSIMonitoring() } return true @@ -91,6 +101,7 @@ class BluetoothGattClientManager( connectionScope.launch { stopScanning() + stopRSSIMonitoring() Log.i(TAG, "GATT client manager stopped") } } @@ -106,6 +117,41 @@ class BluetoothGattClientManager( } } + /** + * Start periodic RSSI monitoring for all client connections + */ + private fun startRSSIMonitoring() { + rssiMonitoringJob?.cancel() + rssiMonitoringJob = connectionScope.launch { + while (isActive) { + try { + // Request RSSI from all client connections + val connectedDevices = connectionTracker.getConnectedDevices() + connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn -> + try { + Log.d(TAG, "Requesting RSSI from ${deviceConn.device.address}") + deviceConn.gatt?.readRemoteRssi() + } catch (e: Exception) { + Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") + } + } + delay(RSSI_UPDATE_INTERVAL) + } catch (e: Exception) { + Log.w(TAG, "Error in RSSI monitoring: ${e.message}") + delay(RSSI_UPDATE_INTERVAL) + } + } + } + } + + /** + * Stop RSSI monitoring + */ + private fun stopRSSIMonitoring() { + rssiMonitoringJob?.cancel() + rssiMonitoringJob = null + } + /** * Start scanning with rate limiting */ @@ -230,6 +276,9 @@ class BluetoothGattClientManager( return } + // Store RSSI from scan results for later use (especially for server connections) + connectionTracker.updateScanRSSI(deviceAddress, rssi) + // Power-aware RSSI filtering if (rssi < powerManager.getRSSIThreshold()) { Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}") @@ -381,6 +430,21 @@ class BluetoothGattClientManager( Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") } } + + override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { + val deviceAddress = gatt.device.address + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm") + + // Update the connection tracker with new RSSI value + connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> + val updatedConn = deviceConn.copy(rssi = rssi) + connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) + } + } else { + Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status") + } + } } try { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index c73ea16c..d70f2353 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -69,7 +69,7 @@ class BluetoothGattServerManager( isActive = true connectionScope.launch { - setupGattServer() + // setupGattServer() delay(300) // Brief delay to ensure GATT server is ready startAdvertising() } @@ -122,8 +122,13 @@ class BluetoothGattServerManager( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "Server: Device connected ${device.address}") + + // Get best available RSSI (scan RSSI for server connections) + val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE + val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = device, + rssi = rssi, isClient = false ) connectionTracker.addDeviceConnection(device.address, deviceConn) 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 803d65dd..dca98057 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -280,6 +280,13 @@ class BluetoothMeshService(private val context: Context) { sendKeyExchangeToDevice() } } + + override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { + // Find the peer ID for this device address and update RSSI in PeerManager + connectionManager.addressPeerMap[deviceAddress]?.let { peerID -> + peerManager.updatePeerRSSI(peerID, rssi) + } + } } } 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 d8e8503a..ce37c477 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -49,15 +49,13 @@ class MeshDelegateHandler( // Show notification with enhanced information - now includes senderPeerID message.senderPeerID?.let { senderPeerID -> - if (state.getSelectedPrivateChatPeerValue() != senderPeerID) { - // Use nickname if available, fall back to sender or senderPeerID - val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID - notificationManager.showPrivateMessageNotification( - senderPeerID = senderPeerID, - senderNickname = senderNickname, - messageContent = message.content - ) - } + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = message.content + ) } } else if (message.channel != null) { // Channel message diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index f032182f..ba97657f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -41,17 +42,19 @@ fun MessagesList( } } - LazyColumn( - state = listState, - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items(messages) { message -> - MessageItem( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService - ) + SelectionContainer { + LazyColumn( + state = listState, + modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(messages) { message -> + MessageItem( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index ce7e1e8a..265ea48c 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -96,7 +96,7 @@ class NotificationManager(private val context: Context) { */ fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) { // Only show notifications if app is in background OR user is not viewing this specific chat - val shouldNotify = isAppInBackground || currentPrivateChatPeer != senderPeerID + val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID) if (!shouldNotify) { Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname") 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 4186a76f..27e6927f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -39,6 +39,7 @@ fun SidebarOverlay( val currentChannel by viewModel.currentChannel.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val nickname by viewModel.nickname.observeAsState("") + val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) // Get peer data from mesh service val peerNicknames = viewModel.meshService.getPeerNicknames() @@ -94,7 +95,8 @@ fun SidebarOverlay( }, onLeaveChannel = { channel -> viewModel.leaveChannel(channel) - } + }, + unreadChannelMessages = unreadChannelMessages ) } @@ -155,7 +157,8 @@ fun ChannelsSection( currentChannel: String?, colorScheme: ColorScheme, onChannelClick: (String) -> Unit, - onLeaveChannel: (String) -> Unit + onLeaveChannel: (String) -> Unit, + unreadChannelMessages: Map = emptyMap() ) { Column { Row( @@ -181,6 +184,7 @@ fun ChannelsSection( channels.forEach { channel -> val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 Row( modifier = Modifier @@ -193,6 +197,13 @@ fun ChannelsSection( .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { + // Unread badge for channels + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme, + modifier = Modifier.padding(end = 8.dp) + ) + Text( text = channel, // Channel already contains the # prefix style = MaterialTheme.typography.bodyMedium, @@ -288,7 +299,7 @@ fun PeopleSection( PeerItem( peerID = peerID, displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID), - signalStrength = peerRSSI[peerID] ?: 0, + signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]), isSelected = peerID == selectedPrivatePeer, isFavorite = isFavorite, hasUnreadDM = hasUnreadPrivateMessages.contains(peerID), @@ -297,7 +308,11 @@ fun PeopleSection( onToggleFavorite = { Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") viewModel.toggleFavorite(peerID) - } + }, + unreadCount = privateChats[peerID]?.count { msg -> + // Count unread messages from this peer (messages not from the current user) + msg.sender != nickname && hasUnreadPrivateMessages.contains(peerID) + } ?: if (hasUnreadPrivateMessages.contains(peerID)) 1 else 0 ) } } @@ -314,7 +329,8 @@ private fun PeerItem( hasUnreadDM: Boolean, colorScheme: ColorScheme, onItemClick: () -> Unit, - onToggleFavorite: () -> Unit + onToggleFavorite: () -> Unit, + unreadCount: Int = 0 ) { Row( modifier = Modifier @@ -327,13 +343,11 @@ private fun PeerItem( .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - // Show filled mail icon instead of signal strength when user has unread DMs + // Show unread badge or signal strength if (hasUnreadDM) { - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread messages", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // Orange to match private message theme + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme ) } else { // Signal strength indicators @@ -391,3 +405,57 @@ private fun SignalStrengthIndicator( } } } + +/** + * Reusable unread badge component for both channels and private messages + */ +@Composable +private fun UnreadBadge( + count: Int, + colorScheme: ColorScheme, + modifier: Modifier = Modifier +) { + if (count > 0) { + Box( + modifier = modifier + .background( + color = Color(0xFFFFD700), // Yellow color + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 2.dp, vertical = 0.dp) + .defaultMinSize(minWidth = 14.dp, minHeight = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ), + color = Color.Black // Black text on yellow background + ) + } + } +} + +/** + * Convert RSSI value (dBm) to signal strength percentage (0-100) + * RSSI typically ranges from -30 (excellent) to -100 (very poor) + * Maps to 0-100 scale where: + * - 0-32: No signal (0 bars) + * - 33-65: Weak (1 bar) + * - 66-98: Good (2 bars) + * - 99-100: Excellent (3 bars) + */ +private fun convertRSSIToSignalStrength(rssi: Int?): Int { + if (rssi == null) return 0 + + return when { + rssi >= -40 -> 100 // Excellent signal + rssi >= -55 -> 85 // Very good signal + rssi >= -70 -> 70 // Good signal + rssi >= -85 -> 50 // Fair signal + rssi >= -100 -> 25 // Poor signal + else -> 0 // Very poor or no signal + } +}