Debug button (#377)

* feat(debug): add DebugSettingsManager + DebugSettingsSheet scaffold and AboutSheet entry; groundwork for verbose logging, GATT controls, relay stats, device/scan views

* fix(debug): wire debug sheet launch via onShowDebug in AboutSheet from ChatScreen; add public start/stop server/client methods}

* fix(about): remove misplaced debug item block inside PoW section; keep debug launcher managed by ChatScreen only

* feat(debug): wire DebugSettingsSheet role switches to explicit startServer/stopServer and startClient/stopClient; expose role controls + disconnectAll on BluetoothConnectionManager; fix syntax error; build passes

* feat(debug): add connect/disconnect helpers; wire DebugSettingsSheet connect/disconnect actions to BluetoothConnectionManager

* feat(debug): add debug settings button at bottom of AboutSheet; wire onShowDebug callback to open DebugSettingsSheet from ChatScreen

* feat(debug): wire verbose logging into chat view via DebugSettingsManager; add rolling relay stats; push connected devices and scan results; ensure GATT role stop closes connections; log incoming packets and relay events; add UI polling for devices; build passes

* chore(debug-branch): remove unrelated files that were mistakenly added in first commit; keep only intended debug settings changes

* fix(chat): prevent mesh→geohash leak

Root cause:
- DebugSettingsManager→chat bridge appended system logs to the global timeline regardless of active channel
- MeshDelegateHandler added public mesh messages to UI unconditionally

Fixes:
- Only inject debug system messages when selected location channel is Mesh
- Only add public mesh messages to UI when Mesh is selected (still send notifications)

Build: ./gradlew assembleDebug
}

* fix(timeline): restore message persistence across channel switches

Root cause:
- NostrGeohashService.switchLocationChannel() called messageManager.clearMessages()
  which wiped ALL messages (mesh + debug + geohash) from main timeline
- Geohash events were adding to main timeline, causing cross-contamination

Fixes:
- Remove messageManager.clearMessages() from channel switching
- ChatScreen displayMessages now routes to separate storage:
  - Mesh: messages (main timeline, includes debug logs when Mesh selected)
  - Geohash: viewModel.getGeohashMessages(geohash) from separate history
  - Private: privateChats[peerID]
  - Channels: channelMessages[channel]
- Geohash events no longer add to main timeline, only to geohash history
- Mesh messages always stored to preserve history when switching away

Result: Each chat type has persistent separate storage, no message loss

* Debug/relay logs: use MessageType names; include device route in verbose packet logs.

* Verbose device-peer assignment and connection/disconnection logs with peerId, deviceId, nickname; packet relay log uses MessageType names.}

* Debug logs API: include nickname and deviceId for incoming/relay; update callers.}

* Fix compile: import MessageType; correct debug manager API usage and remove stray insertion; build.}

* Respect relay toggle; helper to log relay with deviceId.}

* PacketRelayManager: add relay toggle and unified logging helper; clean file header comments; build fixes.}

* changes

* Geohash local echo: do not add to mesh timeline; rely on geohash history and ChatScreen display for location channels.

* persisting

* persist debug settings

* gitignore

* gatt server / client controls

* debugger

* max connection settings and graph

* more graph

* better logging

