mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 23:25:20 +00:00
wip mesh graph
This commit is contained in:
@@ -694,12 +694,21 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||||
val tlvPayload = announcement.encode()
|
var tlvPayload = announcement.encode()
|
||||||
if (tlvPayload == null) {
|
if (tlvPayload == null) {
|
||||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||||
|
try {
|
||||||
|
val directPeers = getDirectPeerIDsForGossip()
|
||||||
|
if (directPeers.isNotEmpty()) {
|
||||||
|
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||||
|
tlvPayload = tlvPayload + gossip
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val announcePacket = BitchatPacket(
|
val announcePacket = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
ttl = MAX_TTL,
|
ttl = MAX_TTL,
|
||||||
@@ -741,12 +750,21 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||||
val tlvPayload = announcement.encode()
|
var tlvPayload = announcement.encode()
|
||||||
if (tlvPayload == null) {
|
if (tlvPayload == null) {
|
||||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||||
|
try {
|
||||||
|
val directPeers = getDirectPeerIDsForGossip()
|
||||||
|
if (directPeers.isNotEmpty()) {
|
||||||
|
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||||
|
tlvPayload = tlvPayload + gossip
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
ttl = MAX_TTL,
|
ttl = MAX_TTL,
|
||||||
@@ -764,6 +782,20 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect up to 10 direct neighbors for gossip TLV.
|
||||||
|
*/
|
||||||
|
private fun getDirectPeerIDsForGossip(): List<String> {
|
||||||
|
return try {
|
||||||
|
// Prefer verified peers that are currently marked as direct
|
||||||
|
val verified = peerManager.getVerifiedPeers()
|
||||||
|
val direct = verified.filter { it.value.isDirectConnection }.keys.toList()
|
||||||
|
direct.take(10)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send leave announcement
|
* Send leave announcement
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -240,6 +240,13 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
previousPeerID = null
|
previousPeerID = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update mesh graph from gossip neighbors (if present)
|
||||||
|
try {
|
||||||
|
val neighbors = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(peerID, nickname, neighbors, packet.timestamp)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
||||||
return isFirstAnnounce
|
return isFirstAnnounce
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.bitchat.android.services.meshgraph
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
|
||||||
|
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
|
||||||
|
*/
|
||||||
|
object GossipTLV {
|
||||||
|
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
|
||||||
|
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
|
||||||
|
*/
|
||||||
|
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
|
||||||
|
val unique = peerIDs.distinct().take(10)
|
||||||
|
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
|
||||||
|
if (valueBytes.size > 255) {
|
||||||
|
// Safety check, though 10*8 = 80 bytes, so well under 255
|
||||||
|
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
|
||||||
|
}
|
||||||
|
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a TLV-encoded announce payload and extract neighbor peerIDs if present.
|
||||||
|
*/
|
||||||
|
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset + 2 <= payload.size) {
|
||||||
|
val type = payload[offset].toUByte()
|
||||||
|
val len = payload[offset + 1].toUByte().toInt()
|
||||||
|
offset += 2
|
||||||
|
if (offset + len > payload.size) break
|
||||||
|
val value = payload.sliceArray(offset until offset + len)
|
||||||
|
offset += len
|
||||||
|
|
||||||
|
if (type == DIRECT_NEIGHBORS_TYPE) {
|
||||||
|
// Value is N*8 bytes of peer IDs
|
||||||
|
var pos = 0
|
||||||
|
while (pos + 8 <= value.size) {
|
||||||
|
val idBytes = value.sliceArray(pos until pos + 8)
|
||||||
|
result.add(bytesToPeerIdHex(idBytes))
|
||||||
|
pos += 8
|
||||||
|
}
|
||||||
|
break // only one neighbors TLV expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
|
||||||
|
val clean = hexString.lowercase().take(16)
|
||||||
|
val result = ByteArray(8) { 0 }
|
||||||
|
var idx = 0
|
||||||
|
var out = 0
|
||||||
|
while (idx + 1 < clean.length && out < 8) {
|
||||||
|
val byteStr = clean.substring(idx, idx + 2)
|
||||||
|
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
|
||||||
|
result[out++] = b
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToPeerIdHex(bytes: ByteArray): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (b in bytes.take(8)) {
|
||||||
|
sb.append(String.format("%02x", b))
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.bitchat.android.services.meshgraph
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains an internal undirected graph of the mesh based on gossip.
|
||||||
|
* Nodes are peers (peerID), edges are direct connections.
|
||||||
|
*/
|
||||||
|
class MeshGraphService private constructor() {
|
||||||
|
data class GraphNode(val peerID: String, val nickname: String?)
|
||||||
|
data class GraphEdge(val a: String, val b: String)
|
||||||
|
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
|
||||||
|
|
||||||
|
// Map peerID -> nickname (may be null if unknown)
|
||||||
|
private val nicknames = ConcurrentHashMap<String, String?>()
|
||||||
|
// Adjacency (undirected): peerID -> set of neighbor peerIDs
|
||||||
|
private val adjacency = ConcurrentHashMap<String, MutableSet<String>>()
|
||||||
|
// Latest announcement timestamp per peer (ULong from packet)
|
||||||
|
private val lastUpdate = ConcurrentHashMap<String, ULong>()
|
||||||
|
|
||||||
|
private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList()))
|
||||||
|
val graphState: StateFlow<GraphSnapshot> = _graphState.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update graph from a verified announcement.
|
||||||
|
* Replaces previous neighbors for origin if this is newer (by timestamp).
|
||||||
|
*/
|
||||||
|
fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighbors: List<String>, timestamp: ULong) {
|
||||||
|
// Newer-only replacement per origin
|
||||||
|
val prevTs = lastUpdate[originPeerID]
|
||||||
|
if (prevTs != null && prevTs >= timestamp) return
|
||||||
|
lastUpdate[originPeerID] = timestamp
|
||||||
|
|
||||||
|
synchronized(this) {
|
||||||
|
// Update nickname
|
||||||
|
if (originNickname != null) nicknames[originPeerID] = originNickname
|
||||||
|
|
||||||
|
// Remove old symmetric edges contributed by this origin
|
||||||
|
val prevNeighbors = adjacency[originPeerID]?.toSet().orEmpty()
|
||||||
|
prevNeighbors.forEach { n ->
|
||||||
|
adjacency[n]?.remove(originPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace origin's adjacency with new set
|
||||||
|
val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toMutableSet()
|
||||||
|
adjacency[originPeerID] = newSet
|
||||||
|
// Ensure nodes exist for neighbors
|
||||||
|
newSet.forEach { n ->
|
||||||
|
adjacency.putIfAbsent(n, mutableSetOf())
|
||||||
|
// Add symmetric edge for undirected graph visualization
|
||||||
|
adjacency[n]?.add(originPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute snapshot
|
||||||
|
publishSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateNickname(peerID: String, nickname: String?) {
|
||||||
|
if (nickname == null) return
|
||||||
|
nicknames[peerID] = nickname
|
||||||
|
publishSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishSnapshot() {
|
||||||
|
val nodes = mutableSetOf<String>()
|
||||||
|
adjacency.forEach { (a, neighbors) ->
|
||||||
|
nodes.add(a)
|
||||||
|
nodes.addAll(neighbors)
|
||||||
|
}
|
||||||
|
// Merge in nicknames-only nodes
|
||||||
|
nodes.addAll(nicknames.keys)
|
||||||
|
|
||||||
|
val nodeList = nodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
|
||||||
|
val edgeSet = mutableSetOf<Pair<String, String>>()
|
||||||
|
adjacency.forEach { (a, ns) ->
|
||||||
|
ns.forEach { b ->
|
||||||
|
val (x, y) = if (a <= b) a to b else b to a
|
||||||
|
edgeSet.add(x to y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val edges = edgeSet.map { GraphEdge(it.first, it.second) }.sortedWith(compareBy({ it.a }, { it.b }))
|
||||||
|
_graphState.value = GraphSnapshot(nodeList, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile private var INSTANCE: MeshGraphService? = null
|
||||||
|
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -91,6 +92,11 @@ fun DebugSettingsSheet(
|
|||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
// Mesh topology visualization
|
||||||
|
MeshTopologySection()
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
|
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
|
||||||
@@ -250,7 +256,74 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Label list for clarity under the canvas
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
|
||||||
|
items(nodes) { 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Connected devices
|
// Connected devices
|
||||||
item {
|
item {
|
||||||
|
|||||||
Reference in New Issue
Block a user