mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:25:20 +00:00
Mesh gossip (#381)
* wip mesh graph * gossip fix * gossip works * source-based routing wip * log
This commit is contained in:
@@ -247,6 +247,16 @@ class BluetoothConnectionManager(
|
|||||||
serverManager.getCharacteristic()
|
serverManager.getCharacteristic()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
|
if (!isActive) return false
|
||||||
|
return packetBroadcaster.sendToPeer(
|
||||||
|
peerID,
|
||||||
|
routed,
|
||||||
|
serverManager.getGattServer(),
|
||||||
|
serverManager.getCharacteristic()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Expose role controls for debug UI
|
// Expose role controls for debug UI
|
||||||
|
|||||||
@@ -408,6 +408,10 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
override fun relayPacket(routed: RoutedPacket) {
|
override fun relayPacket(routed: RoutedPacket) {
|
||||||
connectionManager.broadcastPacket(routed)
|
connectionManager.broadcastPacket(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
|
return connectionManager.sendToPeer(peerID, routed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BluetoothConnectionManager delegates
|
// BluetoothConnectionManager delegates
|
||||||
@@ -688,11 +692,25 @@ 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
|
||||||
|
}
|
||||||
|
// Always update our own node in the mesh graph with the neighbor list we used
|
||||||
|
try {
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val announcePacket = BitchatPacket(
|
val announcePacket = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
@@ -735,11 +753,25 @@ 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
|
||||||
|
}
|
||||||
|
// Always update our own node in the mesh graph with the neighbor list we used
|
||||||
|
try {
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
@@ -758,6 +790,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
|
||||||
*/
|
*/
|
||||||
@@ -939,21 +985,37 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||||
return try {
|
return try {
|
||||||
|
// Optionally compute and attach a source route for addressed packets
|
||||||
|
val withRoute = try {
|
||||||
|
val rec = packet.recipientID
|
||||||
|
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||||
|
val dest = rec.joinToString("") { b -> "%02x".format(b) }
|
||||||
|
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
|
||||||
|
if (path != null && path.size >= 3) {
|
||||||
|
// Exclude first (sender) and last (recipient); only intermediates
|
||||||
|
val intermediates = path.subList(1, path.size - 1)
|
||||||
|
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||||
|
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||||
|
packet.copy(route = hopsBytes)
|
||||||
|
} else packet.copy(route = null)
|
||||||
|
} else packet
|
||||||
|
} catch (_: Exception) { packet }
|
||||||
|
|
||||||
// Get the canonical packet data for signing (without signature)
|
// Get the canonical packet data for signing (without signature)
|
||||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||||
if (packetDataForSigning == null) {
|
if (packetDataForSigning == null) {
|
||||||
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
||||||
return packet
|
return withRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the packet data using our signing key
|
// Sign the packet data using our signing key
|
||||||
val signature = encryptionService.signData(packetDataForSigning)
|
val signature = encryptionService.signData(packetDataForSigning)
|
||||||
if (signature != null) {
|
if (signature != null) {
|
||||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
||||||
packet.copy(signature = signature)
|
withRoute.copy(signature = signature)
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
||||||
packet
|
withRoute
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
||||||
|
|||||||
@@ -159,6 +159,46 @@ class BluetoothPacketBroadcaster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||||
|
* Returns true if sent to at least one matching connection.
|
||||||
|
*/
|
||||||
|
fun sendToPeer(
|
||||||
|
targetPeerID: String,
|
||||||
|
routed: RoutedPacket,
|
||||||
|
gattServer: BluetoothGattServer?,
|
||||||
|
characteristic: BluetoothGattCharacteristic?
|
||||||
|
): Boolean {
|
||||||
|
val packet = routed.packet
|
||||||
|
val data = packet.toBinaryData() ?: return false
|
||||||
|
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) }
|
||||||
|
|
||||||
|
// Try server-side connections first
|
||||||
|
val targetDevice = connectionTracker.getSubscribedDevices()
|
||||||
|
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||||
|
if (targetDevice != null) {
|
||||||
|
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||||
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try client-side connections next
|
||||||
|
val targetConn = connectionTracker.getConnectedDevices().values
|
||||||
|
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||||
|
if (targetConn != null) {
|
||||||
|
if (writeToDeviceConn(targetConn, data)) {
|
||||||
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal broadcast implementation - runs in serialized actor context
|
* Internal broadcast implementation - runs in serialized actor context
|
||||||
|
|||||||
@@ -240,6 +240,13 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
previousPeerID = null
|
previousPeerID = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||||
|
try {
|
||||||
|
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
override fun broadcastPacket(routed: RoutedPacket) {
|
override fun broadcastPacket(routed: RoutedPacket) {
|
||||||
delegate?.relayPacket(routed)
|
delegate?.relayPacket(routed)
|
||||||
}
|
}
|
||||||
|
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
|
return delegate?.sendToPeer(peerID, routed) ?: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,4 +313,5 @@ interface PacketProcessorDelegate {
|
|||||||
fun sendAnnouncementToPeer(peerID: String)
|
fun sendAnnouncementToPeer(peerID: String)
|
||||||
fun sendCachedMessages(peerID: String)
|
fun sendCachedMessages(peerID: String)
|
||||||
fun relayPacket(routed: RoutedPacket)
|
fun relayPacket(routed: RoutedPacket)
|
||||||
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,35 @@ class PacketRelayManager(private val myPeerID: String) {
|
|||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
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}")
|
||||||
|
|
||||||
|
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||||
|
val route = relayPacket.route
|
||||||
|
if (!route.isNullOrEmpty()) {
|
||||||
|
val myIdBytes = hexStringToPeerBytes(myPeerID)
|
||||||
|
val index = route.indexOfFirst { it.contentEquals(myIdBytes) }
|
||||||
|
if (index >= 0) {
|
||||||
|
val nextHopIdHex: String? = run {
|
||||||
|
val nextIndex = index + 1
|
||||||
|
if (nextIndex < route.size) {
|
||||||
|
route[nextIndex].toHexString()
|
||||||
|
} else {
|
||||||
|
// We are the last intermediate; try final recipient as next hop
|
||||||
|
relayPacket.recipientID?.toHexString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextHopIdHex != null) {
|
||||||
|
val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
|
||||||
|
if (success) {
|
||||||
|
Log.i(TAG, "📦 Source-route relay: ${myPeerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply relay logic based on packet type and debug switch
|
// Apply relay logic based on packet type and debug switch
|
||||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||||
|
|
||||||
if (shouldRelay) {
|
if (shouldRelay) {
|
||||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||||
} else {
|
} else {
|
||||||
@@ -206,4 +232,17 @@ interface PacketRelayManagerDelegate {
|
|||||||
|
|
||||||
// Packet operations
|
// Packet operations
|
||||||
fun broadcastPacket(routed: RoutedPacket)
|
fun broadcastPacket(routed: RoutedPacket)
|
||||||
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||||
|
val result = ByteArray(8)
|
||||||
|
var idx = 0
|
||||||
|
var out = 0
|
||||||
|
while (idx + 1 < hex.length && out < 8) {
|
||||||
|
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
|
||||||
|
result[out++] = b
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ data class BitchatPacket(
|
|||||||
val timestamp: ULong,
|
val timestamp: ULong,
|
||||||
val payload: ByteArray,
|
val payload: ByteArray,
|
||||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||||
var ttl: UByte
|
var ttl: UByte,
|
||||||
|
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -95,7 +96,8 @@ data class BitchatPacket(
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
signature = null, // Remove signature for signing
|
signature = null, // Remove signature for signing
|
||||||
ttl = 0u // Use fixed TTL=0 for signing to ensure relay compatibility
|
ttl = 0u, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
route = route
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,11 @@ data class BitchatPacket(
|
|||||||
if (!signature.contentEquals(other.signature)) return false
|
if (!signature.contentEquals(other.signature)) return false
|
||||||
} else if (other.signature != null) return false
|
} else if (other.signature != null) return false
|
||||||
if (ttl != other.ttl) return false
|
if (ttl != other.ttl) return false
|
||||||
|
if (route != null || other.route != null) {
|
||||||
|
val a = route?.map { it.toList() } ?: emptyList()
|
||||||
|
val b = other.route?.map { it.toList() } ?: emptyList()
|
||||||
|
if (a != b) return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -160,6 +167,7 @@ data class BitchatPacket(
|
|||||||
result = 31 * result + payload.contentHashCode()
|
result = 31 * result + payload.contentHashCode()
|
||||||
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + ttl.hashCode()
|
result = 31 * result + ttl.hashCode()
|
||||||
|
result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +185,7 @@ object BinaryProtocol {
|
|||||||
const val HAS_RECIPIENT: UByte = 0x01u
|
const val HAS_RECIPIENT: UByte = 0x01u
|
||||||
const val HAS_SIGNATURE: UByte = 0x02u
|
const val HAS_SIGNATURE: UByte = 0x02u
|
||||||
const val IS_COMPRESSED: UByte = 0x04u
|
const val IS_COMPRESSED: UByte = 0x04u
|
||||||
|
const val HAS_ROUTE: UByte = 0x08u
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encode(packet: BitchatPacket): ByteArray? {
|
fun encode(packet: BitchatPacket): ByteArray? {
|
||||||
@@ -215,6 +224,9 @@ object BinaryProtocol {
|
|||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
flags = flags or Flags.IS_COMPRESSED
|
flags = flags or Flags.IS_COMPRESSED
|
||||||
}
|
}
|
||||||
|
if (!packet.route.isNullOrEmpty()) {
|
||||||
|
flags = flags or Flags.HAS_ROUTE
|
||||||
|
}
|
||||||
buffer.put(flags.toByte())
|
buffer.put(flags.toByte())
|
||||||
|
|
||||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||||
@@ -236,6 +248,14 @@ object BinaryProtocol {
|
|||||||
buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size))
|
buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route (optional): 1 byte count + N*8 bytes
|
||||||
|
packet.route?.let { routeList ->
|
||||||
|
val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } }
|
||||||
|
val count = cleaned.size.coerceAtMost(255)
|
||||||
|
buffer.put(count.toByte())
|
||||||
|
cleaned.take(count).forEach { hop -> buffer.put(hop) }
|
||||||
|
}
|
||||||
|
|
||||||
// Payload (with original size prepended if compressed)
|
// Payload (with original size prepended if compressed)
|
||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
@@ -302,6 +322,7 @@ object BinaryProtocol {
|
|||||||
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
||||||
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
|
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
|
||||||
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
||||||
|
val hasRoute = (flags and Flags.HAS_ROUTE) != 0u.toUByte()
|
||||||
|
|
||||||
// Payload length
|
// Payload length
|
||||||
val payloadLength = buffer.getShort().toUShort()
|
val payloadLength = buffer.getShort().toUShort()
|
||||||
@@ -309,6 +330,15 @@ object BinaryProtocol {
|
|||||||
// Calculate expected total size
|
// Calculate expected total size
|
||||||
var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt()
|
var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt()
|
||||||
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
||||||
|
var routeCount = 0
|
||||||
|
if (hasRoute) {
|
||||||
|
// Peek count (1 byte) without consuming buffer for now
|
||||||
|
val mark = buffer.position()
|
||||||
|
if (raw.size >= mark + 1) {
|
||||||
|
routeCount = raw[mark].toUByte().toInt()
|
||||||
|
}
|
||||||
|
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
|
||||||
|
}
|
||||||
if (hasSignature) expectedSize += SIGNATURE_SIZE
|
if (hasSignature) expectedSize += SIGNATURE_SIZE
|
||||||
|
|
||||||
if (raw.size < expectedSize) return null
|
if (raw.size < expectedSize) return null
|
||||||
@@ -324,6 +354,18 @@ object BinaryProtocol {
|
|||||||
recipientBytes
|
recipientBytes
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
|
// Route (optional)
|
||||||
|
val route: List<ByteArray>? = if (hasRoute) {
|
||||||
|
val count = buffer.get().toUByte().toInt()
|
||||||
|
val hops = mutableListOf<ByteArray>()
|
||||||
|
repeat(count) {
|
||||||
|
val hop = ByteArray(SENDER_ID_SIZE)
|
||||||
|
buffer.get(hop)
|
||||||
|
hops.add(hop)
|
||||||
|
}
|
||||||
|
hops
|
||||||
|
} else null
|
||||||
|
|
||||||
// Payload
|
// Payload
|
||||||
val payload = if (isCompressed) {
|
val payload = if (isCompressed) {
|
||||||
// First 2 bytes are original size
|
// First 2 bytes are original size
|
||||||
@@ -357,7 +399,8 @@ object BinaryProtocol {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
signature = signature,
|
signature = signature,
|
||||||
ttl = ttl
|
ttl = ttl,
|
||||||
|
route = route
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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.
|
||||||
|
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return result // present (possibly empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not present
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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,102 @@
|
|||||||
|
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?, neighborsOrNull: List<String>?, timestamp: ULong) {
|
||||||
|
synchronized(this) {
|
||||||
|
// Always update nickname if provided
|
||||||
|
if (originNickname != null) nicknames[originPeerID] = originNickname
|
||||||
|
|
||||||
|
// If no neighbors TLV present, do not modify edges or timestamps
|
||||||
|
if (neighborsOrNull == null) {
|
||||||
|
publishSnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer-only replacement per origin (based on TLV-bearing announcements only)
|
||||||
|
val prevTs = lastUpdate[originPeerID]
|
||||||
|
if (prevTs != null && prevTs >= timestamp) {
|
||||||
|
// Older or equal TLV-bearing update: ignore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastUpdate[originPeerID] = timestamp
|
||||||
|
|
||||||
|
// 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 (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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.bitchat.android.services.meshgraph
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.PriorityQueue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes shortest paths on the current mesh graph snapshot using Dijkstra.
|
||||||
|
* Assumes unit edge weights.
|
||||||
|
*/
|
||||||
|
object RoutePlanner {
|
||||||
|
private const val TAG = "RoutePlanner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return full path [src, ..., dst] if reachable, else null.
|
||||||
|
*/
|
||||||
|
fun shortestPath(src: String, dst: String): List<String>? {
|
||||||
|
if (src == dst) return listOf(src)
|
||||||
|
val snapshot = MeshGraphService.getInstance().graphState.value
|
||||||
|
val neighbors = mutableMapOf<String, MutableSet<String>>()
|
||||||
|
snapshot.edges.forEach { e ->
|
||||||
|
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
|
||||||
|
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
|
||||||
|
}
|
||||||
|
// Ensure nodes known even if isolated
|
||||||
|
snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) }
|
||||||
|
|
||||||
|
if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null
|
||||||
|
|
||||||
|
val dist = mutableMapOf<String, Int>()
|
||||||
|
val prev = mutableMapOf<String, String?>()
|
||||||
|
val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
|
||||||
|
|
||||||
|
neighbors.keys.forEach { v ->
|
||||||
|
dist[v] = if (v == src) 0 else Int.MAX_VALUE
|
||||||
|
prev[v] = null
|
||||||
|
}
|
||||||
|
pq.add(src to 0)
|
||||||
|
|
||||||
|
while (pq.isNotEmpty()) {
|
||||||
|
val (u, d) = pq.poll()
|
||||||
|
if (d > (dist[u] ?: Int.MAX_VALUE)) continue
|
||||||
|
if (u == dst) break
|
||||||
|
neighbors[u]?.forEach { v ->
|
||||||
|
val alt = d + 1
|
||||||
|
if (alt < (dist[v] ?: Int.MAX_VALUE)) {
|
||||||
|
dist[v] = alt
|
||||||
|
prev[v] = u
|
||||||
|
pq.add(v to alt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null
|
||||||
|
|
||||||
|
val path = mutableListOf<String>()
|
||||||
|
var cur: String? = dst
|
||||||
|
while (cur != null) {
|
||||||
|
path.add(cur)
|
||||||
|
cur = prev[cur]
|
||||||
|
}
|
||||||
|
path.reverse()
|
||||||
|
Log.d(TAG, "Computed path $path")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,92 @@ 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
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) { 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -124,6 +209,11 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mesh topology visualization (moved below verbose logging)
|
||||||
|
item {
|
||||||
|
MeshTopologySection()
|
||||||
|
}
|
||||||
|
|
||||||
// GATT controls
|
// GATT controls
|
||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
@@ -250,39 +340,82 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Left gutter layout: unit + ticks neatly aligned
|
// Y-axis ticks (min/max) in the left margin
|
||||||
Row(Modifier.fillMaxSize()) {
|
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))
|
||||||
Box(Modifier.width(leftGutter).fillMaxHeight()) {
|
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))
|
||||||
// Unit label on the far left, centered vertically
|
// Y-axis unit label (vertical)
|
||||||
Text(
|
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))
|
||||||
"p/s",
|
}
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
fontSize = 10.sp,
|
}
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
}
|
||||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
|
|
||||||
)
|
@Composable
|
||||||
// Tick labels right-aligned in gutter, top and bottom aligned
|
fun MeshTopologySection() {
|
||||||
Text(
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
"${maxVal.toInt()}",
|
val graphService = remember { MeshGraphService.getInstance() }
|
||||||
fontFamily = FontFamily.Monospace,
|
val snapshot by graphService.graphState.collectAsState()
|
||||||
fontSize = 10.sp,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp)
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
)
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(
|
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
|
||||||
"0",
|
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
fontSize = 10.sp,
|
val nodes = snapshot.nodes
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
val edges = snapshot.edges
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp)
|
val empty = nodes.isEmpty()
|
||||||
)
|
if (empty) {
|
||||||
}
|
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||||
Spacer(Modifier.weight(1f))
|
} 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