mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +00:00
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:
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user