diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index f446888e6..7098d46cf 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -248,6 +248,16 @@ class BluetoothConnectionManager( ) } + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive) return false + return packetBroadcaster.sendToPeer( + peerID, + routed, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun cancelTransfer(transferId: String): Boolean { return packetBroadcaster.cancelTransfer(transferId) } 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 56c640dfd..1d9e0f995 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -474,6 +474,10 @@ class BluetoothMeshService(private val context: Context) { connectionManager.broadcastPacket(routed) } + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return connectionManager.sendToPeer(peerID, routed) + } + override fun handleRequestSync(routed: RoutedPacket) { // Decode request and respond with missing packets val fromPeer = routed.peerID ?: return @@ -878,11 +882,25 @@ class BluetoothMeshService(private val context: Context) { // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") 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( type = MessageType.ANNOUNCE.value, @@ -927,11 +945,25 @@ class BluetoothMeshService(private val context: Context) { // Create iOS-compatible IdentityAnnouncement with TLV encoding val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) - val tlvPayload = announcement.encode() + var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") 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( type = MessageType.ANNOUNCE.value, @@ -953,6 +985,20 @@ class BluetoothMeshService(private val context: Context) { try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } + /** + * Collect up to 10 direct neighbors for gossip TLV. + */ + private fun getDirectPeerIDsForGossip(): List { + 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 */ @@ -1125,21 +1171,37 @@ class BluetoothMeshService(private val context: Context) { */ private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { 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) - val packetDataForSigning = packet.toBinaryDataForSigning() + val packetDataForSigning = withRoute.toBinaryDataForSigning() if (packetDataForSigning == null) { 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 val signature = encryptionService.signData(packetDataForSigning) if (signature != null) { Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)") - packet.copy(signature = signature) + withRoute.copy(signature = signature) } else { Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned") - packet + withRoute } } catch (e: Exception) { Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned") diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index b34742177..78af426fc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -275,6 +275,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 diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d016dd37b..d358c4b6e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -278,6 +278,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro 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") return isFirstAnnounce } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 2b5fac102..54d006b9f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -112,6 +112,9 @@ class PacketProcessor(private val myPeerID: String) { override fun broadcastPacket(routed: RoutedPacket) { delegate?.relayPacket(routed) } + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return delegate?.sendToPeer(peerID, routed) ?: false + } } } @@ -323,4 +326,5 @@ interface PacketProcessorDelegate { fun sendAnnouncementToPeer(peerID: String) fun sendCachedMessages(peerID: String) fun relayPacket(routed: RoutedPacket) + fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index bc401daac..7a6bf584f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -65,9 +65,40 @@ class PacketRelayManager(private val myPeerID: String) { val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte()) 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()) { + // Check for duplicate hops to prevent routing loops + if (route.map { it.toHexString() }.toSet().size < route.size) { + Log.w(TAG, "Packet with duplicate hops dropped") + return + } + 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 val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID) - if (shouldRelay) { relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress)) } else { @@ -170,4 +201,17 @@ interface PacketRelayManagerDelegate { // Packet operations 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 } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 2d15b86e4..9d825e7b0 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -59,7 +59,8 @@ data class BitchatPacket( val timestamp: ULong, val payload: ByteArray, var signature: ByteArray? = null, // Changed from val to var for packet signing - var ttl: UByte + var ttl: UByte, + var route: List? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient ) : Parcelable { constructor( @@ -97,6 +98,7 @@ data class BitchatPacket( timestamp = timestamp, payload = payload, signature = null, // Remove signature for signing + route = route, ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility ) return BinaryProtocol.encode(unsignedPacket) @@ -149,6 +151,11 @@ data class BitchatPacket( if (!signature.contentEquals(other.signature)) return false } else if (other.signature != null) 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 } @@ -162,6 +169,7 @@ data class BitchatPacket( result = 31 * result + payload.contentHashCode() result = 31 * result + (signature?.contentHashCode() ?: 0) result = 31 * result + ttl.hashCode() + result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0) return result } } @@ -180,6 +188,7 @@ object BinaryProtocol { const val HAS_RECIPIENT: UByte = 0x01u const val HAS_SIGNATURE: UByte = 0x02u const val IS_COMPRESSED: UByte = 0x04u + const val HAS_ROUTE: UByte = 0x08u } private fun getHeaderSize(version: UByte): Int { @@ -231,6 +240,9 @@ object BinaryProtocol { if (isCompressed) { flags = flags or Flags.IS_COMPRESSED } + if (!packet.route.isNullOrEmpty()) { + flags = flags or Flags.HAS_ROUTE + } buffer.put(flags.toByte()) // Payload length (2 or 4 bytes, big-endian) - includes original size if compressed @@ -256,6 +268,14 @@ object BinaryProtocol { 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) if (isCompressed) { @@ -324,6 +344,7 @@ object BinaryProtocol { val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte() val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte() val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte() + val hasRoute = (flags and Flags.HAS_ROUTE) != 0u.toUByte() // Payload length - version-dependent (2 or 4 bytes) val payloadLength = if (version >= 2u.toUByte()) { @@ -335,6 +356,15 @@ object BinaryProtocol { // Calculate expected total size var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt() 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 (raw.size < expectedSize) return null @@ -350,6 +380,18 @@ object BinaryProtocol { recipientBytes } else null + // Route (optional) + val route: List? = if (hasRoute) { + val count = buffer.get().toUByte().toInt() + val hops = mutableListOf() + repeat(count) { + val hop = ByteArray(SENDER_ID_SIZE) + buffer.get(hop) + hops.add(hop) + } + hops + } else null + // Payload val payload = if (isCompressed) { // First 2 bytes are original size @@ -383,7 +425,8 @@ object BinaryProtocol { timestamp = timestamp, payload = payload, signature = signature, - ttl = ttl + ttl = ttl, + route = route ) } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt new file mode 100644 index 000000000..85ea6c9c7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/GossipTLV.kt @@ -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): 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? { + val result = mutableListOf() + 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() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt new file mode 100644 index 000000000..ceb87a919 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -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, val edges: List) + + // Map peerID -> nickname (may be null if unknown) + private val nicknames = ConcurrentHashMap() + // Adjacency (undirected): peerID -> set of neighbor peerIDs + private val adjacency = ConcurrentHashMap>() + // Latest announcement timestamp per peer (ULong from packet) + private val lastUpdate = ConcurrentHashMap() + + private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList())) + val graphState: StateFlow = _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?, 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() + 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>() + 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 } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt new file mode 100644 index 000000000..ae2794054 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt @@ -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? { + if (src == dst) return listOf(src) + val snapshot = MeshGraphService.getInstance().graphState.value + val neighbors = mutableMapOf>() + 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() + val prev = mutableMapOf() + val pq = PriorityQueue>(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() + var cur: String? = dst + while (cur != null) { + path.add(cur) + cur = prev[cur] + } + path.reverse() + Log.d(TAG, "Computed path $path") + return path + } +} + 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 6cd255475..208111fa4 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 @@ -24,10 +24,95 @@ 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 +@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) @Composable fun DebugSettingsSheet( @@ -129,6 +214,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)) { @@ -255,39 +345,82 @@ fun DebugSettingsSheet( } } } - // Left gutter layout: unit + ticks neatly aligned - Row(Modifier.fillMaxSize()) { - Box(Modifier.width(leftGutter).fillMaxHeight()) { - // Unit label on the far left, centered vertically - Text( - "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) - ) - // Tick labels right-aligned in gutter, top and bottom aligned - Text( - "${maxVal.toInt()}", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp) - ) - Text( - "0", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp) - ) - } - Spacer(Modifier.weight(1f)) + // Y-axis ticks (min/max) in the left margin + 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)) + 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)) + // Y-axis unit label (vertical) + 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)) + } + } + } +} + +@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 item { diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt new file mode 100644 index 000000000..ae896e530 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -0,0 +1,117 @@ + +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +class PacketRelayManagerTest { + + private lateinit var packetRelayManager: PacketRelayManager + private val delegate: PacketRelayManagerDelegate = mock() + + private val myPeerID = "1111111111111111" + private val otherPeerID = "2222222222222222" + private val nextHopPeerID = "3333333333333333" + private val finalRecipientID = "4444444444444444" + + @Before + fun setUp() { + packetRelayManager = PacketRelayManager(myPeerID) + packetRelayManager.delegate = delegate + whenever(delegate.getNetworkSize()).thenReturn(10) + whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0)) + } + + private fun createPacket(route: List?, recipient: String? = null): BitchatPacket { + return BitchatPacket( + type = MessageType.MESSAGE.value, + senderID = hexStringToPeerBytes(otherPeerID), + recipientID = recipient?.let { hexStringToPeerBytes(it) }, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + ttl = 5u, + route = route + ) + } + + @Test + fun `packet with duplicate hops is dropped`() = runTest { + val route = listOf( + hexStringToPeerBytes(nextHopPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `valid source-routed packet is relayed to next hop`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID), + hexStringToPeerBytes(nextHopPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `last hop does not relay further`() = runTest { + val route = listOf( + hexStringToPeerBytes(myPeerID) + ) + val packet = createPacket(route, finalRecipientID) + val routedPacket = RoutedPacket(packet, otherPeerID) + whenever(delegate.sendToPeer(any(), any())).thenReturn(true) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate).sendToPeer(org.mockito.kotlin.eq(finalRecipientID), any()) + verify(delegate, never()).broadcastPacket(any()) + } + + @Test + fun `packet with empty route is broadcast`() = runTest { + val packet = createPacket(null) + val routedPacket = RoutedPacket(packet, otherPeerID) + + packetRelayManager.handlePacketRelay(routedPacket) + + verify(delegate, never()).sendToPeer(any(), any()) + verify(delegate).broadcastPacket(any()) + } + + 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 + } +} diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index f6d101e25..9bb4529b6 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -30,7 +30,7 @@ Unknown flags are ignored by older implementations (they will simply not see a r ## Sender Behavior - Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route. -- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`. +- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from the sender (your peerID) to the recipient (the destination peerID). The `BitchatPacket` already contains dedicated `senderID` and `recipientID` fields. The `Route` field's `hops` list **SHOULD** contain the sequence of intermediate peer IDs that the packet should traverse. It **SHOULD NOT** duplicate the `senderID` or `recipientID` if they are already present in the `BitchatPacket`'s dedicated fields. Instead, the `hops` list represents the explicit path *between* the sender and recipient, starting from the first relay and ending with the last relay before the recipient. - Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`. - Signing: The route is covered by the Ed25519 signature (recommended): - Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol. @@ -40,11 +40,13 @@ Unknown flags are ignored by older implementations (they will simply not see a r When receiving a packet that is not addressed to you: 1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). -2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list: - - If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it. - - If successful, do NOT broadcast this packet further. - - If not directly connected (or the send fails), fall back to broadcast relaying. - - If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further). +2) If `HAS_ROUTE` is set: + - **Route Sanity Check**: Before processing, the relay **MUST** validate the route. If the route contains duplicate hops (i.e., the same peer ID appears more than once), the packet **MUST** be dropped to prevent loops. + - If your peer ID appears at index `i` in the hop list: + - If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it. + - If successful, do NOT broadcast this packet further. + - If not directly connected (or the send fails), fall back to broadcast relaying. + - If you are the last hop (no `i+1`), the packet has reached the end of its explicit route. The relay should then attempt to deliver it to the final `recipientID` if directly connected, but SHOULD NOT relay it further as a broadcast. TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay. @@ -64,11 +66,11 @@ TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whe - Variable sections (ordered): - `SenderID(8)` - `RecipientID(8)` (if present) - - `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes + - `HAS_ROUTE` set → `count=1`, `hops = [H1]` where `H1` is 8 bytes - Payload (optionally compressed) - Signature (64) -Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. +In this example, `SENDER_ID` is the sender, `RECIPIENT_ID` is the final recipient, and `H1` is the single intermediate relay. The `hops` list explicitly defines the path *between* the sender and recipient. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. ## Operational Notes