Gossip mesh topology + source-based routing (#445)

* wip mesh graph

* gossip fix

* gossip works

* source-based routing wip

* log

* update spec to be explicit about intermediate hops only

* drop duplicate hops

* add to spec

* test

* forgot comma

* source routing v2

* add compression bomb protection

* v2 source routing

* fragmented packets inherit route

* update spec

* Gossip routing tmp with connection limit fixed (#569)

* fix: r8 exception for LocationManager (#566)

* fragmented packets inherit route

* update spec

* fix connection limits

* fix: deserialization issue with routed packets

* log

* dynamic fragment size

* fragment size

* add tests

* feat(gossip): implement two-way handshake for source routing edges

- Update MeshGraphService to track directed announcements
- Require bidirectional announcements for a 'confirmed' edge
- Update RoutePlanner to strictly use confirmed edges
- Update Mesh Topology debug view to show confirmed vs unconfirmed edges (solid vs dotted)

* docs: update SOURCE_ROUTING.md with two-way handshake requirement

* evict stale peers from mesh graph service

* better logging

* fix announce spe

* fix: empty route

* fix spec

* fix: compile error in DebugSettingsSheet and potential NPE in RoutePlanner

* revert

* try again
This commit is contained in:
callebtc
2026-01-13 04:18:07 +07:00
committed by GitHub
parent d164b3f9bc
commit 66012e9fe9
18 changed files with 1251 additions and 133 deletions
@@ -109,11 +109,19 @@ class BluetoothConnectionManager(
}
// Connection caps: enforce on change
connectionScope.launch {
dbg.maxConnectionsOverall.collect {
dbg.maxConnectionsOverall.collect { maxOverall ->
if (!isActive) return@collect
// 1. Enforce client limits (handled by tracker)
connectionTracker.enforceConnectionLimits()
// Also enforce server side best-effort
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
// 2. Enforce overall limit on server connections if needed
// (Tracker knows about all connections but can't disconnect servers directly)
val maxServer = dbg.maxServerConnections.value
val excessServers = connectionTracker.getExcessServerConnections(maxServer, maxOverall)
excessServers.forEach { device ->
Log.d(TAG, "Disconnecting server ${device.address} due to overall cap")
serverManager.disconnectDevice(device)
}
}
}
connectionScope.launch {
@@ -123,9 +131,18 @@ class BluetoothConnectionManager(
}
}
connectionScope.launch {
dbg.maxServerConnections.collect {
dbg.maxServerConnections.collect { maxServer ->
if (!isActive) return@collect
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
// Enforce server specific limit
serverManager.enforceServerLimit(maxServer)
// Also check if this change puts us over the overall limit
val maxOverall = dbg.maxConnectionsOverall.value
val excessServers = connectionTracker.getExcessServerConnections(maxServer, maxOverall)
excessServers.forEach { device ->
Log.d(TAG, "Disconnecting server ${device.address} due to overall cap")
serverManager.disconnectDevice(device)
}
}
}
} catch (_: Exception) { }
@@ -261,6 +278,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)
}
@@ -229,11 +229,17 @@ class BluetoothConnectionTracker(
*/
fun getConnectedDeviceCount(): Int = connectedDevices.size
/**
* Check if connection limit is reached
*/
/**
* Check if connection limit is reached
*/
fun isConnectionLimitReached(): Boolean {
return connectedDevices.size >= powerManager.getMaxConnections()
// Respect debug override if set
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val maxConnections = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
return connectedDevices.size >= maxConnections
}
/**
@@ -244,10 +250,9 @@ class BluetoothConnectionTracker(
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
val maxServer = dbg?.maxServerConnections?.value ?: maxOverall
// Note: maxServer is handled by GattServerManager, but we need to respect overall limit here too
val clients = connectedDevices.values.filter { it.isClient }
val servers = connectedDevices.values.filter { !it.isClient }
// Enforce client cap first (we can actively disconnect)
if (clients.size > maxClient) {
@@ -259,22 +264,56 @@ class BluetoothConnectionTracker(
}
}
// Note: server cap enforced in GattServerManager (we don't have server handle here)
// Enforce overall cap by disconnecting oldest client connections
// Re-check overall cap after client cleanup
if (connectedDevices.size > maxOverall) {
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
val excess = connectedDevices.size - maxOverall
val toDisconnect = connectedDevices.values
.filter { it.isClient } // only clients from here
// Prefer disconnecting clients first to satisfy overall cap
val clientsToDisconnect = connectedDevices.values
.filter { it.isClient }
.sortedBy { it.connectedAt }
.take(excess)
toDisconnect.forEach { dc ->
clientsToDisconnect.forEach { dc ->
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
dc.gatt?.disconnect()
}
}
}
/**
* Get excess server connections that should be disconnected to satisfy limits.
* This allows the Manager to coordinate server disconnects since Tracker doesn't control the server.
*/
fun getExcessServerConnections(maxServer: Int, maxOverall: Int): List<BluetoothDevice> {
val servers = connectedDevices.values.filter { !it.isClient }
val excessList = mutableListOf<BluetoothDevice>()
// 1. Check server specific limit
if (servers.size > maxServer) {
val excessCount = servers.size - maxServer
val toRemove = servers.sortedBy { it.connectedAt }.take(excessCount)
excessList.addAll(toRemove.map { it.device })
}
// 2. Check overall limit (considering we might have already removed some above)
// We need to count how many connections we will have after the above removals
val currentTotal = connectedDevices.size
val plannedRemovals = excessList.size
val projectedTotal = currentTotal - plannedRemovals
if (projectedTotal > maxOverall) {
val furtherExcess = projectedTotal - maxOverall
// We can only remove servers here. Clients are handled in enforceConnectionLimits.
// Filter out devices we already planned to remove
val remainingServers = servers.filter { s -> excessList.none { it.address == s.device.address } }
val toRemove = remainingServers.sortedBy { it.connectedAt }.take(furtherExcess)
excessList.addAll(toRemove.map { it.device })
}
return excessList
}
/**
* Clean up a specific device connection
@@ -49,15 +49,28 @@ class BluetoothGattServerManager(
fun enforceServerLimit(maxServer: Int) {
if (maxServer <= 0) return
try {
val subs = connectionTracker.getSubscribedDevices()
if (subs.size > maxServer) {
val excess = subs.size - maxServer
subs.take(excess).forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
// Use connection tracker to get actual connected server devices
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
if (servers.size > maxServer) {
val excess = servers.size - maxServer
// Disconnect oldest
servers.sortedBy { it.connectedAt }.take(excess).forEach { d ->
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
}
}
} catch (_: Exception) { }
}
/**
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
*/
fun disconnectDevice(device: BluetoothDevice) {
try {
gattServer?.cancelConnection(device)
} catch (e: Exception) {
Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}")
}
}
/**
* Start GATT server
@@ -122,9 +135,10 @@ class BluetoothGattServerManager(
// Try to cancel any active connections explicitly before closing
try {
val devices = connectionTracker.getSubscribedDevices()
devices.forEach { d ->
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
// Disconnect ALL server connections
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
servers.forEach { d ->
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
}
} catch (_: Exception) { }
@@ -172,6 +172,9 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onPeerRemoved(peerID: String) {
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
// Also drop any Noise session state for this peer when they go offline
try {
encryptionService.removePeer(peerID)
@@ -526,6 +529,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
@@ -536,19 +543,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address,
packetVersion = packet.version,
routeInfo = routeInfo
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
@@ -1020,11 +1031,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,
@@ -1069,11 +1094,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,
@@ -1095,6 +1134,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<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
*/
@@ -1277,21 +1330,38 @@ 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)")
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
packet.copy(route = hopsBytes, version = 2u)
} 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")
@@ -68,7 +68,9 @@ class BluetoothPacketBroadcaster(
incomingAddr: String?,
toPeer: String?,
toDeviceAddress: String,
ttl: UByte
ttl: UByte,
packetVersion: UByte = 1u,
routeInfo: String? = null
) {
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
@@ -80,7 +82,9 @@ class BluetoothPacketBroadcaster(
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
previousHopPeerID = incomingPeer,
packetVersion = packetVersion,
routeInfo = routeInfo
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
@@ -94,7 +98,9 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
isRelay = true
isRelay = true,
packetVersion = packetVersion,
routeInfo = routeInfo
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -221,17 +227,19 @@ class BluetoothPacketBroadcaster(
TransferProgressManager.start(transferId, 1)
}
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 senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
// Prefer server-side subscriptions
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
@@ -245,7 +253,7 @@ class BluetoothPacketBroadcaster(
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
@@ -283,6 +291,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
@@ -299,6 +347,8 @@ class BluetoothPacketBroadcaster(
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
if (packet.recipientID != SpecialRecipients.BROADCAST) {
val recipientID = packet.recipientID?.let {
@@ -314,7 +364,7 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue
}
}
@@ -328,7 +378,7 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
if (writeToDeviceConn(targetDeviceConn, data)) {
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue
}
}
@@ -338,7 +388,7 @@ class BluetoothPacketBroadcaster(
val subscribedDevices = connectionTracker.getSubscribedDevices()
val connectedDevices = connectionTracker.getConnectedDevices()
Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
val senderID = String(packet.senderID).replace("\u0000", "")
@@ -355,7 +405,7 @@ class BluetoothPacketBroadcaster(
val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
}
}
@@ -373,7 +423,7 @@ class BluetoothPacketBroadcaster(
val sent = writeToDeviceConn(deviceConn, data)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl)
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
}
}
}
@@ -77,8 +77,31 @@ class FragmentManager {
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// 512 - Overhead
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
@@ -98,13 +121,16 @@ class FragmentManager {
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
@@ -286,6 +286,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
}
@@ -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
}
@@ -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: ${peerID.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
}
@@ -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<ByteArray>? = 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 {
@@ -193,12 +202,12 @@ object BinaryProtocol {
try {
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: UShort? = null
var originalPayloadSize: Int? = null
var isCompressed = false
if (CompressionUtil.shouldCompress(payload)) {
CompressionUtil.compress(payload)?.let { compressedPayload ->
originalPayloadSize = payload.size.toUShort()
originalPayloadSize = payload.size
payload = compressedPayload
isCompressed = true
}
@@ -208,8 +217,12 @@ object BinaryProtocol {
val headerSize = getHeaderSize(packet.version)
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
val payloadBytes = payload.size + if (isCompressed) 2 else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
val payloadBytes = payload.size + sizeFieldBytes
val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE)
} else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
@@ -231,10 +244,14 @@ object BinaryProtocol {
if (isCompressed) {
flags = flags or Flags.IS_COMPRESSED
}
// HAS_ROUTE is only supported for v2+ packets
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
flags = flags or Flags.HAS_ROUTE
}
buffer.put(flags.toByte())
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
val payloadDataSize = payload.size + sizeFieldBytes
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
@@ -256,12 +273,26 @@ object BinaryProtocol {
buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size))
}
}
// Route (optional, v2+ only): 1 byte count + N*8 bytes
if (packet.version >= 2u.toUByte() && !packet.route.isNullOrEmpty()) {
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) {
val originalSize = originalPayloadSize
if (originalSize != null) {
buffer.putShort(originalSize.toShort())
if (packet.version >= 2u.toUByte()) {
buffer.putInt(originalSize.toInt())
} else {
buffer.putShort(originalSize.toShort())
}
}
}
buffer.put(payload)
@@ -324,6 +355,8 @@ 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()
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte()
// Payload length - version-dependent (2 or 4 bytes)
val payloadLength = if (version >= 2u.toUByte()) {
@@ -335,6 +368,22 @@ 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
// The buffer is currently positioned at the start of SenderID (after fixed header)
// We must skip SenderID and RecipientID (if present) to find the route count
val currentPos = buffer.position()
var routeOffset = currentPos + SENDER_ID_SIZE
if (hasRecipient) {
routeOffset += RECIPIENT_ID_SIZE
}
if (raw.size >= routeOffset + 1) {
routeCount = raw[routeOffset].toUByte().toInt()
}
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
}
if (hasSignature) expectedSize += SIGNATURE_SIZE
if (raw.size < expectedSize) return null
@@ -350,15 +399,46 @@ object BinaryProtocol {
recipientBytes
} else null
// Route (optional)
val route: List<ByteArray>? = if (hasRoute) {
val count = buffer.get().toUByte().toInt()
if (count == 0) {
null // Treat empty route list as null to enforce canonical representation
} else {
val hops = mutableListOf<ByteArray>()
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
if (payloadLength.toInt() < 2) return null
val originalSize = buffer.getShort().toInt()
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
if (payloadLength.toInt() < lengthFieldBytes) return null
val originalSize = if (version >= 2u.toUByte()) {
buffer.getInt()
} else {
buffer.getShort().toUShort().toInt()
}
// Compressed payload
val compressedPayload = ByteArray(payloadLength.toInt() - 2)
val compressedSize = payloadLength.toInt() - lengthFieldBytes
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Security check: Compression bomb protection
if (compressedSize > 0) {
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 50_000.0) {
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
return null
}
}
// Decompress
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
@@ -383,7 +463,8 @@ object BinaryProtocol {
timestamp = timestamp,
payload = payload,
signature = signature,
ttl = ttl
ttl = ttl,
route = route
)
} 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,123 @@
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 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, val isConfirmed: Boolean, val confirmedBy: String? = null)
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?>()
// Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
private val announcements = ConcurrentHashMap<String, Set<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
// Update what originPeerID announces
// Filter out self-loops just in case
val newSet = neighborsOrNull.distinct().take(10).filter { it != originPeerID }.toSet()
announcements[originPeerID] = newSet
publishSnapshot()
}
}
fun updateNickname(peerID: String, nickname: String?) {
if (nickname == null) return
nicknames[peerID] = nickname
publishSnapshot()
}
/**
* Remove a peer from the graph completely (e.g. when stale/offline).
*/
fun removePeer(peerID: String) {
synchronized(this) {
nicknames.remove(peerID)
announcements.remove(peerID)
lastUpdate.remove(peerID)
publishSnapshot()
}
}
private fun publishSnapshot() {
// Collect all known nodes from nicknames and announcements
val allNodes = mutableSetOf<String>()
allNodes.addAll(nicknames.keys)
announcements.forEach { (origin, neighbors) ->
allNodes.add(origin)
allNodes.addAll(neighbors)
}
val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
val edges = mutableListOf<GraphEdge>()
val processedPairs = mutableSetOf<Pair<String, String>>()
// We only care about connections that exist in at least one direction.
// So iterating through all entries in `announcements` covers every declared edge.
announcements.forEach { (source, targets) ->
targets.forEach { target ->
val pair = if (source <= target) source to target else target to source
if (processedPairs.add(pair)) {
// This is a new pair we haven't evaluated yet
val (a, b) = pair
val aAnnouncesB = announcements[a]?.contains(b) == true
val bAnnouncesA = announcements[b]?.contains(a) == true
if (aAnnouncesB && bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = true))
} else if (aAnnouncesB) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
} else if (bAnnouncesA) {
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
}
}
}
}
val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
}
companion object {
@Volatile private var INSTANCE: MeshGraphService? = null
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
}
}
}
@@ -0,0 +1,68 @@
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>>()
// Only consider confirmed edges for routing
snapshot.edges.filter { it.isConfirmed }.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 top = pq.poll() ?: break
val (u, d) = top
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
}
}
@@ -418,7 +418,9 @@ class DebugSettingsManager private constructor() {
toNickname: String?,
toDeviceAddress: String?,
ttl: UByte?,
isRelay: Boolean = true
isRelay: Boolean = true,
packetVersion: UByte = 1u,
routeInfo: String? = null
) {
// Build message only if verbose logging is enabled, but always update stats
val senderLabel = when {
@@ -441,18 +443,20 @@ class DebugSettingsManager private constructor() {
val fromAddr = fromDeviceAddress ?: "?"
val toAddr = toDeviceAddress ?: "?"
val ttlStr = ttl?.toString() ?: "?"
val routeStr = if (routeInfo != null) " $routeInfo" else ""
if (verboseLoggingEnabled.value) {
if (isRelay) {
// Relay: show [previousPeer] -> [nextPeer]
addDebugMessage(
DebugMessage.RelayEvent(
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"♻️ Relayed v$packetVersion $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
} else {
addDebugMessage(
DebugMessage.PacketEvent(
"📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
"📤 Sent v$packetVersion $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr$routeStr"
)
)
}
@@ -462,10 +466,11 @@ class DebugSettingsManager private constructor() {
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})$routeStr"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
@@ -489,10 +494,11 @@ class DebugSettingsManager private constructor() {
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null, packetVersion: UByte = 1u, routeInfo: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing v$packetVersion $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})$routeStr"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
@@ -25,12 +25,120 @@ 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
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
@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) {
if (e.isConfirmed) {
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
} else {
// Unconfirmed: draw "solid" from declarer, "dashed" from other
val start = if (e.confirmedBy == e.a) p1 else p2
val end = if (e.confirmedBy == e.a) p2 else p1
val midX = (start.x + end.x) / 2
val midY = (start.y + end.y) / 2
val mid = androidx.compose.ui.geometry.Offset(midX, midY)
// Solid half
drawLine(color = Color(0xFF4A90E2), start = start, end = mid, strokeWidth = 2f)
// Dotted half
drawLine(
color = Color(0xFF4A90E2),
start = mid,
end = end,
strokeWidth = 2f,
pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f)
)
}
}
}
// 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.size) { i ->
val node = nodes[i]
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
}
}
}
}
}
}
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@@ -139,6 +247,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)) {
@@ -348,7 +461,7 @@ fun DebugSettingsSheet(
kotlinx.coroutines.delay(1000)
}
}
// Helper functions moved to top-level composable below to avoid scope issues
// Render two blocks: Incoming and Outgoing
@@ -488,9 +601,6 @@ fun DebugSettingsSheet(
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -0,0 +1,191 @@
package com.bitchat.android.mesh
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.FragmentPayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Random
@RunWith(RobolectricTestRunner::class)
class FragmentManagerTest {
private lateinit var fragmentManager: FragmentManager
private val senderID = "1122334455667788"
private val recipientID = "8877665544332211"
@Before
fun setup() {
fragmentManager = FragmentManager()
}
@Test
fun `test fragmentation without route`() {
// Create a large payload (e.g., 1000 bytes)
val payload = ByteArray(1000)
Random().nextBytes(payload)
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 7u,
route = null
)
val fragments = fragmentManager.createFragments(packet)
assertTrue("Should create multiple fragments", fragments.size > 1)
// Verify each fragment fits in MTU (512)
for (fragment in fragments) {
val encodedSize = fragment.toBinaryData()?.size ?: 0
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
// Inspect the payload data size
val fragmentPayload = FragmentPayload.decode(fragment.payload)
assertNotNull(fragmentPayload)
}
}
@Test
fun `test fragmentation with route`() {
// Create a large payload
val payload = ByteArray(1000)
Random().nextBytes(payload)
// Create a fake route (3 hops)
val route = listOf(
hexStringToByteArray("AABBCCDDEEFF0011"),
hexStringToByteArray("1100FFEEDDCCBBAA"),
hexStringToByteArray("1234567890ABCDEF")
)
val packet = BitchatPacket(
version = 2u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 7u,
route = route
)
val fragments = fragmentManager.createFragments(packet)
assertTrue("Should create multiple fragments", fragments.size > 1)
// Verify fragments retain the route and version 2
for (fragment in fragments) {
assertEquals("Fragment version should be 2", 2u.toUByte(), fragment.version)
assertEquals("Fragment should have the route", route.size, fragment.route?.size)
val encodedSize = fragment.toBinaryData()?.size ?: 0
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
}
}
@Test
fun `test fragmentation size difference with and without route`() {
// This test specifically checks if the dynamic calculation logic works
// by observing that fragments with routes carry less data payload per fragment
val payload = ByteArray(2000) // Large enough to ensure full fragments
Random().nextBytes(payload)
// 1. Without route
val packetNoRoute = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 7u,
route = null
)
val fragmentsNoRoute = fragmentManager.createFragments(packetNoRoute)
val firstFragPayloadNoRoute = FragmentPayload.decode(fragmentsNoRoute[0].payload)
val dataSizeNoRoute = firstFragPayloadNoRoute?.data?.size ?: 0
// 2. With large route (e.g., 5 hops)
val route = List(5) { hexStringToByteArray("000000000000000$it") }
val packetWithRoute = BitchatPacket(
version = 2u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 7u,
route = route
)
val fragmentsWithRoute = fragmentManager.createFragments(packetWithRoute)
val firstFragPayloadWithRoute = FragmentPayload.decode(fragmentsWithRoute[0].payload)
val dataSizeWithRoute = firstFragPayloadWithRoute?.data?.size ?: 0
println("Data size without route: $dataSizeNoRoute")
println("Data size with route: $dataSizeWithRoute")
assertTrue("Data payload should be smaller with route", dataSizeWithRoute < dataSizeNoRoute)
// Rough verification of the math:
// 5 hops * 8 bytes = 40 bytes extra.
// Plus v2 header overhead differences.
// The difference should be roughly 40+ bytes.
assertTrue("Difference should be significant", (dataSizeNoRoute - dataSizeWithRoute) >= 40)
}
@Test
fun `test reassembly`() {
val originalPayload = ByteArray(1500)
Random().nextBytes(originalPayload)
val originalPacket = BitchatPacket(
version = 1u,
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = System.currentTimeMillis().toULong(),
payload = originalPayload,
ttl = 7u
)
val fragments = fragmentManager.createFragments(originalPacket)
var reassembledPacket: BitchatPacket? = null
// Feed fragments back into FragmentManager
// Note: FragmentManager stores state in incomingFragments
for (fragment in fragments) {
val result = fragmentManager.handleFragment(fragment)
if (result != null) {
reassembledPacket = result
}
}
assertNotNull("Should have reassembled packet", reassembledPacket)
assertEquals("Type should match", originalPacket.type, reassembledPacket!!.type)
assertEquals("Payload size should match", originalPacket.payload.size, reassembledPacket.payload.size)
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8)
for (i in 0 until 8) {
val byteStr = hexString.substring(i * 2, i * 2 + 2)
result[i] = byteStr.toInt(16).toByte()
}
return result
}
}
@@ -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<ByteArray>?, 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
}
}
+119 -55
View File
@@ -1,78 +1,142 @@
# Source-Based Routing for BitChat Packets
# Source-Based Routing for BitChat Packets (v2)
This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying.
This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.
Status: optional and backward-compatible.
**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).
## Layering Overview
---
- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength).
- Flags: adds a new bit `HAS_ROUTE (0x08)`.
- Variable sections (when present, in order):
1) `SenderID` (8 bytes)
2) `RecipientID` (8 bytes) if `HAS_RECIPIENT`
3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs
4) `Payload` (with optional compression preamble)
5) `Signature` (64 bytes) if `HAS_SIGNATURE`
## 1. Protocol Versioning & Layering
Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before).
To support source routing and larger payloads, the packet format has been upgraded to **Version 2**.
## Route Field Encoding
* **Version 1 (Legacy):** 2-byte payload length limit. Ignores routing flags.
* **Version 2 (Current):** 4-byte payload length limit. Supports Source Routing.
- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`.
- Layout (immediately after optional `RecipientID`):
- `count`: 1 byte (0..255)
- `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes
- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the onwire `senderID`/`recipientID` encoding.
- Size impact: `1 + 8*N` bytes, where `N = count`.
- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it).
**Key Rule:** The `HAS_ROUTE (0x08)` flag is **only valid** if the packet `version >= 2`. Relays receiving a v1 packet must ignore this flag even if set.
## 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 Dijkstras 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]`.
- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8byte 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.
## 2. Packet Structure Comparison
## Relay Behavior
The following diagram illustrates the structural differences between a standard v1 packet and a source-routed v2 packet.
When receiving a packet that is not addressed to you:
### V1 Packet (Legacy)
```text
+-------------------+---------------------------------------------------------+
| Fixed Header (14) | Variable Sections |
+-------------------+----------+-------------+------------------+-------------+
| Ver: 1 (1B) | SenderID | RecipientID | Payload | Signature |
| Type, TTL, etc. | (8B) | (8B) | (Length in Head) | (64B) |
| Len: 2 Bytes | | (Optional) | | (Optional) |
+-------------------+----------+-------------+------------------+-------------+
```
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).
### V2 Packet (Source Routed)
```text
+-------------------+-----------------------------------------------------------------------------+
| Fixed Header (16) | Variable Sections |
+-------------------+----------+-------------+-----------------------+------------------+-------------+
| Ver: 2 (1B) | SenderID | RecipientID | SOURCE ROUTE | Payload | Signature |
| Type, TTL, etc. | (8B) | (8B) | (Variable) | (Length in Head) | (64B) |
| Len: 4 Bytes | | (Required*) | Only if HAS_ROUTE=1 | | (Optional) |
+-------------------+----------+-------------+-----------------------+------------------+-------------+
```
TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay.
**(*) Note:** A `Route` can be attached to **any** packet type that has a `RecipientID` (flag `HAS_RECIPIENT` set).
## Receiver Behavior (Destination)
### Fixed Header Differences
- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.).
- Signature verification MUST include the route field when present; route tampering will invalidate the signature.
| Field | Size (v1) | Size (v2) | Description |
|---|---|---|---|
| **Version** | 1 byte | 1 byte | `0x01` vs `0x02` |
| **Payload Length** | **2 bytes** | **4 bytes** | `UInt32` in v2 to support large files. **Excludes** route/IDs/sig. |
| **Total Size** | **14 bytes** | **16 bytes** | V2 header is 2 bytes larger. |
## Compatibility
---
- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that dont implement this feature will ignore the route entirely, because they wont set or check `HAS_ROUTE`.
- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol.
## 3. Source Route Specification
## Minimal Example (conceptual)
The `Source Route` field is a variable-length list of **intermediate hops** that the packet must traverse.
- Header (fixed 13 bytes): unchanged.
- Variable sections (ordered):
- `SenderID(8)`
- `RecipientID(8)` (if present)
- `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes
- Payload (optionally compressed)
- Signature (64)
* **Location:** Immediately follows `RecipientID`.
* **Structure:**
* `Count` (1 byte): Number of intermediate hops (`N`).
* `Hops` (`N * 8` bytes): Sequence of Peer IDs.
Where `H0` is the senders peer ID, `H2` is the recipients 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.
### Intermediate Hops Only
The route list MUST contain **only** the intermediate relays between the sender and the recipient.
* **DO NOT** include the `SenderID` (it is already in the packet).
* **DO NOT** include the `RecipientID` (it is already in the packet).
## Operational Notes
**Example:**
Topology: `Alice (Sender) -> Bob -> Charlie -> Dave (Recipient)`
* Packet `SenderID`: Alice
* Packet `RecipientID`: Dave
* Packet `Route`: `[Bob, Charlie]` (Count = 2)
- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed.
- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop.
- Implementations may introduce policy controls (e.g., disable source routing, cap max route length).
---
## 4. Topology Discovery (Gossip)
To calculate routes, nodes need a view of the network topology. This is achieved via a **Neighbor List** extension to the `IdentityAnnouncement` packet.
* **Mechanism:** `IdentityAnnouncement` packets contain a TLV (Type-Length-Value) payload.
* **New TLV Type:** `0x04` (Direct Neighbors).
* **Content:** A list of Peer IDs that the announcing node is directly connected to.
**TLV Structure (Type 0x04):**
```text
[Type: 0x04] [Length: 1B] [Count: 1B] [NeighborID1 (8B)] [NeighborID2 (8B)] ...
```
Nodes receiving this TLV update their local mesh graph, linking the sender to the listed neighbors.
### Edge Verification (Two-Way Handshake)
To prevent spoofing and routing through stale connections, the Mesh Graph service implements a strict two-way handshake verification:
* **Unconfirmed Edge:** If Peer A announces Peer B, but Peer B does *not* announce Peer A, the connection is treated as **unconfirmed**. Unconfirmed edges are visualized as dotted lines in debug tools but are **excluded** from route calculations.
* **Confirmed Edge:** An edge is only valid for routing when **both** peers explicitly announce each other in their neighbor lists. This ensures that the connection is bidirectional and currently active from both perspectives.
---
## 5. Fragmentation & Source Routing
When a large source-routed packet (e.g., File Transfer) exceeds the MTU and requires fragmentation:
1. **Version Inheritance:** All fragments MUST be marked as **Version 2**.
2. **Route Inheritance:** All fragments MUST contain the **exact same Route field** as the parent packet.
**Why?** If fragments were sent as v1 packets or without routes, they would fall back to flooding, negating the bandwidth benefits of source routing for large data transfers.
---
## 6. Security & Signing
Source routing is fully secured by the existing Ed25519 signature scheme.
* **Scope:** The signature covers the **entire packet structure** (Header + Sender + Recipient + Route + Payload).
* **Verification:** The receiver verifies the signature against the `SenderID`'s public key.
* **Integrity:** Any tampering with the route list by malicious relays will invalidate the signature, causing the packet to be dropped by the destination.
**Signature Input Construction:**
Serialize the packet exactly as transmitted, but temporarily set `TTL = 0` and remove the `Signature` bytes.
---
## 7. Relay Logic
When a node receives a packet **not** addressed to itself:
1. **Check Route:**
* Is `Version >= 2`?
* Is `HAS_ROUTE` flag set?
* Is the route list non-empty?
2. **If YES (Source Routed):**
* Find local Peer ID in the route list at index `i`.
* **Next Hop:** The peer at `i + 1`.
* **Last Hop:** If `i` is the last index, the Next Hop is the `RecipientID`.
* **Action:** Attempt to unicast (`sendToPeer`) to the Next Hop.
* **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.
3. **If NO (Standard):**
* Flood the packet to all connected neighbors (subject to TTL and probability rules).