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
@@ -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)) }
}
}
}