diff --git a/.gitignore b/.gitignore index fcd3e1db..9365010c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ gen/ *~ *.swp *.lock +.goosehints # Google services google-services.json diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 2df5e3b4..7a0ede22 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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 } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index e9310c16..0cc7f967 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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> { + 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) { } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 92d91ff5..3494d1b2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -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( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 40c5708d..bd36c8ce 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -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( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 96a86c4d..c4117b8e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -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() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 01fd3472..da3d1226 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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) { @@ -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) { } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 202c64ee..1fc52bb4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index c8726707..54ecffbe 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index 5395a153..f292534b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -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\"}") } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index ff505e96..cb98f732 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -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 { + 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) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 50f9a5dc..3caccf32 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 8638c094..6c0a5555 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index a9a75e44..1b120b9b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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 { + return nostrGeohashService.getGeohashMessages(geohash) + } + /** * Get participant count for a specific geohash (5-minute activity window) */ diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 30a7ec21..57d755d6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index c1e58963..e554679d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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()) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt new file mode 100644 index 00000000..84b624f7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -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() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt new file mode 100644 index 00000000..81538058 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -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 = _verboseLoggingEnabled.asStateFlow() + + private val _gattServerEnabled = MutableStateFlow(true) + val gattServerEnabled: StateFlow = _gattServerEnabled.asStateFlow() + + private val _gattClientEnabled = MutableStateFlow(true) + val gattClientEnabled: StateFlow = _gattClientEnabled.asStateFlow() + + private val _packetRelayEnabled = MutableStateFlow(true) + val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + + // Connection limit overrides (debug) + private val _maxConnectionsOverall = MutableStateFlow(8) + val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() + private val _maxServerConnections = MutableStateFlow(8) + val maxServerConnections: StateFlow = _maxServerConnections.asStateFlow() + private val _maxClientConnections = MutableStateFlow(8) + val maxClientConnections: StateFlow = _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>(emptyList()) + val debugMessages: StateFlow> = _debugMessages.asStateFlow() + + private val _scanResults = MutableStateFlow>(emptyList()) + val scanResults: StateFlow> = _scanResults.asStateFlow() + + private val _connectedDevices = MutableStateFlow>(emptyList()) + val connectedDevices: StateFlow> = _connectedDevices.asStateFlow() + + // Packet relay statistics + private val _relayStats = MutableStateFlow(PacketRelayStats()) + val relayStats: StateFlow = _relayStats.asStateFlow() + + // Timestamps to compute rolling window stats + private val relayTimestamps = ConcurrentLinkedQueue() + + // Internal data storage for managing debug data + private val debugMessageQueue = ConcurrentLinkedQueue() + private val scanResultsQueue = ConcurrentLinkedQueue() + + 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) { + _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() +) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt new file mode 100644 index 00000000..33fde064 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -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)) } + } + } +}