mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 00:45:22 +00:00
Merge pull request #109 from permissionlesstech/ui-select-text
UI select text + track RSSI + unread indicator
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user