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 0cc7f967..23539dcb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -247,6 +247,16 @@ class BluetoothConnectionManager( 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 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 2ddb6139..a636042a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -401,6 +401,10 @@ class BluetoothMeshService(private val context: Context) { override fun relayPacket(routed: RoutedPacket) { connectionManager.broadcastPacket(routed) } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + return connectionManager.sendToPeer(peerID, routed) + } } // BluetoothConnectionManager delegates @@ -987,21 +991,36 @@ 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) } + 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 1fc52bb4..b5c5484f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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 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 54ecffbe..315308e2 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 + } } } @@ -310,4 +313,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 f292534b..246960bf 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,35 @@ 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()) { + 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 { @@ -206,4 +232,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 3dcc47a2..0a0c466a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -57,7 +57,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), including sender and final recipient ) : Parcelable { constructor( @@ -95,7 +96,8 @@ data class BitchatPacket( timestamp = timestamp, payload = payload, 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) } @@ -147,6 +149,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 } @@ -160,6 +167,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 } } @@ -177,6 +185,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 } fun encode(packet: BitchatPacket): ByteArray? { @@ -215,6 +224,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 bytes, big-endian) - includes original size if compressed @@ -236,6 +248,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) { @@ -302,6 +322,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 val payloadLength = buffer.getShort().toUShort() @@ -309,6 +330,15 @@ object BinaryProtocol { // Calculate expected total size var expectedSize = HEADER_SIZE + 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 @@ -324,6 +354,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 @@ -357,7 +399,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/RoutePlanner.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/RoutePlanner.kt new file mode 100644 index 00000000..ae279405 --- /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 + } +} +