mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:05:21 +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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user