Gossip mesh topology + source-based routing (#445)

* wip mesh graph

* gossip fix

* gossip works

* source-based routing wip

* log

* update spec to be explicit about intermediate hops only

* drop duplicate hops

* add to spec

* test

* forgot comma

* source routing v2

* add compression bomb protection

* v2 source routing

* fragmented packets inherit route

* update spec

* Gossip routing tmp with connection limit fixed (#569)

* fix: r8 exception for LocationManager (#566)

* fragmented packets inherit route

* update spec

* fix connection limits

* fix: deserialization issue with routed packets

* log

* dynamic fragment size

* fragment size

* add tests

* feat(gossip): implement two-way handshake for source routing edges

- Update MeshGraphService to track directed announcements
- Require bidirectional announcements for a 'confirmed' edge
- Update RoutePlanner to strictly use confirmed edges
- Update Mesh Topology debug view to show confirmed vs unconfirmed edges (solid vs dotted)

* docs: update SOURCE_ROUTING.md with two-way handshake requirement

* evict stale peers from mesh graph service

* better logging

* fix announce spe

* fix: empty route

* fix spec

* fix: compile error in DebugSettingsSheet and potential NPE in RoutePlanner

* revert

* try again
This commit is contained in:
callebtc
2026-01-13 04:18:07 +07:00
committed by GitHub
parent d164b3f9bc
commit 66012e9fe9
18 changed files with 1251 additions and 133 deletions
@@ -418,7 +418,9 @@ class DebugSettingsManager private constructor() {
toNickname: String?,
toDeviceAddress: String?,
ttl: UByte?,
isRelay: Boolean = true
isRelay: Boolean = true,
packetVersion: UByte = 1u,
routeInfo: String? = null
) {
// Build message only if verbose logging is enabled, but always update stats
val senderLabel = when {
@@ -441,18 +443,20 @@ class DebugSettingsManager private constructor() {
val fromAddr = fromDeviceAddress ?: "?"
val toAddr = toDeviceAddress ?: "?"
val ttlStr = ttl?.toString() ?: "?"
val routeStr = if (routeInfo != null) " $routeInfo" else ""
if (verboseLoggingEnabled.value) {
if (isRelay) {
// Relay: show [previousPeer] -> [nextPeer]
addDebugMessage(
DebugMessage.RelayEvent(
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
} else {
addDebugMessage(
DebugMessage.PacketEvent(
"📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
}
@@ -462,10 +466,11 @@ class DebugSettingsManager private constructor() {
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})$routeStr"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
@@ -489,10 +494,11 @@ class DebugSettingsManager private constructor() {
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
@@ -25,12 +25,120 @@ 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 com.bitchat.android.services.meshgraph.MeshGraphService
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
@Composable
fun MeshTopologySection() {
val colorScheme = MaterialTheme.colorScheme
val graphService = remember { MeshGraphService.getInstance() }
val snapshot by graphService.graphState.collectAsState()
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.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val nodes = snapshot.nodes
val edges = snapshot.edges
val empty = nodes.isEmpty()
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]
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
}
}
}
}
}
}
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -139,6 +247,11 @@ fun DebugSettingsSheet(
}
}
// Mesh topology visualization (moved below verbose logging)
item {
MeshTopologySection()
}
// GATT controls
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -348,7 +461,7 @@ fun DebugSettingsSheet(
kotlinx.coroutines.delay(1000)
}
}
// Helper functions moved to top-level composable below to avoid scope issues
// Render two blocks: Incoming and Outgoing
@@ -488,9 +601,6 @@ fun DebugSettingsSheet(
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {