Merge branch 'main' into wifi-aware-refactor-mesh-core

# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/com/bitchat/android/BitchatApplication.kt
#	app/src/main/java/com/bitchat/android/MainActivity.kt
#	app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
#	app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
#	app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt
#	app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt
#	app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
This commit is contained in:
CC
2026-06-08 22:56:40 +02:00
146 changed files with 12976 additions and 2773 deletions
@@ -0,0 +1,55 @@
package com.bitchat.android.crypto
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.noise.NoiseEncryptionService
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Arrays
@RunWith(RobolectricTestRunner::class)
class EncryptionServiceTest {
private lateinit var context: Context
private lateinit var encryptionService: EncryptionService
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
encryptionService = EncryptionService(context)
}
@Test
fun `test clearPersistentIdentity changes keys`() {
// 1. Get initial keys
val initialStaticKey = encryptionService.getStaticPublicKey()
val initialSigningKey = encryptionService.getSigningPublicKey()
val initialFingerprint = encryptionService.getIdentityFingerprint()
assertNotNull("Initial static key should not be null", initialStaticKey)
assertNotNull("Initial signing key should not be null", initialSigningKey)
// 2. Call clearPersistentIdentity (Panic Mode)
encryptionService.clearPersistentIdentity()
// 3. Get keys again.
val afterStaticKey = encryptionService.getStaticPublicKey()
val afterSigningKey = encryptionService.getSigningPublicKey()
val afterFingerprint = encryptionService.getIdentityFingerprint()
// 4. Verify keys are different (Panic Mode should clear/rotate in-memory keys)
// Note: We use string comparison for byte arrays to be safe in assertion messages
assertNotEquals("Static key should change after panic",
Arrays.toString(initialStaticKey), Arrays.toString(afterStaticKey))
assertNotEquals("Signing key should change after panic",
Arrays.toString(initialSigningKey), Arrays.toString(afterSigningKey))
assertNotEquals("Fingerprint should change after panic",
initialFingerprint, afterFingerprint)
}
}
@@ -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
}
}
File diff suppressed because it is too large Load Diff
@@ -8,12 +8,14 @@ import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.ConscryptMode
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Date
@RunWith(RobolectricTestRunner::class)
@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues
class FileTransferTest {
@Test
@@ -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
}
}
@@ -0,0 +1,286 @@
package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class SecurityManagerTest {
private lateinit var securityManager: SecurityManager
private lateinit var fakeEncryptionService: FakeEncryptionService
private lateinit var mockDelegate: SecurityManagerDelegate
private val myPeerID = "1111222233334444"
private val otherPeerID = "aaaabbbbccccdddd"
private val unknownPeerID = "9999888877776666"
private val dummyPayload = "Hello World".toByteArray()
private val validSignature = ByteArray(64) { 1 }
private val invalidSignature = ByteArray(64) { 0 }
// Key pairs (using dummy bytes for mock verification)
private val otherSigningKey = ByteArray(32) { 0xA }
private val otherNoiseKey = ByteArray(32) { 0xB }
// Fake implementation to bypass initialization issues in tests
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
var shouldVerify: Boolean = true
var lastVerifySignature: ByteArray? = null
var lastVerifyKey: ByteArray? = null
override fun initialize() {
// Do nothing to avoid KeyStore access in tests
}
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
lastVerifySignature = signature
lastVerifyKey = publicKeyBytes
// Simple logic: if configured to verify, check if signature matches validSignature
// We use the signature bytes passed in setup()
if (shouldVerify) {
return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
}
return false
}
}
@Before
fun setup() {
fakeEncryptionService = FakeEncryptionService()
mockDelegate = mock()
securityManager = SecurityManager(fakeEncryptionService, myPeerID)
securityManager.delegate = mockDelegate
}
@After
fun tearDown() {
if (::securityManager.isInitialized) {
securityManager.shutdown()
}
}
@Test
fun `validatePacket - rejects packet with missing signature`() {
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = null
val result = securityManager.validatePacket(packet, otherPeerID)
assertFalse("Packet without signature should be rejected", result)
}
@Test
fun `validatePacket - rejects packet with invalid signature`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = invalidSignature
val result = securityManager.validatePacket(packet, otherPeerID)
assertFalse("Packet with invalid signature should be rejected", result)
}
@Test
fun `validatePacket - rejects packet from unknown peer (no key)`() {
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = unknownPeerID,
payload = dummyPayload
)
packet.signature = validSignature
val result = securityManager.validatePacket(packet, unknownPeerID)
assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result)
}
@Test
fun `validatePacket - accepts packet with valid signature from known peer`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = validSignature
val result = securityManager.validatePacket(packet, otherPeerID)
assertTrue("Valid signed packet from known peer should be accepted", result)
}
@Test
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
val announcement = IdentityAnnouncement(
nickname = "New User",
noisePublicKey = otherNoiseKey,
signingPublicKey = otherSigningKey
)
val payload = announcement.encode()!!
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 10u,
senderID = unknownPeerID,
payload = payload
)
packet.signature = validSignature
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
val result = securityManager.validatePacket(packet, unknownPeerID)
assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result)
// Verify we used the correct key
assertTrue("Should have used extracted key for verification",
fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
}
@Test
fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() {
val announcement = IdentityAnnouncement(
nickname = "New User",
noisePublicKey = otherNoiseKey,
signingPublicKey = otherSigningKey
)
val payload = announcement.encode()!!
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 10u,
senderID = unknownPeerID,
payload = payload
)
packet.signature = invalidSignature
val result = securityManager.validatePacket(packet, unknownPeerID)
assertFalse("ANNOUNCE with invalid signature should be rejected", result)
}
@Test
fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() {
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 10u,
senderID = unknownPeerID,
payload = byteArrayOf(0x00, 0x01, 0x02)
)
packet.signature = validSignature
val result = securityManager.validatePacket(packet, unknownPeerID)
assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result)
}
@Test
fun `validatePacket - ignores own packets`() {
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = myPeerID,
payload = dummyPayload
)
packet.signature = null
val result = securityManager.validatePacket(packet, myPeerID)
assertFalse("Own packets should return false (skipped)", result)
}
@Test
fun `validatePacket - detects duplicates`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = validSignature
val result1 = securityManager.validatePacket(packet, otherPeerID)
assertTrue("First packet should be accepted", result1)
val result2 = securityManager.validatePacket(packet, otherPeerID)
assertFalse("Duplicate packet should be rejected", result2)
}
@Test
fun `validatePacket - handles ANNOUNCE duplicates correctly`() {
val announcement = IdentityAnnouncement(
nickname = "New User",
noisePublicKey = otherNoiseKey,
signingPublicKey = otherSigningKey
)
val payload = announcement.encode()!!
// 1. Initial Announce (Fresh)
val packet1 = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u
senderID = unknownPeerID,
payload = payload
)
packet1.signature = validSignature
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID))
// 2. Relayed Duplicate (Lower TTL)
val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte())
assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID))
// 3. Direct Duplicate (Max TTL)
val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS)
assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID))
}
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
val info = PeerInfo(
id = peerID,
nickname = "Test User",
isConnected = true,
isDirectConnection = true,
noisePublicKey = ByteArray(32),
signingPublicKey = signingKey,
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info)
}
}
@@ -0,0 +1,58 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.util.Date
class AppStateStoreTest {
@Before
fun setUp() {
AppStateStore.clear()
}
@After
fun tearDown() {
AppStateStore.clear()
}
@Test
fun `public timeline collapses request sync replay even when android message ids differ`() {
val timestamp = Date(1_700_000_000_000L)
val originalDelivery = BitchatMessage(
id = "random-id-from-first-delivery",
sender = "alice",
content = "hello from sync",
timestamp = timestamp,
senderPeerID = "1122334455667788"
)
val requestSyncReplay = originalDelivery.copy(id = "different-random-id-from-replay")
AppStateStore.addPublicMessage(originalDelivery)
AppStateStore.addPublicMessage(requestSyncReplay)
assertEquals(listOf(originalDelivery), AppStateStore.publicMessages.value)
}
@Test
fun `public timeline still keeps same content sent at different packet timestamps`() {
val first = BitchatMessage(
id = "first-packet-id",
sender = "alice",
content = "same text",
timestamp = Date(1_700_000_000_000L),
senderPeerID = "1122334455667788"
)
val second = first.copy(
id = "second-packet-id",
timestamp = Date(first.timestamp.time + 1_000L)
)
AppStateStore.addPublicMessage(first)
AppStateStore.addPublicMessage(second)
assertEquals(listOf(first, second), AppStateStore.publicMessages.value)
}
}
@@ -0,0 +1,126 @@
package com.bitchat.android.services.meshgraph
import org.junit.Assert.*
import org.junit.Test
import org.junit.Before
class MeshGraphServiceTest {
private lateinit var service: MeshGraphService
@Before
fun setUp() {
// Use the test-only API to reset the singleton state safely
MeshGraphService.resetForTesting()
service = MeshGraphService.getInstance()
}
@Test
fun testUpdateFromAnnouncement_AddsNeighbors() {
val origin = "PeerA"
val neighbors = listOf("PeerB", "PeerC")
val timestamp = 100UL
service.updateFromAnnouncement(origin, "Alice", neighbors, timestamp)
val snapshot = service.graphState.value
// Verify nodes
assertTrue(snapshot.nodes.any { it.peerID == "PeerA" })
assertTrue(snapshot.nodes.any { it.peerID == "PeerB" })
assertTrue(snapshot.nodes.any { it.peerID == "PeerC" })
// Verify edges (unconfirmed because B and C haven't announced A)
// A -> B
val edgeAB = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }
assertNotNull(edgeAB)
assertFalse(edgeAB!!.isConfirmed)
assertEquals("PeerA", edgeAB.confirmedBy)
// A -> C
val edgeAC = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }
assertNotNull(edgeAC)
assertFalse(edgeAC!!.isConfirmed)
assertEquals("PeerA", edgeAC.confirmedBy)
}
@Test
fun testUpdateFromAnnouncement_NewerTimestampReplacesNeighbors() {
val origin = "PeerA"
// Initial state: A -> {B, C}
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL)
// Update: A -> {B, D} (newer timestamp)
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerD"), 200UL)
val snapshot = service.graphState.value
// Verify Edge A-B exists
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
// Verify Edge A-D exists
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") })
// Verify Edge A-C does NOT exist
assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") })
}
@Test
fun testUpdateFromAnnouncement_OlderTimestampIsIgnored() {
val origin = "PeerA"
// Initial state: A -> {B, C} at ts=200
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL)
// Old Update: A -> {D} at ts=100
service.updateFromAnnouncement(origin, "Alice", listOf("PeerD"), 100UL)
val snapshot = service.graphState.value
// Should still be {B, C}
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") })
assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") })
}
@Test
fun testUpdateFromAnnouncement_NullNeighborsClearsList_TheFix() {
val origin = "PeerA"
// Initial state: A -> {B, C} at ts=100
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL)
// Update with NULL neighbors (omitted TLV) at ts=200
service.updateFromAnnouncement(origin, "Alice", null, 200UL)
val snapshot = service.graphState.value
// All edges from A should be gone
val edgesFromA = snapshot.edges.filter { it.a == "PeerA" || it.b == "PeerA" }
assertTrue("Edges from PeerA should be empty after null update", edgesFromA.isEmpty())
// Nodes B and C might still exist if they were added to the node list, but connected edges are gone.
// Actually, publishSnapshot collects nodes from nicknames and announcements.
// Since we provided nicknames for PeerA, it should be there.
// PeerB and PeerC were only in announcements. Since A's announcement is cleared, and B/C never announced,
// they might disappear from the node list if 'nicknames' doesn't contain them.
// Let's check edges primarily as that's what routing cares about.
}
@Test
fun testUpdateFromAnnouncement_NullNeighborsWithOlderTimestampIsIgnored() {
val origin = "PeerA"
// Initial state: A -> {B, C} at ts=200
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL)
// Old Update with NULL neighbors at ts=100
service.updateFromAnnouncement(origin, "Alice", null, 100UL)
val snapshot = service.graphState.value
// Should still be {B, C} because the null update was older
assertFalse(snapshot.edges.isEmpty())
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
}
}