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 6adb55f0..e9dd4f8e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -549,13 +549,18 @@ class BluetoothMeshService(private val context: Context) { val nick = getPeerNicknames()[peerID] val route = packet.route val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null + + // Convert route to hex strings for visualization + val routeStrings = route?.map { it.toHexString() } + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( packetType = packet.type.toString(), fromPeerID = peerID, fromNickname = nick, fromDeviceAddress = device?.address, packetVersion = packet.version, - routeInfo = routeInfo + routeInfo = routeInfo, + route = routeStrings ) } catch (_: Exception) { } packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) 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 index 2815cb82..d04a2f3e 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -3,6 +3,8 @@ package com.bitchat.android.ui.debug import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import java.util.Date import java.util.concurrent.ConcurrentLinkedQueue @@ -465,13 +467,40 @@ class DebugSettingsManager private constructor() { // Do not update counters here; this path is for readable logs only. } + // MARK: - Debug Events for Animation + sealed class MeshVisualEvent { + data class PacketActivity(val peerID: String) : MeshVisualEvent() + data class RouteActivity(val route: List) : MeshVisualEvent() + } + + private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + ) + val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow = _meshVisualEvents.asSharedFlow() + + fun emitVisualEvent(event: MeshVisualEvent) { + if (_debugSheetVisible.value) { + _meshVisualEvents.tryEmit(event) + } + } + // Explicit incoming/outgoing logging to avoid double counting - fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null) { + fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null, route: List? = null) { if (verboseLoggingEnabled.value) { val who = fromNickname ?: fromPeerID ?: "unknown" val routeStr = if (routeInfo != null) " $routeInfo" else "" addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})$routeStr")) } + + // Emit visual events + if (fromPeerID != null) { + emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID)) + } + if (!route.isNullOrEmpty()) { + emitVisualEvent(MeshVisualEvent.RouteActivity(route)) + } + val now = System.currentTimeMillis() val visible = _debugSheetVisible.value if (visible) incomingTimestamps.offer(now) 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 index 4ba53931..ba63df44 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -53,85 +53,29 @@ fun MeshTopologySection() { if (empty) { Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { - androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) { - val w = size.width - val h = size.height - val cx = w / 2f - val cy = h / 2f - val radius = (minOf(w, h) * 0.36f) - val n = nodes.size - if (n == 1) { - // Single node centered - drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy)) - } else { - // Circular layout - val positions = nodes.mapIndexed { i, node -> - val angle = (2 * Math.PI * i.toDouble()) / n - val x = cx + (radius * Math.cos(angle)).toFloat() - val y = cy + (radius * Math.sin(angle)).toFloat() - node.peerID to androidx.compose.ui.geometry.Offset(x, y) - }.toMap() - - // Draw edges - edges.forEach { e -> - val p1 = positions[e.a] - val p2 = positions[e.b] - if (p1 != null && p2 != null) { - if (e.isConfirmed) { - drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) - } else { - // Unconfirmed: draw "solid" from declarer, "dashed" from other - val start = if (e.confirmedBy == e.a) p1 else p2 - val end = if (e.confirmedBy == e.a) p2 else p1 - - val midX = (start.x + end.x) / 2 - val midY = (start.y + end.y) / 2 - val mid = androidx.compose.ui.geometry.Offset(midX, midY) - - // Solid half - drawLine(color = Color(0xFF4A90E2), start = start, end = mid, strokeWidth = 2f) - - // Dotted half - drawLine( - color = Color(0xFF4A90E2), - start = mid, - end = end, - strokeWidth = 2f, - pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f) - ) - } - } - } - - // Draw nodes - nodes.forEach { node -> - val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) - drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) - } - - // Draw labels near nodes (nickname or short ID) - val labelColor = colorScheme.onSurface.toArgb() - val textSizePx = 10.sp.toPx() - drawIntoCanvas { canvas -> - val paint = android.graphics.Paint().apply { - isAntiAlias = true - color = labelColor - textSize = textSizePx - } - nodes.forEach { node -> - val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) - val label = (node.nickname?.takeIf { it.isNotBlank() } ?: node.peerID.take(8)) - canvas.nativeCanvas.drawText(label, pos.x + 12f, pos.y - 12f, paint) - } - } - } - } - // Label list for clarity under the canvas - LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) { - items(nodes.size) { i -> - val node = nodes[i] + ForceDirectedMeshGraph( + nodes = nodes, + edges = edges, + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background(colorScheme.surface.copy(alpha = 0.4f)) + ) + + // Flexible peer list + FlowRow( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + nodes.forEach { node -> val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" - Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f)) + Text( + text = label, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.85f) + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt new file mode 100644 index 00000000..2df56b5d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -0,0 +1,409 @@ +package com.bitchat.android.ui.debug + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.services.meshgraph.MeshGraphService +import kotlin.math.* +import kotlin.random.Random +import androidx.compose.material3.MaterialTheme +import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent + +// Physics constants +private const val REPULSION_FORCE = 100000f +private const val SPRING_LENGTH = 150f +private const val SPRING_STRENGTH = 0.02f +private const val CENTER_GRAVITY = 0.02f +private const val DAMPING = 0.85f +private const val MAX_VELOCITY = 30f +private const val PULSE_DECAY = 0.05f +private const val ROUTE_DECAY = 0.02f + +private class GraphNodeState( + val id: String, + var label: String, + var x: Float, + var y: Float +) { + var vx: Float = 0f + var vy: Float = 0f + var isDragged: Boolean = false + var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation +} + +private class Simulation { + val nodes = mutableMapOf() + // Storing edges as pairs of IDs + val edges = mutableListOf() + // Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0) + val activeRoutes = mutableListOf, Float>>() + + // Bounds for initial placement and centering + var width: Float = 1000f + var height: Float = 1000f + + fun updateTopology( + newNodes: List, + newEdges: List + ) { + // Remove stale nodes + val newIds = newNodes.map { it.peerID }.toSet() + nodes.keys.toList().forEach { id -> + if (id !in newIds) nodes.remove(id) + } + + // Add/Update nodes + newNodes.forEach { n -> + val existing = nodes[n.peerID] + val displayLabel = n.nickname ?: n.peerID.take(8) + if (existing != null) { + existing.label = displayLabel + } else { + // Spawn near center with random jitter + val angle = Random.nextFloat() * 2 * PI + val radius = 50f + Random.nextFloat() * 50f + nodes[n.peerID] = GraphNodeState( + id = n.peerID, + label = displayLabel, + x = (width / 2f) + (cos(angle) * radius).toFloat(), + y = (height / 2f) + (sin(angle) * radius).toFloat() + ) + } + } + + // Update edges + edges.clear() + edges.addAll(newEdges) + } + + fun triggerNodePulse(peerID: String) { + nodes[peerID]?.pulseLevel = 1f + } + + fun triggerRouteAnimation(route: List) { + if (route.size > 1) { + activeRoutes.add(route to 1f) + } + } + + fun step() { + val nodeList = nodes.values.toList() + val cx = width / 2f + val cy = height / 2f + + // 1. Repulsion (Node-Node) + for (i in nodeList.indices) { + val n1 = nodeList[i] + for (j in i + 1 until nodeList.size) { + val n2 = nodeList[j] + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val distSq = dx * dx + dy * dy + if (distSq > 0.1f) { + val dist = sqrt(distSq) + val force = REPULSION_FORCE / distSq + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx += fx + n1.vy += fy + } + if (!n2.isDragged) { + n2.vx -= fx + n2.vy -= fy + } + } + } + } + + // 2. Attraction (Edges) + edges.forEach { edge -> + val n1 = nodes[edge.a] + val n2 = nodes[edge.b] + if (n1 != null && n2 != null) { + val dx = n1.x - n2.x + val dy = n1.y - n2.y + val dist = sqrt(dx * dx + dy * dy) + if (dist > 0.1f) { + val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH + val fx = (dx / dist) * force + val fy = (dy / dist) * force + + if (!n1.isDragged) { + n1.vx -= fx + n1.vy -= fy + } + if (!n2.isDragged) { + n2.vx += fx + n2.vy += fy + } + } + } + } + + // 3. Center Gravity & Integration & Animation Decay + nodeList.forEach { n -> + if (!n.isDragged) { + // Pull to center + val dx = n.x - cx + val dy = n.y - cy + n.vx -= dx * CENTER_GRAVITY + n.vy -= dy * CENTER_GRAVITY + + // Apply velocity + val vMag = sqrt(n.vx * n.vx + n.vy * n.vy) + if (vMag > MAX_VELOCITY) { + n.vx = (n.vx / vMag) * MAX_VELOCITY + n.vy = (n.vy / vMag) * MAX_VELOCITY + } + + n.x += n.vx + n.y += n.vy + + // Damping + n.vx *= DAMPING + n.vy *= DAMPING + } else { + n.vx = 0f + n.vy = 0f + } + + // Decay pulse + if (n.pulseLevel > 0f) { + n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f) + } + } + + // Decay active routes + val iter = activeRoutes.iterator() + while (iter.hasNext()) { + val (route, intensity) = iter.next() + val newIntensity = intensity - ROUTE_DECAY + if (newIntensity <= 0f) { + iter.remove() + } else { + // Ugly mutation but efficient for simulation loop + // We need to replace the pair since pairs are immutable + // Finding index is O(N) but N is small + val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity } + if (idx >= 0) { + activeRoutes[idx] = route to newIntensity + } + } + } + } +} + +@Composable +fun ForceDirectedMeshGraph( + nodes: List, + edges: List, + modifier: Modifier = Modifier +) { + val density = LocalDensity.current + val simulation = remember { Simulation() } + val colorScheme = MaterialTheme.colorScheme + + // Listen for visual events + val debugManager = remember { DebugSettingsManager.getInstance() } + LaunchedEffect(Unit) { + debugManager.meshVisualEvents.collect { event -> + when (event) { + is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID) + is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route) + } + } + } + + // We need a state that changes on every tick to trigger redraw + var tick by remember { mutableLongStateOf(0L) } + + // Update topology when input data changes + LaunchedEffect(nodes, edges) { + simulation.updateTopology(nodes, edges) + } + + // Animation Loop + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { + simulation.step() + tick++ + } + } + } + + BoxWithConstraints(modifier = modifier) { + val w = maxWidth.value * density.density + val h = maxHeight.value * density.density + + // Update simulation bounds if size changes + SideEffect { + simulation.width = w + simulation.height = h + } + + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + // Find closest node + val closest = simulation.nodes.values.minByOrNull { + val dx = it.x - offset.x + val dy = it.y - offset.y + dx*dx + dy*dy + } + if (closest != null) { + val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2)) + if (dist < 80f) { // Touch radius + closest.isDragged = true + } + } + }, + onDragEnd = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDragCancel = { + simulation.nodes.values.forEach { it.isDragged = false } + }, + onDrag = { change, dragAmount -> + change.consume() + val dragged = simulation.nodes.values.find { it.isDragged } + if (dragged != null) { + dragged.x += dragAmount.x + dragged.y += dragAmount.y + } + } + ) + } + ) { + // Read tick to ensure recomposition + val t = tick + + val nodeMap = simulation.nodes + + // Draw Edges + simulation.edges.forEach { edge -> + val n1 = nodeMap[edge.a] + val n2 = nodeMap[edge.b] + + if (n1 != null && n2 != null) { + val start = Offset(n1.x, n1.y) + val end = Offset(n2.x, n2.y) + val baseColor = Color(0xFF4A90E2) + + if (edge.isConfirmed) { + drawLine( + color = baseColor, + start = start, + end = end, + strokeWidth = 5f + ) + } else { + // Unconfirmed: draw "solid" from declarer, "dashed" from other + val isA = (edge.confirmedBy == edge.a) + val solidStart = if (isA) start else end + val solidEnd = if (isA) end else start + + val midX = (start.x + end.x) / 2 + val midY = (start.y + end.y) / 2 + val mid = Offset(midX, midY) + + drawLine( + color = baseColor, + start = solidStart, + end = mid, + strokeWidth = 4f + ) + + drawLine( + color = baseColor.copy(alpha = 0.6f), + start = mid, + end = solidEnd, + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + } + } + } + + // Draw Active Routes (overlay) + simulation.activeRoutes.forEach { (route, intensity) -> + val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold + val strokeW = 4f * intensity + 2f + + for (i in 0 until route.size - 1) { + val p1 = nodeMap[route[i]] + val p2 = nodeMap[route[i+1]] + if (p1 != null && p2 != null) { + drawLine( + color = routeColor, + start = Offset(p1.x, p1.y), + end = Offset(p2.x, p2.y), + strokeWidth = strokeW, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + } + + // Draw Nodes + val labelColor = colorScheme.onSurface.toArgb() + val textPaint = android.graphics.Paint().apply { + isAntiAlias = true + textSize = 12.sp.toPx() + this.color = labelColor + } + + nodeMap.values.forEach { node -> + val center = Offset(node.x, node.y) + val pulse = node.pulseLevel + + // Pulse glow + if (pulse > 0.05f) { + drawCircle( + color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f), + radius = 16f + (pulse * 20f), + center = center + ) + } + + drawCircle( + color = Color(0xFF00C851), + radius = 16f + (pulse * 4f), // Slight scale up + center = center + ) + drawCircle( + color = Color.White, + radius = 12f + (pulse * 3f), + center = center, + style = Stroke(width = 2f) + ) + + // Label + drawContext.canvas.nativeCanvas.drawText( + node.label, + node.x + 22f + (pulse * 5f), + node.y + 4f, + textPaint + ) + } + } + } +}