* refactor logging
This commit is contained in:
callebtc
2025-09-05 15:40:39 +02:00
committed by GitHub
parent 91f3f270d4
commit f47819a31e
19 changed files with 1318 additions and 79 deletions
+1
View File
@@ -48,6 +48,7 @@ gen/
*~
*.swp
*.lock
.goosehints
# Google services
google-services.json
@@ -32,6 +32,9 @@ class BitchatApplication : Application() {
// Initialize theme preference
ThemePreferenceManager.init(this)
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// TorManager already initialized above
}
}
@@ -6,6 +6,7 @@ import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
/**
* Power-optimized Bluetooth connection manager with comprehensive memory management
@@ -86,6 +87,44 @@ class BluetoothConnectionManager(
init {
powerManager.delegate = this
// Observe debug settings to enforce role state while active
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Role enable/disable
connectionScope.launch {
dbg.gattServerEnabled.collect { enabled ->
if (!isActive) return@collect
if (enabled) startServer() else stopServer()
}
}
connectionScope.launch {
dbg.gattClientEnabled.collect { enabled ->
if (!isActive) return@collect
if (enabled) startClient() else stopClient()
}
}
// Connection caps: enforce on change
connectionScope.launch {
dbg.maxConnectionsOverall.collect {
if (!isActive) return@collect
connectionTracker.enforceConnectionLimits()
// Also enforce server side best-effort
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
}
}
connectionScope.launch {
dbg.maxClientConnections.collect {
if (!isActive) return@collect
connectionTracker.enforceConnectionLimits()
}
}
connectionScope.launch {
dbg.maxServerConnections.collect {
if (!isActive) return@collect
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
}
}
} catch (_: Exception) { }
}
/**
@@ -125,18 +164,29 @@ class BluetoothConnectionManager(
// Start power manager
powerManager.start()
// Start server manager
if (!serverManager.start()) {
Log.e(TAG, "Failed to start server manager")
this@BluetoothConnectionManager.isActive = false
return@launch
// Start server/client based on debug settings
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val startServer = dbg?.gattServerEnabled?.value != false
val startClient = dbg?.gattClientEnabled?.value != false
if (startServer) {
if (!serverManager.start()) {
Log.e(TAG, "Failed to start server manager")
this@BluetoothConnectionManager.isActive = false
return@launch
}
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
// Start client manager
if (!clientManager.start()) {
Log.e(TAG, "Failed to start client manager")
this@BluetoothConnectionManager.isActive = false
return@launch
if (startClient) {
if (!clientManager.start()) {
Log.e(TAG, "Failed to start client manager")
this@BluetoothConnectionManager.isActive = false
return@launch
}
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
Log.i(TAG, "Bluetooth services started successfully")
@@ -198,6 +248,58 @@ class BluetoothConnectionManager(
)
}
// Expose role controls for debug UI
fun startServer() { connectionScope.launch { serverManager.start() } }
fun stopServer() { connectionScope.launch { serverManager.stop() } }
fun startClient() { connectionScope.launch { clientManager.start() } }
fun stopClient() { connectionScope.launch { clientManager.stop() } }
// Inject nickname resolver for broadcaster logs
fun setNicknameResolver(resolver: (String) -> String?) { packetBroadcaster.setNicknameResolver(resolver) }
// Debug snapshots for connected devices
fun getConnectedDeviceEntries(): List<Triple<String, Boolean, Int?>> {
return try {
connectionTracker.getConnectedDevices().values.map { dc ->
val rssi = if (dc.rssi != Int.MIN_VALUE) dc.rssi else null
Triple(dc.device.address, dc.isClient, rssi)
}
} catch (e: Exception) {
emptyList()
}
}
// Expose local adapter address for debug UI
fun getLocalAdapterAddress(): String? = try { bluetoothAdapter?.address } catch (e: Exception) { null }
fun isClientConnection(address: String): Boolean? {
return try { connectionTracker.getConnectedDevices()[address]?.isClient } catch (e: Exception) { null }
}
/**
* Public: connect/disconnect helpers for debug UI
*/
fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
// Optionally disconnect all connections (server and client)
fun disconnectAll() {
connectionScope.launch {
// Stop and restart to force disconnects
clientManager.stop()
serverManager.stop()
delay(200)
if (isActive) {
// Restart managers if service is active
serverManager.start()
clientManager.start()
}
}
}
/**
* Get connected device count
*/
@@ -230,20 +332,35 @@ class BluetoothConnectionManager(
// Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings
serverManager.restartAdvertising()
// Update advertising with new power settings if server enabled
val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
if (serverEnabled) {
serverManager.restartAdvertising()
} else {
serverManager.stop()
}
// Only restart scanning if the duty cycle behavior changed
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
clientManager.restartScanning()
val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
if (clientEnabled) {
clientManager.restartScanning()
} else {
clientManager.stop()
}
} else {
Log.d(TAG, "Duty cycle behavior unchanged, keeping existing scan state")
}
// Enforce connection limits
connectionTracker.enforceConnectionLimits()
// Best-effort server cap
try {
val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value
serverManager.enforceServerLimit(maxServer)
} catch (_: Exception) { }
}
}
@@ -201,6 +201,17 @@ class BluetoothConnectionTracker(
}
}
/**
* Disconnect a specific device (by MAC address)
*/
fun disconnectDevice(deviceAddress: String) {
connectedDevices[deviceAddress]?.gatt?.let {
try { it.disconnect() } catch (_: Exception) { }
}
cleanupDeviceConnection(deviceAddress)
Log.d(TAG, "Requested disconnect for $deviceAddress")
}
/**
* Remove a pending connection
*/
@@ -224,19 +235,38 @@ class BluetoothConnectionTracker(
* Enforce connection limits by disconnecting oldest connections
*/
fun enforceConnectionLimits() {
val maxConnections = powerManager.getMaxConnections()
if (connectedDevices.size > maxConnections) {
Log.i(TAG, "Enforcing connection limit: ${connectedDevices.size} > $maxConnections")
// Disconnect oldest client connections first
val sortedConnections = connectedDevices.values
.filter { it.isClient }
// Read debug overrides if available
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
val maxServer = dbg?.maxServerConnections?.value ?: maxOverall
val clients = connectedDevices.values.filter { it.isClient }
val servers = connectedDevices.values.filter { !it.isClient }
// Enforce client cap first (we can actively disconnect)
if (clients.size > maxClient) {
Log.i(TAG, "Enforcing client cap: ${clients.size} > $maxClient")
val toDisconnect = clients.sortedBy { it.connectedAt }.take(clients.size - maxClient)
toDisconnect.forEach { dc ->
Log.d(TAG, "Disconnecting client ${dc.device.address} due to client cap")
dc.gatt?.disconnect()
}
}
// Note: server cap enforced in GattServerManager (we don't have server handle here)
// Enforce overall cap by disconnecting oldest client connections
if (connectedDevices.size > maxOverall) {
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
val excess = connectedDevices.size - maxOverall
val toDisconnect = connectedDevices.values
.filter { it.isClient } // only clients from here
.sortedBy { it.connectedAt }
val toDisconnect = sortedConnections.take(connectedDevices.size - maxConnections)
toDisconnect.forEach { deviceConn ->
Log.d(TAG, "Disconnecting ${deviceConn.device.address} due to connection limit")
deviceConn.gatt?.disconnect()
.take(excess)
toDisconnect.forEach { dc ->
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
dc.gatt?.disconnect()
}
}
}
@@ -341,4 +371,4 @@ class BluetoothConnectionTracker(
}
}
}
}
}
@@ -14,6 +14,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
import kotlinx.coroutines.Job
import com.bitchat.android.ui.debug.DebugSettingsManager
import com.bitchat.android.ui.debug.DebugScanResult
/**
* Manages GATT client operations, scanning, and client-side connections
@@ -44,6 +46,21 @@ class BluetoothGattClientManager(
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
/**
* Public: Connect to a device by MAC address (for debug UI)
*/
fun connectToAddress(deviceAddress: String): Boolean {
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
return if (device != null) {
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
connectToDevice(device, rssi)
true
} else {
Log.w(TAG, "connectToAddress: No device for $deviceAddress")
false
}
}
// Scan management
private var scanCallback: ScanCallback? = null
@@ -63,6 +80,18 @@ class BluetoothGattClientManager(
* Start client manager
*/
fun start(): Boolean {
// Respect debug setting
try {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
return false
}
} catch (_: Exception) { }
if (isActive) {
Log.d(TAG, "GATT client already active; start is a no-op")
return true
}
if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
return false
@@ -98,9 +127,25 @@ class BluetoothGattClientManager(
* Stop client manager
*/
fun stop() {
if (!isActive) {
// Idempotent stop
stopScanning()
stopRSSIMonitoring()
Log.i(TAG, "GATT client manager stopped (already inactive)")
return
}
isActive = false
connectionScope.launch {
// Disconnect all client connections decisively
try {
val conns = connectionTracker.getConnectedDevices().values.filter { it.isClient && it.gatt != null }
conns.forEach { dc ->
try { dc.gatt?.disconnect() } catch (_: Exception) { }
}
} catch (_: Exception) { }
stopScanning()
stopRSSIMonitoring()
Log.i(TAG, "GATT client manager stopped")
@@ -111,7 +156,8 @@ class BluetoothGattClientManager(
* Handle scan state changes from power manager
*/
fun onScanStateChanged(shouldScan: Boolean) {
if (shouldScan) {
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
if (shouldScan && enabled) {
startScanning()
} else {
stopScanning()
@@ -158,7 +204,9 @@ class BluetoothGattClientManager(
*/
@Suppress("DEPRECATION")
private fun startScanning() {
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive) return
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
// Rate limit scan starts to prevent "scanning too frequently" errors
val currentTime = System.currentTimeMillis()
@@ -282,10 +330,34 @@ class BluetoothGattClientManager(
// Store RSSI from scan results for later use (especially for server connections)
connectionTracker.updateScanRSSI(deviceAddress, rssi)
// Publish scan result to debug UI buffer
try {
DebugSettingsManager.getInstance().addScanResult(
DebugScanResult(
deviceName = device.name,
deviceAddress = deviceAddress,
rssi = rssi,
peerID = null // peerID unknown at scan time
)
)
} catch (_: Exception) { }
// Power-aware RSSI filtering
if (rssi < powerManager.getRSSIThreshold()) {
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
// Even if we skip connecting, still publish scan result to debug UI
try {
val pid: String? = null // We don't know peerID until packet exchange
DebugSettingsManager.getInstance().addScanResult(
DebugScanResult(
deviceName = device.name,
deviceAddress = deviceAddress,
rssi = rssi,
peerID = pid
)
)
} catch (_: Exception) { }
return
}
@@ -474,7 +546,9 @@ class BluetoothGattClientManager(
* Restart scanning for power mode changes
*/
fun restartScanning() {
if (!isActive) return
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
if (!isActive || !enabled) return
connectionScope.launch {
stopScanning()
@@ -489,4 +563,4 @@ class BluetoothGattClientManager(
}
}
}
}
}
@@ -47,11 +47,37 @@ class BluetoothGattServerManager(
// State management
private var isActive = false
// Enforce a server connection limit by canceling the oldest connections (best-effort)
fun enforceServerLimit(maxServer: Int) {
if (maxServer <= 0) return
try {
val subs = connectionTracker.getSubscribedDevices()
if (subs.size > maxServer) {
val excess = subs.size - maxServer
subs.take(excess).forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
}
}
} catch (_: Exception) { }
}
/**
* Start GATT server
*/
fun start(): Boolean {
// Respect debug setting
try {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
return false
}
} catch (_: Exception) { }
if (isActive) {
Log.d(TAG, "GATT server already active; start is a no-op")
return true
}
if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
return false
@@ -82,11 +108,29 @@ class BluetoothGattServerManager(
* Stop GATT server
*/
fun stop() {
if (!isActive) {
// Idempotent stop
stopAdvertising()
// Ensure server is closed if present
gattServer?.close()
gattServer = null
Log.i(TAG, "GATT server stopped (already inactive)")
return
}
isActive = false
connectionScope.launch {
stopAdvertising()
// Try to cancel any active connections explicitly before closing
try {
val devices = connectionTracker.getSubscribedDevices()
devices.forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
}
} catch (_: Exception) { }
// Close GATT server
gattServer?.close()
gattServer = null
@@ -281,7 +325,9 @@ class BluetoothGattServerManager(
*/
@Suppress("DEPRECATION")
private fun startAdvertising() {
if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null || !isActive || bluetoothAdapter == null || !bluetoothAdapter.isMultipleAdvertisementSupported()) {
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null || !isActive || bluetoothAdapter == null || !bluetoothAdapter.isMultipleAdvertisementSupported() || !enabled) {
throw Exception("Missing Bluetooth permissions or BLE advertiser not available")
}
@@ -327,12 +373,17 @@ class BluetoothGattServerManager(
* Restart advertising (for power mode changes)
*/
fun restartAdvertising() {
if (!isActive) return
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
if (!isActive || !enabled) {
stopAdvertising()
return
}
connectionScope.launch {
stopAdvertising()
delay(100)
startAdvertising()
}
}
}
}
@@ -30,6 +30,7 @@ import kotlin.random.Random
* - PacketProcessor: Incoming packet routing
*/
class BluetoothMeshService(private val context: Context) {
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
private const val TAG = "BluetoothMeshService"
@@ -103,6 +104,10 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
} catch (_: Exception) { }
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
@@ -410,6 +415,14 @@ class BluetoothMeshService(private val context: Context) {
delay(200)
sendBroadcastAnnounce()
}
// Verbose debug: device connected
try {
val addr = device.address
val peer = connectionManager.addressPeerMap[addr]
val nick = peer?.let { peerManager.getPeerNickname(it) } ?: "unknown"
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
.logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!)
} catch (_: Exception) { }
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
@@ -424,6 +437,12 @@ class BluetoothMeshService(private val context: Context) {
// Peer might still be reachable indirectly; mark as not-direct
try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { }
}
// Verbose debug: device disconnected
try {
val nick = peerManager.getPeerNickname(peer) ?: "unknown"
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
.logPeerDisconnection(peer, nick, addr)
} catch (_: Exception) { }
}
}
@@ -7,6 +7,8 @@ import android.bluetooth.BluetoothGattServer
import android.util.Log
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -44,6 +46,47 @@ class BluetoothPacketBroadcaster(
private const val TAG = "BluetoothPacketBroadcaster"
private const val CLEANUP_DELAY = 500L
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
private var nicknameResolver: ((String) -> String?)? = null
fun setNicknameResolver(resolver: (String) -> String?) {
nicknameResolver = resolver
}
/**
* Debug logging helper - can be easily removed/disabled for production
*/
private fun logPacketRelay(
typeName: String,
senderPeerID: String,
senderNick: String?,
incomingPeer: String?,
incomingAddr: String?,
toPeer: String?,
toDeviceAddress: String,
ttl: UByte
) {
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
fromPeerID = incomingPeer,
fromNickname = fromNick,
fromDeviceAddress = incomingAddr,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl
)
} catch (_: Exception) {
// Silently ignore debug logging failures
}
}
// Data class to hold broadcast request information
private data class BroadcastRequest(
@@ -127,6 +170,11 @@ class BluetoothPacketBroadcaster(
) {
val packet = routed.packet
val data = packet.toBinaryData() ?: return
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
if (packet.recipientID != SpecialRecipients.BROADCAST) {
val recipientID = packet.recipientID?.let {
@@ -140,8 +188,11 @@ class BluetoothPacketBroadcaster(
// If found, send directly
if (targetDevice != null) {
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
if (notifyDevice(targetDevice, data, gattServer, characteristic))
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl)
return // Sent, no need to continue
}
}
// Try to find the recipient in client connections (connectedDevices)
@@ -151,8 +202,11 @@ class BluetoothPacketBroadcaster(
// If found, send directly
if (targetDeviceConn != null) {
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
if (writeToDeviceConn(targetDeviceConn, data))
if (writeToDeviceConn(targetDeviceConn, data)) {
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl)
return // Sent, no need to continue
}
}
}
@@ -174,7 +228,11 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Skipping broadcast to client back to sender: ${device.address}")
return@forEach
}
notifyDevice(device, data, gattServer, characteristic)
val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl)
}
}
// Send to client connections (GATT servers we are connected to)
@@ -188,7 +246,11 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Skipping roadcast to server back to sender: ${deviceConn.device.address}")
return@forEach
}
writeToDeviceConn(deviceConn, data)
val sent = writeToDeviceConn(deviceConn, data)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl)
}
}
}
}
@@ -268,4 +330,4 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "BluetoothPacketBroadcaster shutdown complete")
}
}
}
@@ -16,6 +16,7 @@ import kotlinx.coroutines.channels.actor
* from the same peer simultaneously, causing session management conflicts.
*/
class PacketProcessor(private val myPeerID: String) {
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
private const val TAG = "PacketProcessor"
@@ -128,8 +129,16 @@ class PacketProcessor(private val myPeerID: String) {
}
var validPacket = true
Log.d(TAG, "Processing packet type ${MessageType.fromValue(packet.type)} from ${formatPeerForLog(peerID)}")
val messageType = MessageType.fromValue(packet.type)
Log.d(TAG, "Processing packet type ${messageType} from ${formatPeerForLog(peerID)}")
// Verbose logging to debug manager (and chat via ChatViewModel observer)
try {
val mt = messageType?.name ?: packet.type.toString()
val routeDevice = routed.relayAddress
val nick = delegate?.getPeerNickname(peerID)
debugManager?.logIncomingPacket(peerID, nick, mt, routeDevice)
} catch (_: Exception) { }
// Handle public packet types (no address check needed)
when (messageType) {
@@ -1,4 +1,5 @@
package com.bitchat.android.mesh
import com.bitchat.android.protocol.MessageType
import android.util.Log
import com.bitchat.android.model.RoutedPacket
@@ -14,11 +15,18 @@ import kotlin.random.Random
* All packets that aren't specifically addressed to us get processed here.
*/
class PacketRelayManager(private val myPeerID: String) {
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
private const val TAG = "PacketRelayManager"
}
private fun isRelayEnabled(): Boolean = try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().packetRelayEnabled.value
} catch (_: Exception) { true }
// Logging moved to BluetoothPacketBroadcaster per actual transmission target
// Delegate for callbacks
var delegate: PacketRelayManagerDelegate? = null
@@ -33,7 +41,7 @@ class PacketRelayManager(private val myPeerID: String) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Evaluating relay for packet type ${packet.type} from $peerID (TTL: ${packet.ttl})")
Log.d(TAG, "Evaluating relay for packet type ${'$'}{packet.type} from ${'$'}peerID (TTL: ${'$'}{packet.ttl})")
// Double-check this packet isn't addressed to us
if (isPacketAddressedToMe(packet)) {
@@ -55,15 +63,15 @@ class PacketRelayManager(private val myPeerID: String) {
// Decrement TTL by 1
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
Log.d(TAG, "Decremented TTL from ${'$'}{packet.ttl} to ${'$'}{relayPacket.ttl}")
// Apply relay logic based on packet type
val shouldRelay = shouldRelayPacket(relayPacket, peerID)
// Apply relay logic based on packet type and debug switch
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
if (shouldRelay) {
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
} else {
Log.d(TAG, "Relay decision: NOT relaying packet type ${packet.type}")
Log.d(TAG, "Relay decision: NOT relaying packet type ${'$'}{packet.type}")
}
}
@@ -95,7 +103,7 @@ class PacketRelayManager(private val myPeerID: String) {
private fun shouldRelayPacket(packet: BitchatPacket, fromPeerID: String): Boolean {
// Always relay if TTL is high enough (indicates important message)
if (packet.ttl >= 4u) {
Log.d(TAG, "High TTL (${packet.ttl}), relaying")
Log.d(TAG, "High TTL (${ '$' }{packet.ttl}), relaying")
return true
}
@@ -104,7 +112,7 @@ class PacketRelayManager(private val myPeerID: String) {
// Small networks always relay to ensure connectivity
if (networkSize <= 3) {
Log.d(TAG, "Small network ($networkSize peers), relaying")
Log.d(TAG, "Small network (${ '$' }networkSize peers), relaying")
return true
}
@@ -118,7 +126,7 @@ class PacketRelayManager(private val myPeerID: String) {
}
val shouldRelay = Random.nextDouble() < relayProb
Log.d(TAG, "Network size: $networkSize, Relay probability: $relayProb, Decision: $shouldRelay")
Log.d(TAG, "Network size: ${'$'}networkSize, Relay probability: ${'$'}relayProb, Decision: ${'$'}shouldRelay")
return shouldRelay
}
@@ -151,11 +159,11 @@ class PacketRelayManager(private val myPeerID: String) {
if (shouldRelay) {
val delay = Random.nextLong(50, 500) // Random delay like iOS
Log.d(TAG, "Relaying message after ${delay}ms delay")
Log.d(TAG, "Relaying message after ${'$'}delay ms delay")
delay(delay)
relayPacket(routed.copy(packet = relayPacket))
} else {
Log.d(TAG, "Relay decision: NOT relaying message (network size: $networkSize, prob: $relayProb)")
Log.d(TAG, "Relay decision: NOT relaying message (network size: ${'$'}networkSize, prob: ${'$'}relayProb)")
}
}
@@ -163,7 +171,7 @@ class PacketRelayManager(private val myPeerID: String) {
* Actually broadcast the packet for relay
*/
private fun relayPacket(routed: RoutedPacket) {
Log.d(TAG, "🔄 Relaying packet type ${routed.packet.type} with TTL ${routed.packet.ttl}")
Log.d(TAG, "🔄 Relaying packet type ${'$'}{routed.packet.type} with TTL ${'$'}{routed.packet.ttl}")
delegate?.broadcastPacket(routed)
}
@@ -173,9 +181,9 @@ class PacketRelayManager(private val myPeerID: String) {
fun getDebugInfo(): String {
return buildString {
appendLine("=== Packet Relay Manager Debug Info ===")
appendLine("Relay Scope Active: ${relayScope.isActive}")
appendLine("My Peer ID: $myPeerID")
appendLine("Network Size: ${delegate?.getNetworkSize() ?: "unknown"}")
appendLine("Relay Scope Active: ${'$'}{relayScope.isActive}")
appendLine("My Peer ID: ${'$'}myPeerID")
appendLine("Network Size: ${'$'}{delegate?.getNetworkSize() ?: \"unknown\"}")
}
}
@@ -258,11 +258,12 @@ class NostrGeohashService(
powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null
)
// Store and display the message immediately
// Store immediately; UI will display from geohash history (not main mesh timeline)
storeGeohashMessage(channel.geohash, localMessage)
messageManager.addMessage(localMessage)
// IMPORTANT: Do not add to main mesh timeline to avoid duplication in mesh chat view
// messageManager.addMessage(localMessage)
Log.d(TAG, "📝 Added message immediately with temp ID: $tempMessageId")
Log.d(TAG, "📝 Added geohash local echo with temp ID: $tempMessageId (not shown in mesh timeline)")
// Check if PoW is enabled before starting animation
val powSettings = PoWPreferenceManager.getCurrentSettings()
@@ -726,6 +727,13 @@ class NostrGeohashService(
messageManager.addMessage(message)
}
}
/**
* Get stored messages for a geohash without mutating UI state
*/
fun getGeohashMessages(geohash: String): List<BitchatMessage> {
return geohashMessageHistory[geohash]?.toList() ?: emptyList()
}
/**
* Clear geohash message history
@@ -986,9 +994,8 @@ class NostrGeohashService(
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
// STEP 1: Immediate UI updates (synchronous, no blocking)
try {
// Clear all displayed messages and load stored messages for the new channel
messageManager.clearMessages()
Log.d(TAG, "🗑️ Cleared all messages for channel switch")
// NOTE: Don't clear messages here - let ChatScreen's displayMessages logic handle what to show
// This preserves mesh message history when switching between views
when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -1013,9 +1020,7 @@ class NostrGeohashService(
// Clear notifications for this geohash since user is now viewing it
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
// Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes
// Load stored messages for this geohash immediately
loadGeohashMessages(channel.channel.geohash)
// Note: Don't load messages here - ChatScreen will get them via getGeohashMessages()
// Immediate self-registration for instant UI feedback
try {
@@ -1298,16 +1303,8 @@ class NostrGeohashService(
// Store in geohash history for persistence across channel switches
storeGeohashMessage(geohash, message)
// CRITICAL BUG FIX: Add to message timeline if we're viewing this geohash OR if it matches our selected location channel
// This prevents messages from being lost during channel switching race conditions
val selectedLocationChannel = state.selectedLocationChannel.value
val shouldShowMessage = currentGeohash == geohash ||
(selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location &&
selectedLocationChannel.channel.geohash == geohash)
if (shouldShowMessage) {
withContext(Dispatchers.Main) { messageManager.addMessage(message) }
}
// NOTE: Don't add to main message timeline here - ChatScreen will display geohash messages
// from the separate geohash history via getGeohashMessages()
// NOTIFICATION LOGIC: Check for mentions and first messages
checkAndTriggerGeohashNotifications(geohash, senderName, content, message)
@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.debug.DebugSettingsSheet
/**
* About Sheet for bitchat app information
@@ -33,6 +34,7 @@ import com.bitchat.android.nostr.PoWPreferenceManager
fun AboutSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
onShowDebug: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -47,6 +49,7 @@ fun AboutSheet(
}
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = false
)
@@ -422,6 +425,30 @@ fun AboutSheet(
}
}
// Debug settings button
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Debug button styled to match the app aesthetic
TextButton(
onClick = { onShowDebug?.invoke() },
colors = ButtonDefaults.textButtonColors(
contentColor = colorScheme.onSurface.copy(alpha = 0.6f)
)
) {
Text(
text = "debug settings",
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
// Version and footer space
item {
Column(
@@ -71,11 +71,23 @@ fun ChatScreen(viewModel: ChatViewModel) {
val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
// Determine what messages to show
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
// Determine what messages to show based on current context
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> messages
else -> {
val locationChannel = selectedLocationChannel
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// For geohash channels, get messages from geohash history
val geohash = locationChannel.channel.geohash
viewModel.getGeohashMessages(geohash)
} else {
messages // Mesh/public messages
}
}
}
// Use WindowInsets to handle keyboard properly
@@ -445,10 +457,19 @@ private fun ChatDialogs(
)
// About sheet
var showDebugSheet by remember { mutableStateOf(false) }
AboutSheet(
isPresented = showAppInfo,
onDismiss = onAppInfoDismiss
onDismiss = onAppInfoDismiss,
onShowDebug = { showDebugSheet = true }
)
if (showDebugSheet) {
com.bitchat.android.ui.debug.DebugSettingsSheet(
isPresented = showDebugSheet,
onDismiss = { showDebugSheet = false },
meshService = viewModel.meshService
)
}
// Location channels sheet
if (showLocationChannelsSheet) {
@@ -26,6 +26,7 @@ class ChatViewModel(
application: Application,
val meshService: BluetoothMeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
private const val TAG = "ChatViewModel"
@@ -150,6 +151,22 @@ class ChatViewModel(
// Initialize session state monitoring
initializeSessionStateMonitoring()
// Bridge DebugSettingsManager -> Chat messages when verbose logging is on
viewModelScope.launch {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs ->
if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) {
// Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats
val selectedLocation = state.selectedLocationChannel.value
if (selectedLocation is com.bitchat.android.geohash.ChannelID.Mesh) {
// Append only latest debug message as system message to avoid flooding
msgs.lastOrNull()?.let { dm ->
messageManager.addSystemMessage(dm.content)
}
}
}
}
}
// Initialize location channel state
nostrGeohashService.initializeLocationChannelState()
@@ -620,6 +637,13 @@ class ChatViewModel(
}
}
/**
* Get messages for a specific geohash timeline
*/
fun getGeohashMessages(geohash: String): List<BitchatMessage> {
return nostrGeohashService.getGeohashMessages(geohash)
}
/**
* Get participant count for a specific geohash (5-minute activity window)
*/
@@ -67,7 +67,7 @@ class MeshDelegateHandler(
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
}
} else {
// Public message
// Public mesh message - always store to preserve message history
messageManager.addMessage(message)
// Check for mentions in mesh chat
@@ -23,6 +23,17 @@ class MessageManager(private val state: ChatState) {
currentMessages.add(message)
state.setMessages(currentMessages)
}
// Log a system message into the main chat (visible to user)
fun addSystemMessage(text: String) {
val sys = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false
)
addMessage(sys)
}
fun clearMessages() {
state.setMessages(emptyList())
@@ -0,0 +1,77 @@
package com.bitchat.android.ui.debug
import android.content.Context
import android.content.SharedPreferences
/**
* SharedPreferences-backed persistence for debug settings.
* Keeps the DebugSettingsManager stateless with regard to Android Context.
*/
object DebugPreferenceManager {
private const val PREFS_NAME = "bitchat_debug_settings"
private const val KEY_VERBOSE = "verbose_logging"
private const val KEY_GATT_SERVER = "gatt_server_enabled"
private const val KEY_GATT_CLIENT = "gatt_client_enabled"
private const val KEY_PACKET_RELAY = "packet_relay_enabled"
private const val KEY_MAX_CONN_OVERALL = "max_connections_overall"
private const val KEY_MAX_CONN_SERVER = "max_connections_server"
private const val KEY_MAX_CONN_CLIENT = "max_connections_client"
private lateinit var prefs: SharedPreferences
fun init(context: Context) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun ready(): Boolean = ::prefs.isInitialized
fun getVerboseLogging(default: Boolean = false): Boolean =
if (ready()) prefs.getBoolean(KEY_VERBOSE, default) else default
fun setVerboseLogging(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_VERBOSE, value).apply()
}
fun getGattServerEnabled(default: Boolean = true): Boolean =
if (ready()) prefs.getBoolean(KEY_GATT_SERVER, default) else default
fun setGattServerEnabled(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_GATT_SERVER, value).apply()
}
fun getGattClientEnabled(default: Boolean = true): Boolean =
if (ready()) prefs.getBoolean(KEY_GATT_CLIENT, default) else default
fun setGattClientEnabled(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_GATT_CLIENT, value).apply()
}
fun getPacketRelayEnabled(default: Boolean = true): Boolean =
if (ready()) prefs.getBoolean(KEY_PACKET_RELAY, default) else default
fun setPacketRelayEnabled(value: Boolean) {
if (ready()) prefs.edit().putBoolean(KEY_PACKET_RELAY, value).apply()
}
// Optional connection limits (0 or missing => use defaults)
fun getMaxConnectionsOverall(default: Int = 8): Int =
if (ready()) prefs.getInt(KEY_MAX_CONN_OVERALL, default) else default
fun setMaxConnectionsOverall(value: Int) {
if (ready()) prefs.edit().putInt(KEY_MAX_CONN_OVERALL, value).apply()
}
fun getMaxConnectionsServer(default: Int = 8): Int =
if (ready()) prefs.getInt(KEY_MAX_CONN_SERVER, default) else default
fun setMaxConnectionsServer(value: Int) {
if (ready()) prefs.edit().putInt(KEY_MAX_CONN_SERVER, value).apply()
}
fun getMaxConnectionsClient(default: Int = 8): Int =
if (ready()) prefs.getInt(KEY_MAX_CONN_CLIENT, default) else default
fun setMaxConnectionsClient(value: Int) {
if (ready()) prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply()
}
}
@@ -0,0 +1,368 @@
package com.bitchat.android.ui.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Date
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Debug settings manager for controlling debug features and collecting debug data
*/
class DebugSettingsManager private constructor() {
// NOTE: This singleton is referenced from mesh layer. Keep in ui.debug but avoid Compose deps.
companion object {
@Volatile
private var INSTANCE: DebugSettingsManager? = null
fun getInstance(): DebugSettingsManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: DebugSettingsManager().also { INSTANCE = it }
}
}
}
// Debug settings state
private val _verboseLoggingEnabled = MutableStateFlow(false)
val verboseLoggingEnabled: StateFlow<Boolean> = _verboseLoggingEnabled.asStateFlow()
private val _gattServerEnabled = MutableStateFlow(true)
val gattServerEnabled: StateFlow<Boolean> = _gattServerEnabled.asStateFlow()
private val _gattClientEnabled = MutableStateFlow(true)
val gattClientEnabled: StateFlow<Boolean> = _gattClientEnabled.asStateFlow()
private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
// Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
private val _maxServerConnections = MutableStateFlow(8)
val maxServerConnections: StateFlow<Int> = _maxServerConnections.asStateFlow()
private val _maxClientConnections = MutableStateFlow(8)
val maxClientConnections: StateFlow<Int> = _maxClientConnections.asStateFlow()
init {
// Load persisted defaults (if preference manager already initialized)
try {
_verboseLoggingEnabled.value = DebugPreferenceManager.getVerboseLogging(false)
_gattServerEnabled.value = DebugPreferenceManager.getGattServerEnabled(true)
_gattClientEnabled.value = DebugPreferenceManager.getGattClientEnabled(true)
_packetRelayEnabled.value = DebugPreferenceManager.getPacketRelayEnabled(true)
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
_maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8)
} catch (_: Exception) {
// Preferences not ready yet; keep defaults. They will be applied on first change.
}
}
// Debug data collections
private val _debugMessages = MutableStateFlow<List<DebugMessage>>(emptyList())
val debugMessages: StateFlow<List<DebugMessage>> = _debugMessages.asStateFlow()
private val _scanResults = MutableStateFlow<List<DebugScanResult>>(emptyList())
val scanResults: StateFlow<List<DebugScanResult>> = _scanResults.asStateFlow()
private val _connectedDevices = MutableStateFlow<List<ConnectedDevice>>(emptyList())
val connectedDevices: StateFlow<List<ConnectedDevice>> = _connectedDevices.asStateFlow()
// Packet relay statistics
private val _relayStats = MutableStateFlow(PacketRelayStats())
val relayStats: StateFlow<PacketRelayStats> = _relayStats.asStateFlow()
// Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
// Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
private fun updateRelayStatsFromTimestamps() {
val now = System.currentTimeMillis()
// prune older than 15m
while (true) {
val head = relayTimestamps.peek() ?: break
if (now - head > 15 * 60 * 1000L) {
relayTimestamps.poll()
} else break
}
val last1s = relayTimestamps.count { now - it <= 1_000L }
val last10s = relayTimestamps.count { now - it <= 10_000L }
val last1m = relayTimestamps.count { now - it <= 60_000L }
val last15m = relayTimestamps.size
val total = _relayStats.value.totalRelaysCount + 1
_relayStats.value = PacketRelayStats(
totalRelaysCount = total,
lastSecondRelays = last1s,
last10SecondRelays = last10s,
lastMinuteRelays = last1m,
last15MinuteRelays = last15m,
lastResetTime = _relayStats.value.lastResetTime
)
}
// MARK: - Setting Controls
fun setVerboseLoggingEnabled(enabled: Boolean) {
DebugPreferenceManager.setVerboseLogging(enabled)
_verboseLoggingEnabled.value = enabled
if (enabled) {
addDebugMessage(DebugMessage.SystemMessage("🔊 Verbose logging enabled"))
} else {
addDebugMessage(DebugMessage.SystemMessage("🔇 Verbose logging disabled"))
}
}
fun setGattServerEnabled(enabled: Boolean) {
DebugPreferenceManager.setGattServerEnabled(enabled)
_gattServerEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(
if (enabled) "🟢 GATT Server enabled" else "🔴 GATT Server disabled"
))
}
fun setGattClientEnabled(enabled: Boolean) {
DebugPreferenceManager.setGattClientEnabled(enabled)
_gattClientEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(
if (enabled) "🟢 GATT Client enabled" else "🔴 GATT Client disabled"
))
}
fun setPacketRelayEnabled(enabled: Boolean) {
DebugPreferenceManager.setPacketRelayEnabled(enabled)
_packetRelayEnabled.value = enabled
addDebugMessage(DebugMessage.SystemMessage(
if (enabled) "📡 Packet relay enabled" else "🚫 Packet relay disabled"
))
}
fun setMaxConnectionsOverall(value: Int) {
val clamped = value.coerceIn(1, 32)
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
_maxConnectionsOverall.value = clamped
addDebugMessage(DebugMessage.SystemMessage("🔢 Max overall connections set to $clamped"))
}
fun setMaxServerConnections(value: Int) {
val clamped = value.coerceIn(1, 32)
DebugPreferenceManager.setMaxConnectionsServer(clamped)
_maxServerConnections.value = clamped
addDebugMessage(DebugMessage.SystemMessage("🖥️ Max server connections set to $clamped"))
}
fun setMaxClientConnections(value: Int) {
val clamped = value.coerceIn(1, 32)
DebugPreferenceManager.setMaxConnectionsClient(clamped)
_maxClientConnections.value = clamped
addDebugMessage(DebugMessage.SystemMessage("📱 Max client connections set to $clamped"))
}
// MARK: - Debug Data Collection
fun addDebugMessage(message: DebugMessage) {
if (!verboseLoggingEnabled.value && message !is DebugMessage.SystemMessage) {
return // Only show system messages when verbose logging is disabled
}
debugMessageQueue.offer(message)
// Keep only last 200 messages to prevent memory issues
while (debugMessageQueue.size > 200) {
debugMessageQueue.poll()
}
_debugMessages.value = debugMessageQueue.toList()
}
fun addScanResult(scanResult: DebugScanResult) {
// De-duplicate by device address; keep most recent
if (scanResultsQueue.isNotEmpty()) {
val toRemove = scanResultsQueue.filter { it.deviceAddress == scanResult.deviceAddress }
toRemove.forEach { scanResultsQueue.remove(it) }
}
scanResultsQueue.offer(scanResult)
// Keep only last 100 unique scan results
while (scanResultsQueue.size > 100) {
scanResultsQueue.poll()
}
_scanResults.value = scanResultsQueue.toList()
}
fun updateConnectedDevices(devices: List<ConnectedDevice>) {
_connectedDevices.value = devices
}
fun updateRelayStats(stats: PacketRelayStats) {
_relayStats.value = stats
}
// MARK: - Debug Message Creation Helpers
fun logPeerConnection(peerID: String, nickname: String, deviceID: String, isInbound: Boolean) {
if (verboseLoggingEnabled.value) {
val direction = if (isInbound) "connected to our server" else "we connected as client"
addDebugMessage(DebugMessage.PeerEvent(
"🔗 $nickname ($peerID) $direction via device $deviceID"
))
}
}
fun logPeerDisconnection(peerID: String, nickname: String, deviceID: String) {
if (verboseLoggingEnabled.value) {
addDebugMessage(DebugMessage.PeerEvent(
"$nickname ($peerID) disconnected from device $deviceID"
))
}
}
fun logIncomingPacket(senderPeerID: String, senderNickname: String?, messageType: String, viaDeviceId: String?) {
if (verboseLoggingEnabled.value) {
val who = if (!senderNickname.isNullOrBlank()) "$senderNickname ($senderPeerID)" else senderPeerID
val routeInfo = if (!viaDeviceId.isNullOrBlank()) " via $viaDeviceId" else " (direct)"
addDebugMessage(DebugMessage.PacketEvent(
"📥 Received $messageType from $who$routeInfo"
))
}
}
fun logPacketRelay(
packetType: String,
originalPeerID: String,
originalNickname: String?,
viaDeviceId: String?
) {
// Backward-compatible simple API; delegate to detailed formatter with best effort
logPacketRelayDetailed(
packetType = packetType,
senderPeerID = originalPeerID,
senderNickname = originalNickname,
fromPeerID = null,
fromNickname = null,
fromDeviceAddress = viaDeviceId,
toPeerID = null,
toNickname = null,
toDeviceAddress = null,
ttl = null
)
}
// New, more detailed relay logger used by the mesh/broadcaster
fun logPacketRelayDetailed(
packetType: String,
senderPeerID: String?,
senderNickname: String?,
fromPeerID: String?,
fromNickname: String?,
fromDeviceAddress: String?,
toPeerID: String?,
toNickname: String?,
toDeviceAddress: String?,
ttl: UByte?
) {
// Build message only if verbose logging is enabled, but always update stats
val senderLabel = when {
!senderNickname.isNullOrBlank() && !senderPeerID.isNullOrBlank() -> "$senderNickname ($senderPeerID)"
!senderNickname.isNullOrBlank() -> senderNickname
!senderPeerID.isNullOrBlank() -> senderPeerID
else -> "unknown"
}
val fromName = when {
!fromNickname.isNullOrBlank() -> fromNickname
!fromPeerID.isNullOrBlank() -> fromPeerID
else -> "unknown"
}
val toName = when {
!toNickname.isNullOrBlank() -> toNickname
!toPeerID.isNullOrBlank() -> toPeerID
else -> "unknown"
}
val fromAddr = fromDeviceAddress ?: "?"
val toAddr = toDeviceAddress ?: "?"
val ttlStr = ttl?.toString() ?: "?"
if (verboseLoggingEnabled.value) {
addDebugMessage(
DebugMessage.RelayEvent(
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
)
)
}
// Update rolling statistics
relayTimestamps.offer(System.currentTimeMillis())
updateRelayStatsFromTimestamps()
}
// MARK: - Clear Data
fun clearDebugMessages() {
debugMessageQueue.clear()
_debugMessages.value = emptyList()
addDebugMessage(DebugMessage.SystemMessage("🗑️ Debug messages cleared"))
}
fun clearScanResults() {
scanResultsQueue.clear()
_scanResults.value = emptyList()
addDebugMessage(DebugMessage.SystemMessage("🗑️ Scan results cleared"))
}
}
// MARK: - Data Models
/**
* Different types of debug messages for categorization and formatting
*/
sealed class DebugMessage(val content: String, val timestamp: Date = Date()) {
class SystemMessage(content: String) : DebugMessage("⚙️ $content")
class PeerEvent(content: String) : DebugMessage(content)
class PacketEvent(content: String) : DebugMessage(content)
class RelayEvent(content: String) : DebugMessage(content)
}
/**
* Scan result data for debugging
*/
data class DebugScanResult(
val deviceName: String?,
val deviceAddress: String,
val rssi: Int,
val peerID: String?,
val timestamp: Date = Date()
)
/**
* Connected device information for debugging
*/
data class ConnectedDevice(
val deviceAddress: String,
val peerID: String?,
val nickname: String?,
val rssi: Int?,
val connectionType: ConnectionType,
val isDirectConnection: Boolean
)
enum class ConnectionType {
GATT_SERVER,
GATT_CLIENT
}
/**
* Packet relay statistics for monitoring network activity
*/
data class PacketRelayStats(
val totalRelaysCount: Long = 0,
val lastSecondRelays: Int = 0,
val last10SecondRelays: Int = 0,
val lastMinuteRelays: Int = 0,
val last15MinuteRelays: Int = 0,
val lastResetTime: Date = Date()
)
@@ -0,0 +1,340 @@
package com.bitchat.android.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.draw.rotate
import com.bitchat.android.mesh.BluetoothMeshService
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugSettingsSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
meshService: BluetoothMeshService
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val colorScheme = MaterialTheme.colorScheme
val manager = remember { DebugSettingsManager.getInstance() }
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
val gattClientEnabled by manager.gattClientEnabled.collectAsState()
val packetRelayEnabled by manager.packetRelayEnabled.collectAsState()
val maxOverall by manager.maxConnectionsOverall.collectAsState()
val maxServer by manager.maxServerConnections.collectAsState()
val maxClient by manager.maxClientConnections.collectAsState()
val debugMessages by manager.debugMessages.collectAsState()
val scanResults by manager.scanResults.collectAsState()
val connectedDevices by manager.connectedDevices.collectAsState()
val relayStats by manager.relayStats.collectAsState()
// Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) {
if (isPresented) {
// Poll device list periodically for now (TODO: add callbacks)
while (true) {
val entries = meshService.connectionManager.getConnectedDeviceEntries()
val mapping = meshService.getDeviceAddressToPeerMapping()
val peers = mapping.values.toSet()
val nicknames = meshService.getPeerNicknames()
val directMap = peers.associateWith { pid -> meshService.getPeerInfo(pid)?.isDirectConnection == true }
val devices = entries.map { (address, isClient, rssi) ->
val pid = mapping[address]
com.bitchat.android.ui.debug.ConnectedDevice(
deviceAddress = address,
peerID = pid,
nickname = pid?.let { nicknames[it] },
rssi = rssi,
connectionType = if (isClient) ConnectionType.GATT_CLIENT else ConnectionType.GATT_SERVER,
isDirectConnection = pid?.let { directMap[it] } ?: false
)
}
manager.updateConnectedDevices(devices)
kotlinx.coroutines.delay(1000)
}
}
}
val scope = rememberCoroutineScope()
if (!isPresented) return
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug tools", fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
Text(
text = "developer utilities for diagnostics and control",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
// Verbose logging toggle
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
Text("verbose logging", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
}
Text(
"logs peer joins/leaves, connection direction, packet routing and relays",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
// GATT controls
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("bluetooth roles", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt server", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattServerEnabled, onCheckedChange = {
manager.setGattServerEnabled(it)
scope.launch {
if (it) meshService.connectionManager.startServer() else meshService.connectionManager.stopServer()
}
})
}
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
Text("connections: $serverCount / $maxServer", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max server", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxServer.toFloat(),
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
valueRange = 1f..32f,
steps = 30
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt client", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattClientEnabled, onCheckedChange = {
manager.setGattClientEnabled(it)
scope.launch {
if (it) meshService.connectionManager.startClient() else meshService.connectionManager.stopClient()
}
})
}
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
Text("connections: $clientCount / $maxClient", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max client", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxClient.toFloat(),
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
valueRange = 1f..32f,
steps = 30
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxOverall.toFloat(),
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
valueRange = 1f..32f,
steps = 30
)
}
Text(
"turn roles on/off and close all connections when disabled",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
// Packet relay controls and stats
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text("packet relay", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
Text("since start: ${relayStats.totalRelaysCount}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text("last 10s: ${relayStats.last10SecondRelays} • 1m: ${relayStats.lastMinuteRelays} • 15m: ${relayStats.last15MinuteRelays}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
var series by remember { mutableStateOf(List(60) { 0f }) }
LaunchedEffect(isPresented) {
while (isPresented) {
val s = relayStats.lastSecondRelays.toFloat()
val last = series.lastOrNull() ?: 0f
// Faster decay and smoothing
val v = last * 0.5f + s * 0.5f
series = (series + v).takeLast(60)
kotlinx.coroutines.delay(400)
}
}
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
Box(Modifier.fillMaxWidth().height(48.dp)) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = 28.dp.toPx() // leave room on left for ticks
val barCount = series.size
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// draw baseline at y=0 (bottom)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
series.forEachIndexed { i, value ->
val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0
val barHeight = h * ratio
// Draw bars from bottom up, starting after left axis area
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
// Y-axis ticks (min/max) in the left margin
Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp))
Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp))
// Y-axis unit label (vertical)
Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f))
}
}
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text("connected devices", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
Text("our device id: ${localAddr ?: "unknown"}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
if (connectedDevices.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
connectedDevices.forEach { dev ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${dev.peerID ?: "unknown"}${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) "as server (we host)" else "as client (we connect)"
Text("${dev.nickname ?: ""} • RSSI: ${dev.rssi ?: "?"}$roleLabel${if (dev.isDirectConnection) " • direct" else ""}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("disconnect", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
})
}
}
}
}
}
}
}
// Recent scan results
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("recent scan results", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
if (scanResults.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
scanResults.forEach { res ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${res.peerID ?: "unknown"}${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("${res.deviceName ?: ""} • RSSI: ${res.rssi}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("connect", color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.connectToAddress(res.deviceAddress)
})
}
}
}
}
}
}
}
// Debug console
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug console", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Text("clear", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
manager.clearDebugMessages()
})
}
Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) {
debugMessages.takeLast(100).reversed().forEach { msg ->
Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
}
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}