Merge pull request #109 from permissionlesstech/ui-select-text

UI select text + track RSSI + unread indicator
This commit is contained in:
callebtc
2025-07-14 00:17:05 +02:00
committed by GitHub
9 changed files with 224 additions and 33 deletions
@@ -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)
}
@@ -31,6 +31,9 @@ class BluetoothConnectionTracker(
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
// Connection attempt tracking with automatic cleanup
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
@@ -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")
}
}
}
}
@@ -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 {
@@ -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)
@@ -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)
}
}
}
}
@@ -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
@@ -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
)
}
}
}
}
@@ -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")
@@ -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<String, Int> = 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
}
}