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)
This commit is contained in:
callebtc
2026-01-09 23:11:57 +07:00
parent 85ce2739e3
commit 605c05d85a
3 changed files with 67 additions and 35 deletions
@@ -6,18 +6,18 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
* Maintains an internal undirected graph of the mesh based on gossip. * Maintains an internal graph of the mesh based on gossip.
* Nodes are peers (peerID), edges are direct connections. * Nodes are peers (peerID), edges are direct connections.
*/ */
class MeshGraphService private constructor() { class MeshGraphService private constructor() {
data class GraphNode(val peerID: String, val nickname: String?) data class GraphNode(val peerID: String, val nickname: String?)
data class GraphEdge(val a: String, val b: String) data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null)
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>) data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
// Map peerID -> nickname (may be null if unknown) // Map peerID -> nickname (may be null if unknown)
private val nicknames = ConcurrentHashMap<String, String?>() private val nicknames = ConcurrentHashMap<String, String?>()
// Adjacency (undirected): peerID -> set of neighbor peerIDs // Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
private val adjacency = ConcurrentHashMap<String, MutableSet<String>>() private val announcements = ConcurrentHashMap<String, Set<String>>()
// Latest announcement timestamp per peer (ULong from packet) // Latest announcement timestamp per peer (ULong from packet)
private val lastUpdate = ConcurrentHashMap<String, ULong>() private val lastUpdate = ConcurrentHashMap<String, ULong>()
@@ -47,20 +47,10 @@ class MeshGraphService private constructor() {
} }
lastUpdate[originPeerID] = timestamp lastUpdate[originPeerID] = timestamp
// Remove old symmetric edges contributed by this origin // Update what originPeerID announces
val prevNeighbors = adjacency[originPeerID]?.toSet().orEmpty() // Filter out self-loops just in case
prevNeighbors.forEach { n -> val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toSet()
adjacency[n]?.remove(originPeerID) announcements[originPeerID] = newSet
}
// Replace origin's adjacency with new set (may be empty)
val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toMutableSet()
adjacency[originPeerID] = newSet
// Ensure undirected edges
newSet.forEach { n ->
adjacency.putIfAbsent(n, mutableSetOf())
adjacency[n]?.add(originPeerID)
}
publishSnapshot() publishSnapshot()
} }
@@ -73,24 +63,43 @@ class MeshGraphService private constructor() {
} }
private fun publishSnapshot() { private fun publishSnapshot() {
val nodes = mutableSetOf<String>() // Collect all known nodes from nicknames and announcements
adjacency.forEach { (a, neighbors) -> val allNodes = mutableSetOf<String>()
nodes.add(a) allNodes.addAll(nicknames.keys)
nodes.addAll(neighbors) announcements.forEach { (origin, neighbors) ->
allNodes.add(origin)
allNodes.addAll(neighbors)
} }
// Merge in nicknames-only nodes
nodes.addAll(nicknames.keys)
val nodeList = nodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID } val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
val edgeSet = mutableSetOf<Pair<String, String>>()
adjacency.forEach { (a, ns) -> val edges = mutableListOf<GraphEdge>()
ns.forEach { b -> val processedPairs = mutableSetOf<Pair<String, String>>()
val (x, y) = if (a <= b) a to b else b to a
edgeSet.add(x to y) // We only care about connections that exist in at least one direction.
// So iterating through all entries in `announcements` covers every declared edge.
announcements.forEach { (source, targets) ->
targets.forEach { target ->
val pair = if (source <= target) source to target else target to source
if (processedPairs.add(pair)) {
// This is a new pair we haven't evaluated yet
val (a, b) = pair
val aAnnouncesB = announcements[a]?.contains(b) == true
val bAnnouncesA = announcements[b]?.contains(a) == true
if (aAnnouncesB && bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = true))
} else if (aAnnouncesB) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
} else if (bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
}
}
} }
} }
val edges = edgeSet.map { GraphEdge(it.first, it.second) }.sortedWith(compareBy({ it.a }, { it.b }))
_graphState.value = GraphSnapshot(nodeList, edges) val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
} }
companion object { companion object {
@@ -17,7 +17,9 @@ object RoutePlanner {
if (src == dst) return listOf(src) if (src == dst) return listOf(src)
val snapshot = MeshGraphService.getInstance().graphState.value val snapshot = MeshGraphService.getInstance().graphState.value
val neighbors = mutableMapOf<String, MutableSet<String>>() val neighbors = mutableMapOf<String, MutableSet<String>>()
snapshot.edges.forEach { e ->
// Only consider confirmed edges for routing
snapshot.edges.filter { it.isConfirmed }.forEach { e ->
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b) neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a) neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
} }
@@ -63,4 +65,3 @@ object RoutePlanner {
return path return path
} }
} }
@@ -80,7 +80,29 @@ fun MeshTopologySection() {
val p1 = positions[e.a] val p1 = positions[e.a]
val p2 = positions[e.b] val p2 = positions[e.b]
if (p1 != null && p2 != null) { if (p1 != null && p2 != null) {
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) 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)
)
}
} }
} }