mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
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:
@@ -109,11 +109,19 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
// Connection caps: enforce on change
|
// Connection caps: enforce on change
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
dbg.maxConnectionsOverall.collect {
|
dbg.maxConnectionsOverall.collect { maxOverall ->
|
||||||
if (!isActive) return@collect
|
if (!isActive) return@collect
|
||||||
|
// 1. Enforce client limits (handled by tracker)
|
||||||
connectionTracker.enforceConnectionLimits()
|
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 {
|
connectionScope.launch {
|
||||||
@@ -123,9 +131,18 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
dbg.maxServerConnections.collect {
|
dbg.maxServerConnections.collect { maxServer ->
|
||||||
if (!isActive) return@collect
|
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) { }
|
} 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 {
|
fun cancelTransfer(transferId: String): Boolean {
|
||||||
return packetBroadcaster.cancelTransfer(transferId)
|
return packetBroadcaster.cancelTransfer(transferId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,11 +229,17 @@ class BluetoothConnectionTracker(
|
|||||||
*/
|
*/
|
||||||
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connection limit is reached
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Check if connection limit is reached
|
* Check if connection limit is reached
|
||||||
*/
|
*/
|
||||||
fun isConnectionLimitReached(): Boolean {
|
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 dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||||
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
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 clients = connectedDevices.values.filter { it.isClient }
|
||||||
val servers = connectedDevices.values.filter { !it.isClient }
|
|
||||||
|
|
||||||
// Enforce client cap first (we can actively disconnect)
|
// Enforce client cap first (we can actively disconnect)
|
||||||
if (clients.size > maxClient) {
|
if (clients.size > maxClient) {
|
||||||
@@ -259,23 +264,57 @@ class BluetoothConnectionTracker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: server cap enforced in GattServerManager (we don't have server handle here)
|
// Re-check overall cap after client cleanup
|
||||||
|
|
||||||
// Enforce overall cap by disconnecting oldest client connections
|
|
||||||
if (connectedDevices.size > maxOverall) {
|
if (connectedDevices.size > maxOverall) {
|
||||||
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
|
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
|
||||||
val excess = 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 }
|
.sortedBy { it.connectedAt }
|
||||||
.take(excess)
|
.take(excess)
|
||||||
toDisconnect.forEach { dc ->
|
|
||||||
|
clientsToDisconnect.forEach { dc ->
|
||||||
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
|
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
|
||||||
dc.gatt?.disconnect()
|
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
|
* Clean up a specific device connection
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,16 +49,29 @@ class BluetoothGattServerManager(
|
|||||||
fun enforceServerLimit(maxServer: Int) {
|
fun enforceServerLimit(maxServer: Int) {
|
||||||
if (maxServer <= 0) return
|
if (maxServer <= 0) return
|
||||||
try {
|
try {
|
||||||
val subs = connectionTracker.getSubscribedDevices()
|
// Use connection tracker to get actual connected server devices
|
||||||
if (subs.size > maxServer) {
|
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||||
val excess = subs.size - maxServer
|
if (servers.size > maxServer) {
|
||||||
subs.take(excess).forEach { d ->
|
val excess = servers.size - maxServer
|
||||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
// Disconnect oldest
|
||||||
|
servers.sortedBy { it.connectedAt }.take(excess).forEach { d ->
|
||||||
|
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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
|
* Start GATT server
|
||||||
*/
|
*/
|
||||||
@@ -122,9 +135,10 @@ class BluetoothGattServerManager(
|
|||||||
|
|
||||||
// Try to cancel any active connections explicitly before closing
|
// Try to cancel any active connections explicitly before closing
|
||||||
try {
|
try {
|
||||||
val devices = connectionTracker.getSubscribedDevices()
|
// Disconnect ALL server connections
|
||||||
devices.forEach { d ->
|
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
servers.forEach { d ->
|
||||||
|
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
override fun onPeerRemoved(peerID: String) {
|
override fun onPeerRemoved(peerID: String) {
|
||||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
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
|
// Also drop any Noise session state for this peer when they go offline
|
||||||
try {
|
try {
|
||||||
encryptionService.removePeer(peerID)
|
encryptionService.removePeer(peerID)
|
||||||
@@ -526,6 +529,10 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
connectionManager.broadcastPacket(routed)
|
connectionManager.broadcastPacket(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
|
return connectionManager.sendToPeer(peerID, routed)
|
||||||
|
}
|
||||||
|
|
||||||
override fun handleRequestSync(routed: RoutedPacket) {
|
override fun handleRequestSync(routed: RoutedPacket) {
|
||||||
// Decode request and respond with missing packets
|
// Decode request and respond with missing packets
|
||||||
val fromPeer = routed.peerID ?: return
|
val fromPeer = routed.peerID ?: return
|
||||||
@@ -540,11 +547,15 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
// Log incoming for debug graphs (do not double-count anywhere else)
|
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||||
try {
|
try {
|
||||||
val nick = getPeerNicknames()[peerID]
|
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(
|
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||||
packetType = packet.type.toString(),
|
packetType = packet.type.toString(),
|
||||||
fromPeerID = peerID,
|
fromPeerID = peerID,
|
||||||
fromNickname = nick,
|
fromNickname = nick,
|
||||||
fromDeviceAddress = device?.address
|
fromDeviceAddress = device?.address,
|
||||||
|
packetVersion = packet.version,
|
||||||
|
routeInfo = routeInfo
|
||||||
)
|
)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||||
@@ -1020,12 +1031,26 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||||
val tlvPayload = announcement.encode()
|
var tlvPayload = announcement.encode()
|
||||||
if (tlvPayload == null) {
|
if (tlvPayload == null) {
|
||||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||||
|
try {
|
||||||
|
val directPeers = getDirectPeerIDsForGossip()
|
||||||
|
if (directPeers.isNotEmpty()) {
|
||||||
|
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||||
|
tlvPayload = tlvPayload + gossip
|
||||||
|
}
|
||||||
|
// Always update our own node in the mesh graph with the neighbor list we used
|
||||||
|
try {
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val announcePacket = BitchatPacket(
|
val announcePacket = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
ttl = MAX_TTL,
|
ttl = MAX_TTL,
|
||||||
@@ -1069,12 +1094,26 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||||
val tlvPayload = announcement.encode()
|
var tlvPayload = announcement.encode()
|
||||||
if (tlvPayload == null) {
|
if (tlvPayload == null) {
|
||||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||||
|
try {
|
||||||
|
val directPeers = getDirectPeerIDsForGossip()
|
||||||
|
if (directPeers.isNotEmpty()) {
|
||||||
|
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||||
|
tlvPayload = tlvPayload + gossip
|
||||||
|
}
|
||||||
|
// Always update our own node in the mesh graph with the neighbor list we used
|
||||||
|
try {
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
type = MessageType.ANNOUNCE.value,
|
type = MessageType.ANNOUNCE.value,
|
||||||
ttl = MAX_TTL,
|
ttl = MAX_TTL,
|
||||||
@@ -1095,6 +1134,20 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
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
|
* Send leave announcement
|
||||||
*/
|
*/
|
||||||
@@ -1277,21 +1330,38 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||||
return try {
|
return try {
|
||||||
|
// Optionally compute and attach a source route for addressed packets
|
||||||
|
val withRoute = try {
|
||||||
|
val rec = packet.recipientID
|
||||||
|
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||||
|
val dest = rec.joinToString("") { b -> "%02x".format(b) }
|
||||||
|
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
|
||||||
|
if (path != null && path.size >= 3) {
|
||||||
|
// Exclude first (sender) and last (recipient); only intermediates
|
||||||
|
val intermediates = path.subList(1, path.size - 1)
|
||||||
|
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||||
|
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||||
|
// 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)
|
// Get the canonical packet data for signing (without signature)
|
||||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||||
if (packetDataForSigning == null) {
|
if (packetDataForSigning == null) {
|
||||||
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
||||||
return packet
|
return withRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the packet data using our signing key
|
// Sign the packet data using our signing key
|
||||||
val signature = encryptionService.signData(packetDataForSigning)
|
val signature = encryptionService.signData(packetDataForSigning)
|
||||||
if (signature != null) {
|
if (signature != null) {
|
||||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
||||||
packet.copy(signature = signature)
|
withRoute.copy(signature = signature)
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
||||||
packet
|
withRoute
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ class BluetoothPacketBroadcaster(
|
|||||||
incomingAddr: String?,
|
incomingAddr: String?,
|
||||||
toPeer: String?,
|
toPeer: String?,
|
||||||
toDeviceAddress: String,
|
toDeviceAddress: String,
|
||||||
ttl: UByte
|
ttl: UByte,
|
||||||
|
packetVersion: UByte = 1u,
|
||||||
|
routeInfo: String? = null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||||
@@ -80,7 +82,9 @@ class BluetoothPacketBroadcaster(
|
|||||||
toPeerID = toPeer,
|
toPeerID = toPeer,
|
||||||
toNickname = toNick,
|
toNickname = toNick,
|
||||||
toDeviceAddress = toDeviceAddress,
|
toDeviceAddress = toDeviceAddress,
|
||||||
previousHopPeerID = incomingPeer
|
previousHopPeerID = incomingPeer,
|
||||||
|
packetVersion = packetVersion,
|
||||||
|
routeInfo = routeInfo
|
||||||
)
|
)
|
||||||
// Keep the verbose relay message for human readability
|
// Keep the verbose relay message for human readability
|
||||||
manager.logPacketRelayDetailed(
|
manager.logPacketRelayDetailed(
|
||||||
@@ -94,7 +98,9 @@ class BluetoothPacketBroadcaster(
|
|||||||
toNickname = toNick,
|
toNickname = toNick,
|
||||||
toDeviceAddress = toDeviceAddress,
|
toDeviceAddress = toDeviceAddress,
|
||||||
ttl = ttl,
|
ttl = ttl,
|
||||||
isRelay = true
|
isRelay = true,
|
||||||
|
packetVersion = packetVersion,
|
||||||
|
routeInfo = routeInfo
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Silently ignore debug logging failures
|
// Silently ignore debug logging failures
|
||||||
@@ -221,17 +227,19 @@ class BluetoothPacketBroadcaster(
|
|||||||
TransferProgressManager.start(transferId, 1)
|
TransferProgressManager.start(transferId, 1)
|
||||||
}
|
}
|
||||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||||
|
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||||
val incomingAddr = routed.relayAddress
|
val incomingAddr = routed.relayAddress
|
||||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
|
||||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
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
|
// Prefer server-side subscriptions
|
||||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||||
if (serverTarget != null) {
|
if (serverTarget != null) {
|
||||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
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) {
|
if (transferId != null) {
|
||||||
TransferProgressManager.progress(transferId, 1, 1)
|
TransferProgressManager.progress(transferId, 1, 1)
|
||||||
TransferProgressManager.complete(transferId, 1)
|
TransferProgressManager.complete(transferId, 1)
|
||||||
@@ -245,7 +253,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||||
if (clientTarget != null) {
|
if (clientTarget != null) {
|
||||||
if (writeToDeviceConn(clientTarget, data)) {
|
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) {
|
if (transferId != null) {
|
||||||
TransferProgressManager.progress(transferId, 1, 1)
|
TransferProgressManager.progress(transferId, 1, 1)
|
||||||
TransferProgressManager.complete(transferId, 1)
|
TransferProgressManager.complete(transferId, 1)
|
||||||
@@ -284,6 +292,46 @@ class BluetoothPacketBroadcaster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||||
|
* Returns true if sent to at least one matching connection.
|
||||||
|
*/
|
||||||
|
fun sendToPeer(
|
||||||
|
targetPeerID: String,
|
||||||
|
routed: RoutedPacket,
|
||||||
|
gattServer: BluetoothGattServer?,
|
||||||
|
characteristic: BluetoothGattCharacteristic?
|
||||||
|
): Boolean {
|
||||||
|
val packet = routed.packet
|
||||||
|
val data = packet.toBinaryData() ?: return false
|
||||||
|
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||||
|
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||||
|
val incomingAddr = routed.relayAddress
|
||||||
|
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||||
|
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||||
|
|
||||||
|
// Try server-side connections first
|
||||||
|
val targetDevice = connectionTracker.getSubscribedDevices()
|
||||||
|
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||||
|
if (targetDevice != null) {
|
||||||
|
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||||
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try client-side connections next
|
||||||
|
val targetConn = connectionTracker.getConnectedDevices().values
|
||||||
|
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||||
|
if (targetConn != null) {
|
||||||
|
if (writeToDeviceConn(targetConn, data)) {
|
||||||
|
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal broadcast implementation - runs in serialized actor context
|
* Internal broadcast implementation - runs in serialized actor context
|
||||||
*/
|
*/
|
||||||
@@ -299,6 +347,8 @@ class BluetoothPacketBroadcaster(
|
|||||||
val incomingAddr = routed.relayAddress
|
val incomingAddr = routed.relayAddress
|
||||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
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) {
|
if (packet.recipientID != SpecialRecipients.BROADCAST) {
|
||||||
val recipientID = packet.recipientID?.let {
|
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}")
|
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
|
||||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
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
|
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}")
|
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
|
||||||
if (writeToDeviceConn(targetDeviceConn, data)) {
|
if (writeToDeviceConn(targetDeviceConn, data)) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
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
|
return // Sent, no need to continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +388,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
val subscribedDevices = connectionTracker.getSubscribedDevices()
|
val subscribedDevices = connectionTracker.getSubscribedDevices()
|
||||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
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", "")
|
val senderID = String(packet.senderID).replace("\u0000", "")
|
||||||
|
|
||||||
@@ -355,7 +405,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||||
if (sent) {
|
if (sent) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
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)
|
val sent = writeToDeviceConn(deviceConn, data)
|
||||||
if (sent) {
|
if (sent) {
|
||||||
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
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()
|
val fragmentID = FragmentPayload.generateFragmentID()
|
||||||
|
|
||||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||||
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
|
// Calculate dynamic fragment size to fit in MTU (512)
|
||||||
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
|
// 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)
|
fullData.sliceArray(offset..<endOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +121,16 @@ class FragmentManager {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||||
|
// Fix: Fragments must inherit source route and use v2 if routed
|
||||||
val fragmentPacket = BitchatPacket(
|
val fragmentPacket = BitchatPacket(
|
||||||
|
version = if (packet.route != null) 2u else 1u,
|
||||||
type = MessageType.FRAGMENT.value,
|
type = MessageType.FRAGMENT.value,
|
||||||
ttl = packet.ttl,
|
ttl = packet.ttl,
|
||||||
senderID = packet.senderID,
|
senderID = packet.senderID,
|
||||||
recipientID = packet.recipientID,
|
recipientID = packet.recipientID,
|
||||||
timestamp = packet.timestamp,
|
timestamp = packet.timestamp,
|
||||||
payload = fragmentPayload.encode(),
|
payload = fragmentPayload.encode(),
|
||||||
|
route = packet.route,
|
||||||
signature = null // iOS: signature: nil
|
signature = null // iOS: signature: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
|||||||
previousPeerID = null
|
previousPeerID = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||||
|
try {
|
||||||
|
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||||
|
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||||
|
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
||||||
return isFirstAnnounce
|
return isFirstAnnounce
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
override fun broadcastPacket(routed: RoutedPacket) {
|
override fun broadcastPacket(routed: RoutedPacket) {
|
||||||
delegate?.relayPacket(routed)
|
delegate?.relayPacket(routed)
|
||||||
}
|
}
|
||||||
|
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
|
return delegate?.sendToPeer(peerID, routed) ?: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,4 +326,5 @@ interface PacketProcessorDelegate {
|
|||||||
fun sendAnnouncementToPeer(peerID: String)
|
fun sendAnnouncementToPeer(peerID: String)
|
||||||
fun sendCachedMessages(peerID: String)
|
fun sendCachedMessages(peerID: String)
|
||||||
fun relayPacket(routed: RoutedPacket)
|
fun relayPacket(routed: RoutedPacket)
|
||||||
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,40 @@ class PacketRelayManager(private val myPeerID: String) {
|
|||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
||||||
|
|
||||||
|
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||||
|
val route = relayPacket.route
|
||||||
|
if (!route.isNullOrEmpty()) {
|
||||||
|
// 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
|
// Apply relay logic based on packet type and debug switch
|
||||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||||
|
|
||||||
if (shouldRelay) {
|
if (shouldRelay) {
|
||||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||||
} else {
|
} else {
|
||||||
@@ -170,4 +201,17 @@ interface PacketRelayManagerDelegate {
|
|||||||
|
|
||||||
// Packet operations
|
// Packet operations
|
||||||
fun broadcastPacket(routed: RoutedPacket)
|
fun broadcastPacket(routed: RoutedPacket)
|
||||||
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||||
|
val result = ByteArray(8)
|
||||||
|
var idx = 0
|
||||||
|
var out = 0
|
||||||
|
while (idx + 1 < hex.length && out < 8) {
|
||||||
|
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
|
||||||
|
result[out++] = b
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ data class BitchatPacket(
|
|||||||
val timestamp: ULong,
|
val timestamp: ULong,
|
||||||
val payload: ByteArray,
|
val payload: ByteArray,
|
||||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||||
var ttl: UByte
|
var ttl: UByte,
|
||||||
|
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -97,6 +98,7 @@ data class BitchatPacket(
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
signature = null, // Remove signature for signing
|
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
|
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
)
|
)
|
||||||
return BinaryProtocol.encode(unsignedPacket)
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
@@ -149,6 +151,11 @@ data class BitchatPacket(
|
|||||||
if (!signature.contentEquals(other.signature)) return false
|
if (!signature.contentEquals(other.signature)) return false
|
||||||
} else if (other.signature != null) return false
|
} else if (other.signature != null) return false
|
||||||
if (ttl != other.ttl) return false
|
if (ttl != other.ttl) return false
|
||||||
|
if (route != null || other.route != null) {
|
||||||
|
val a = route?.map { it.toList() } ?: emptyList()
|
||||||
|
val b = other.route?.map { it.toList() } ?: emptyList()
|
||||||
|
if (a != b) return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -162,6 +169,7 @@ data class BitchatPacket(
|
|||||||
result = 31 * result + payload.contentHashCode()
|
result = 31 * result + payload.contentHashCode()
|
||||||
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + ttl.hashCode()
|
result = 31 * result + ttl.hashCode()
|
||||||
|
result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +188,7 @@ object BinaryProtocol {
|
|||||||
const val HAS_RECIPIENT: UByte = 0x01u
|
const val HAS_RECIPIENT: UByte = 0x01u
|
||||||
const val HAS_SIGNATURE: UByte = 0x02u
|
const val HAS_SIGNATURE: UByte = 0x02u
|
||||||
const val IS_COMPRESSED: UByte = 0x04u
|
const val IS_COMPRESSED: UByte = 0x04u
|
||||||
|
const val HAS_ROUTE: UByte = 0x08u
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getHeaderSize(version: UByte): Int {
|
private fun getHeaderSize(version: UByte): Int {
|
||||||
@@ -193,12 +202,12 @@ object BinaryProtocol {
|
|||||||
try {
|
try {
|
||||||
// Try to compress payload if beneficial
|
// Try to compress payload if beneficial
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: UShort? = null
|
var originalPayloadSize: Int? = null
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
if (CompressionUtil.shouldCompress(payload)) {
|
if (CompressionUtil.shouldCompress(payload)) {
|
||||||
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
||||||
originalPayloadSize = payload.size.toUShort()
|
originalPayloadSize = payload.size
|
||||||
payload = compressedPayload
|
payload = compressedPayload
|
||||||
isCompressed = true
|
isCompressed = true
|
||||||
}
|
}
|
||||||
@@ -208,8 +217,12 @@ object BinaryProtocol {
|
|||||||
val headerSize = getHeaderSize(packet.version)
|
val headerSize = getHeaderSize(packet.version)
|
||||||
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
|
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
|
||||||
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
||||||
val payloadBytes = payload.size + if (isCompressed) 2 else 0
|
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
|
||||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
|
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) }
|
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -231,10 +244,14 @@ object BinaryProtocol {
|
|||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
flags = flags or Flags.IS_COMPRESSED
|
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())
|
buffer.put(flags.toByte())
|
||||||
|
|
||||||
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
|
// 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()) {
|
if (packet.version >= 2u.toUByte()) {
|
||||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||||
} else {
|
} else {
|
||||||
@@ -257,13 +274,27 @@ object BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Payload (with original size prepended if compressed)
|
||||||
if (isCompressed) {
|
if (isCompressed) {
|
||||||
val originalSize = originalPayloadSize
|
val originalSize = originalPayloadSize
|
||||||
if (originalSize != null) {
|
if (originalSize != null) {
|
||||||
|
if (packet.version >= 2u.toUByte()) {
|
||||||
|
buffer.putInt(originalSize.toInt())
|
||||||
|
} else {
|
||||||
buffer.putShort(originalSize.toShort())
|
buffer.putShort(originalSize.toShort())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
buffer.put(payload)
|
buffer.put(payload)
|
||||||
|
|
||||||
// Signature (if present)
|
// Signature (if present)
|
||||||
@@ -324,6 +355,8 @@ object BinaryProtocol {
|
|||||||
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
||||||
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
|
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
|
||||||
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
||||||
|
// 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)
|
// Payload length - version-dependent (2 or 4 bytes)
|
||||||
val payloadLength = if (version >= 2u.toUByte()) {
|
val payloadLength = if (version >= 2u.toUByte()) {
|
||||||
@@ -335,6 +368,22 @@ object BinaryProtocol {
|
|||||||
// Calculate expected total size
|
// Calculate expected total size
|
||||||
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
|
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
|
||||||
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
||||||
|
var routeCount = 0
|
||||||
|
if (hasRoute) {
|
||||||
|
// Peek count (1 byte) without consuming buffer for now
|
||||||
|
// 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 (hasSignature) expectedSize += SIGNATURE_SIZE
|
||||||
|
|
||||||
if (raw.size < expectedSize) return null
|
if (raw.size < expectedSize) return null
|
||||||
@@ -350,16 +399,47 @@ object BinaryProtocol {
|
|||||||
recipientBytes
|
recipientBytes
|
||||||
} else null
|
} 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
|
// Payload
|
||||||
val payload = if (isCompressed) {
|
val payload = if (isCompressed) {
|
||||||
// First 2 bytes are original size
|
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||||
if (payloadLength.toInt() < 2) return null
|
if (payloadLength.toInt() < lengthFieldBytes) return null
|
||||||
val originalSize = buffer.getShort().toInt()
|
|
||||||
|
val originalSize = if (version >= 2u.toUByte()) {
|
||||||
|
buffer.getInt()
|
||||||
|
} else {
|
||||||
|
buffer.getShort().toUShort().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
// Compressed payload
|
// Compressed payload
|
||||||
val compressedPayload = ByteArray(payloadLength.toInt() - 2)
|
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||||
|
val compressedPayload = ByteArray(compressedSize)
|
||||||
buffer.get(compressedPayload)
|
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
|
// Decompress
|
||||||
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
||||||
} else {
|
} else {
|
||||||
@@ -383,7 +463,8 @@ object BinaryProtocol {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
signature = signature,
|
signature = signature,
|
||||||
ttl = ttl
|
ttl = ttl,
|
||||||
|
route = route
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.bitchat.android.services.meshgraph
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
|
||||||
|
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
|
||||||
|
*/
|
||||||
|
object GossipTLV {
|
||||||
|
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
|
||||||
|
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
|
||||||
|
*/
|
||||||
|
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
|
||||||
|
val unique = peerIDs.distinct().take(10)
|
||||||
|
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
|
||||||
|
if (valueBytes.size > 255) {
|
||||||
|
// Safety check, though 10*8 = 80 bytes, so well under 255
|
||||||
|
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
|
||||||
|
}
|
||||||
|
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a TLV-encoded announce payload and extract neighbor peerIDs.
|
||||||
|
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
|
||||||
|
*/
|
||||||
|
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset + 2 <= payload.size) {
|
||||||
|
val type = payload[offset].toUByte()
|
||||||
|
val len = payload[offset + 1].toUByte().toInt()
|
||||||
|
offset += 2
|
||||||
|
if (offset + len > payload.size) break
|
||||||
|
val value = payload.sliceArray(offset until offset + len)
|
||||||
|
offset += len
|
||||||
|
|
||||||
|
if (type == DIRECT_NEIGHBORS_TYPE) {
|
||||||
|
// Value is N*8 bytes of peer IDs
|
||||||
|
var pos = 0
|
||||||
|
while (pos + 8 <= value.size) {
|
||||||
|
val idBytes = value.sliceArray(pos until pos + 8)
|
||||||
|
result.add(bytesToPeerIdHex(idBytes))
|
||||||
|
pos += 8
|
||||||
|
}
|
||||||
|
return result // present (possibly empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not present
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
|
||||||
|
val clean = hexString.lowercase().take(16)
|
||||||
|
val result = ByteArray(8) { 0 }
|
||||||
|
var idx = 0
|
||||||
|
var out = 0
|
||||||
|
while (idx + 1 < clean.length && out < 8) {
|
||||||
|
val byteStr = clean.substring(idx, idx + 2)
|
||||||
|
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
|
||||||
|
result[out++] = b
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bytesToPeerIdHex(bytes: ByteArray): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (b in bytes.take(8)) {
|
||||||
|
sb.append(String.format("%02x", b))
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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?,
|
toNickname: String?,
|
||||||
toDeviceAddress: String?,
|
toDeviceAddress: String?,
|
||||||
ttl: UByte?,
|
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
|
// Build message only if verbose logging is enabled, but always update stats
|
||||||
val senderLabel = when {
|
val senderLabel = when {
|
||||||
@@ -441,18 +443,20 @@ class DebugSettingsManager private constructor() {
|
|||||||
val fromAddr = fromDeviceAddress ?: "?"
|
val fromAddr = fromDeviceAddress ?: "?"
|
||||||
val toAddr = toDeviceAddress ?: "?"
|
val toAddr = toDeviceAddress ?: "?"
|
||||||
val ttlStr = ttl?.toString() ?: "?"
|
val ttlStr = ttl?.toString() ?: "?"
|
||||||
|
val routeStr = if (routeInfo != null) " $routeInfo" else ""
|
||||||
|
|
||||||
if (verboseLoggingEnabled.value) {
|
if (verboseLoggingEnabled.value) {
|
||||||
if (isRelay) {
|
if (isRelay) {
|
||||||
|
// Relay: show [previousPeer] -> [nextPeer]
|
||||||
addDebugMessage(
|
addDebugMessage(
|
||||||
DebugMessage.RelayEvent(
|
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 {
|
} else {
|
||||||
addDebugMessage(
|
addDebugMessage(
|
||||||
DebugMessage.PacketEvent(
|
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
|
// 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) {
|
if (verboseLoggingEnabled.value) {
|
||||||
val who = fromNickname ?: fromPeerID ?: "unknown"
|
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 now = System.currentTimeMillis()
|
||||||
val visible = _debugSheetVisible.value
|
val visible = _debugSheetVisible.value
|
||||||
@@ -489,10 +494,11 @@ class DebugSettingsManager private constructor() {
|
|||||||
if (visible) updateRelayStatsFromTimestamps()
|
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) {
|
if (verboseLoggingEnabled.value) {
|
||||||
val who = toNickname ?: toPeerID ?: "unknown"
|
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 now = System.currentTimeMillis()
|
||||||
val visible = _debugSheetVisible.value
|
val visible = _debugSheetVisible.value
|
||||||
|
|||||||
@@ -25,12 +25,120 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
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 }
|
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@@ -139,6 +247,11 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mesh topology visualization (moved below verbose logging)
|
||||||
|
item {
|
||||||
|
MeshTopologySection()
|
||||||
|
}
|
||||||
|
|
||||||
// GATT controls
|
// GATT controls
|
||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
@@ -488,9 +601,6 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Connected devices
|
// Connected devices
|
||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
|
|||||||
@@ -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
@@ -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).
|
## 1. Protocol Versioning & Layering
|
||||||
- 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`
|
|
||||||
|
|
||||||
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`.
|
**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.
|
||||||
- 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 on‑wire `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).
|
|
||||||
|
|
||||||
## 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.
|
## 2. Packet Structure Comparison
|
||||||
- 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]`.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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).
|
### V2 Packet (Source Routed)
|
||||||
2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list:
|
```text
|
||||||
- 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.
|
| Fixed Header (16) | Variable Sections |
|
||||||
- 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).
|
| 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.).
|
| Field | Size (v1) | Size (v2) | Description |
|
||||||
- Signature verification MUST include the route field when present; route tampering will invalidate the signature.
|
|---|---|---|---|
|
||||||
|
| **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 don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`.
|
## 3. Source Route Specification
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
* **Location:** Immediately follows `RecipientID`.
|
||||||
- Variable sections (ordered):
|
* **Structure:**
|
||||||
- `SenderID(8)`
|
* `Count` (1 byte): Number of intermediate hops (`N`).
|
||||||
- `RecipientID(8)` (if present)
|
* `Hops` (`N * 8` bytes): Sequence of Peer IDs.
|
||||||
- `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` 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.
|
### 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).
|
||||||
|
|||||||
Reference in New Issue
Block a user