mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Add mesh ping and route diagnostics
This commit is contained in:
parent
2243b4f416
commit
71092a5e17
@ -124,6 +124,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val meshPingManager = MeshPingManager(myPeerID, serviceScope) { packet ->
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
}
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
@ -741,6 +744,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
?: return false
|
||||
return boardStore.ingestRemoteForRelay(wire, routed.packet)
|
||||
}
|
||||
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
|
||||
|
||||
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
|
||||
}
|
||||
|
||||
// BluetoothConnectionManager delegates
|
||||
@ -831,6 +837,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service
|
||||
*/
|
||||
|
||||
@ -134,6 +134,7 @@ class MeshCore(
|
||||
send = ::sendVouchPayload
|
||||
)
|
||||
}
|
||||
private val meshPingManager = MeshPingManager(myPeerID, scope, ::dispatchUnsignedDiagnostic)
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
|
||||
@ -591,9 +592,20 @@ class MeshCore(
|
||||
?: return false
|
||||
return boardStore.ingestRemoteForRelay(wire, routed.packet)
|
||||
}
|
||||
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
|
||||
|
||||
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchUnsignedDiagnostic(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
|
||||
|
||||
101
app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
Normal file
101
app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt
Normal file
@ -0,0 +1,101 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.protocol.MeshPingPayload
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class MeshPingResult(val rttMillis: Long, val hopCount: Int)
|
||||
|
||||
internal class MeshPingManager(
|
||||
private val myPeerID: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val send: (BitchatPacket) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* BLE and Wi-Fi Aware have separate manager instances, but a reply can return over either
|
||||
* transport. Pending probes therefore live at process scope and are namespaced by the
|
||||
* local identity.
|
||||
*/
|
||||
private val pending = ConcurrentHashMap<String, Pending>()
|
||||
}
|
||||
|
||||
private data class Pending(
|
||||
val peerID: String,
|
||||
val startedNanos: Long,
|
||||
val callback: (MeshPingResult?) -> Unit,
|
||||
val timeout: Job,
|
||||
)
|
||||
|
||||
private val inboundByLink = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
fun ping(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
val payload = MeshPingPayload.create(MeshDiagnosticsConstants.TTL)
|
||||
val key = pendingKey(payload)
|
||||
val timeout = scope.launch {
|
||||
delay(MeshDiagnosticsConstants.TIMEOUT_MILLIS)
|
||||
pending.remove(key)?.callback?.invoke(null)
|
||||
}
|
||||
pending[key] = Pending(peerID, System.nanoTime(), callback, timeout)
|
||||
send(packet(MessageType.PING, peerID, payload))
|
||||
}
|
||||
|
||||
fun handlePing(routed: RoutedPacket) {
|
||||
val packet = routed.packet
|
||||
val payload = MeshPingPayload.decode(packet.payload) ?: return
|
||||
val sender = packet.senderID.toHexString()
|
||||
val ingress = routed.ingressLinkID ?: routed.relayAddress ?: routed.peerID ?: return
|
||||
if (!consumeInboundBudget(ingress)) return
|
||||
send(packet(MessageType.PONG, sender, payload))
|
||||
}
|
||||
|
||||
fun handlePong(routed: RoutedPacket) {
|
||||
val payload = MeshPingPayload.decode(routed.packet.payload) ?: return
|
||||
val key = pendingKey(payload)
|
||||
val candidate = pending[key] ?: return
|
||||
if (routed.packet.senderID.toHexString() != candidate.peerID) return
|
||||
if (!pending.remove(key, candidate)) return
|
||||
candidate.timeout.cancel()
|
||||
val elapsed = (System.nanoTime() - candidate.startedNanos) / 1_000_000
|
||||
candidate.callback(MeshPingResult(elapsed, payload.hopCount(routed.packet.ttl)))
|
||||
}
|
||||
|
||||
private fun pendingKey(payload: MeshPingPayload): String =
|
||||
"$myPeerID:${payload.nonce.toHexString()}"
|
||||
|
||||
private fun consumeInboundBudget(link: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamps = inboundByLink.computeIfAbsent(link) { ArrayDeque() }
|
||||
synchronized(timestamps) {
|
||||
while (timestamps.firstOrNull()?.let {
|
||||
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
|
||||
} == true
|
||||
) {
|
||||
timestamps.removeFirst()
|
||||
}
|
||||
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
|
||||
timestamps.addLast(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun packet(type: MessageType, recipientPeerID: String, payload: MeshPingPayload) =
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = type.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload.encode(),
|
||||
signature = null,
|
||||
ttl = MeshDiagnosticsConstants.TTL,
|
||||
)
|
||||
}
|
||||
@ -39,6 +39,7 @@ interface MeshService {
|
||||
|
||||
fun sendBroadcastAnnounce()
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit)
|
||||
|
||||
fun getPeerNicknames(): Map<String, String>
|
||||
fun getPeerRSSI(): Map<String, Int>
|
||||
@ -77,6 +78,7 @@ interface MeshService {
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String?
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String>
|
||||
fun getDirectBlePeerIDs(): Set<String> = emptySet()
|
||||
fun printDeviceAddressesForPeers(): String
|
||||
fun getDebugStatus(): String
|
||||
|
||||
|
||||
@ -180,6 +180,8 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
|
||||
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
|
||||
MessageType.PING -> delegate?.handlePing(routed)
|
||||
MessageType.PONG -> delegate?.handlePong(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
@ -361,6 +363,8 @@ interface PacketProcessorDelegate {
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
fun handleBoardPost(routed: RoutedPacket): Boolean = false
|
||||
fun handlePing(routed: RoutedPacket) {}
|
||||
fun handlePong(routed: RoutedPacket) {}
|
||||
|
||||
// Communication
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
|
||||
@ -4,8 +4,10 @@ import com.bitchat.android.protocol.MessageType
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@ -32,6 +34,7 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
|
||||
// Coroutines
|
||||
private val relayScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val diagnosticRelayTimestamps = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
/**
|
||||
* Main entry point for relay decisions
|
||||
@ -60,6 +63,13 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
Log.d(TAG, "TTL expired, not relaying packet")
|
||||
return
|
||||
}
|
||||
|
||||
val isDiagnostic = MessageType.fromValue(packet.type) in
|
||||
setOf(MessageType.PING, MessageType.PONG)
|
||||
if (isDiagnostic && !consumeDiagnosticRelayBudget(routed, peerID)) {
|
||||
Log.w(TAG, "Diagnostic relay budget exhausted for ingress link")
|
||||
return
|
||||
}
|
||||
|
||||
// Decrement TTL by 1
|
||||
val networkSize = delegate?.getNetworkSize() ?: 1
|
||||
@ -108,11 +118,36 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
// Apply relay logic based on packet type and debug switch
|
||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||
if (shouldRelay) {
|
||||
if (isDiagnostic) {
|
||||
delay(
|
||||
Random.nextLong(
|
||||
MeshDiagnosticsConstants.RELAY_JITTER_MIN_MILLIS,
|
||||
MeshDiagnosticsConstants.RELAY_JITTER_MAX_MILLIS + 1,
|
||||
)
|
||||
)
|
||||
}
|
||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||
} else {
|
||||
Log.d(TAG, "Relay decision: NOT relaying packet type ${packet.type}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeDiagnosticRelayBudget(routed: RoutedPacket, fallbackPeerID: String): Boolean {
|
||||
val ingressKey = routed.ingressLinkID ?: routed.relayAddress ?: fallbackPeerID
|
||||
val now = System.currentTimeMillis()
|
||||
val timestamps = diagnosticRelayTimestamps.computeIfAbsent(ingressKey) { ArrayDeque() }
|
||||
synchronized(timestamps) {
|
||||
while (timestamps.firstOrNull()?.let {
|
||||
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
|
||||
} == true
|
||||
) {
|
||||
timestamps.removeFirst()
|
||||
}
|
||||
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
|
||||
timestamps.addLast(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a packet is specifically addressed to us
|
||||
@ -140,6 +175,9 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
* Determine if we should relay this packet based on type and network conditions
|
||||
*/
|
||||
private fun shouldRelayPacket(packet: BitchatPacket, fromPeerID: String): Boolean {
|
||||
if (MessageType.fromValue(packet.type) in setOf(MessageType.PING, MessageType.PONG)) {
|
||||
return true
|
||||
}
|
||||
// Always relay if TTL is high enough (indicates important message)
|
||||
if (packet.ttl >= 4u) {
|
||||
Log.d(TAG, "High TTL (${packet.ttl}), relaying")
|
||||
|
||||
@ -88,8 +88,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
// Mesh diagnostics are intentionally unsigned for iOS wire compatibility.
|
||||
val isUnsignedDiagnostic =
|
||||
messageType in setOf(MessageType.PING, MessageType.PONG) && packet.signature == null
|
||||
if (!isUnsignedDiagnostic && !verifyPacketSignature(packet, peerID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,9 @@ import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -37,6 +39,10 @@ class UnifiedMeshService(
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val powerManager = PowerManager.getInstance(context.applicationContext)
|
||||
private var announcementJob: Job? = null
|
||||
private val diagnosticsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val meshPingManager = MeshPingManager(bluetooth.myPeerID, diagnosticsScope) { packet ->
|
||||
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override val myPeerID: String
|
||||
get() = bluetooth.myPeerID
|
||||
@ -311,6 +317,10 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshPingManager.ping(peerID, callback)
|
||||
}
|
||||
|
||||
override fun getPeerNicknames(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
|
||||
@ -429,6 +439,13 @@ class UnifiedMeshService(
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getDirectBlePeerIDs(): Set<String> =
|
||||
try {
|
||||
bluetooth.getDeviceAddressToPeerMapping().values.toSet()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
override fun printDeviceAddressesForPeers(): String {
|
||||
return buildString {
|
||||
appendLine(bluetooth.printDeviceAddressesForPeers())
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
@ -54,9 +55,10 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
/** Publishes signed one-time prekeys for forward-secret courier mail. */
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
@Deprecated("Use localSupported() so runtime bridge state is included")
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue)
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PREKEYS.rawValue or GROUPS.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue)
|
||||
|
||||
@Volatile
|
||||
private var bridgeEnabled: Boolean = false
|
||||
|
||||
@ -24,7 +24,9 @@ enum class MessageType(val value: UByte) {
|
||||
BOARD_POST(0x23u),
|
||||
GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast
|
||||
PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys
|
||||
NOSTR_CARRIER(0x28u); // Signed bridge/gateway event carrier
|
||||
NOSTR_CARRIER(0x28u), // Signed bridge/gateway event carrier
|
||||
PING(MeshDiagnosticsConstants.PING_TYPE),
|
||||
PONG(MeshDiagnosticsConstants.PONG_TYPE);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
object MeshDiagnosticsConstants {
|
||||
val PING_TYPE: UByte = 0x26u
|
||||
val PONG_TYPE: UByte = 0x27u
|
||||
val TTL: UByte = 7u
|
||||
const val NONCE_SIZE = 8
|
||||
const val PAYLOAD_SIZE = NONCE_SIZE + 1
|
||||
const val TIMEOUT_MILLIS = 10_000L
|
||||
const val INBOUND_RATE_LIMIT = 5
|
||||
const val INBOUND_RATE_WINDOW_MILLIS = 10_000L
|
||||
const val RELAY_JITTER_MIN_MILLIS = 20L
|
||||
const val RELAY_JITTER_MAX_MILLIS = 60L
|
||||
const val CAPABILITY_BIT = 6
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* iOS-compatible mesh diagnostics payload.
|
||||
*
|
||||
* Wire format: 8-byte nonce followed by the TTL used to launch the ping.
|
||||
* Decoding deliberately accepts trailing bytes for forward compatibility.
|
||||
*/
|
||||
data class MeshPingPayload(
|
||||
val nonce: ByteArray,
|
||||
val originTtl: UByte,
|
||||
) {
|
||||
init {
|
||||
require(nonce.size == MeshDiagnosticsConstants.NONCE_SIZE) {
|
||||
"Mesh ping nonce must be ${MeshDiagnosticsConstants.NONCE_SIZE} bytes"
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(): ByteArray = nonce + byteArrayOf(originTtl.toByte())
|
||||
|
||||
fun hopCount(receivedTtl: UByte): Int =
|
||||
(originTtl.toInt() - receivedTtl.toInt() + 1).coerceAtLeast(1)
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is MeshPingPayload && nonce.contentEquals(other.nonce) && originTtl == other.originTtl
|
||||
|
||||
override fun hashCode(): Int = 31 * nonce.contentHashCode() + originTtl.hashCode()
|
||||
|
||||
companion object {
|
||||
fun create(originTtl: UByte): MeshPingPayload =
|
||||
MeshPingPayload(
|
||||
ByteArray(MeshDiagnosticsConstants.NONCE_SIZE).also(SecureRandom()::nextBytes),
|
||||
originTtl,
|
||||
)
|
||||
|
||||
fun decode(data: ByteArray): MeshPingPayload? {
|
||||
if (data.size < MeshDiagnosticsConstants.PAYLOAD_SIZE) return null
|
||||
return MeshPingPayload(
|
||||
data.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE),
|
||||
data[MeshDiagnosticsConstants.NONCE_SIZE].toUByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,6 +76,31 @@ class MeshGraphService private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shortest path using only bidirectionally confirmed topology claims.
|
||||
*/
|
||||
fun computeRoute(fromPeerID: String, toPeerID: String): List<String>? {
|
||||
if (fromPeerID == toPeerID) return listOf(fromPeerID)
|
||||
val adjacency = mutableMapOf<String, MutableSet<String>>()
|
||||
graphState.value.edges.filter(GraphEdge::isConfirmed).forEach { edge ->
|
||||
adjacency.getOrPut(edge.a) { mutableSetOf() }.add(edge.b)
|
||||
adjacency.getOrPut(edge.b) { mutableSetOf() }.add(edge.a)
|
||||
}
|
||||
val queue = ArrayDeque<List<String>>()
|
||||
val visited = mutableSetOf(fromPeerID)
|
||||
queue.add(listOf(fromPeerID))
|
||||
while (queue.isNotEmpty()) {
|
||||
val path = queue.removeFirst()
|
||||
adjacency[path.last()].orEmpty().sorted().forEach { neighbor ->
|
||||
if (!visited.add(neighbor)) return@forEach
|
||||
val next = path + neighbor
|
||||
if (neighbor == toPeerID) return next
|
||||
queue.add(next)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
// Collect all known nodes from nicknames and announcements
|
||||
val allNodes = mutableSetOf<String>()
|
||||
|
||||
@ -50,6 +50,8 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
|
||||
MessageType.BOARD_POST -> 8
|
||||
MessageType.PREKEY_BUNDLE -> 9
|
||||
MessageType.GROUP_MESSAGE -> 10
|
||||
MessageType.PING,
|
||||
MessageType.PONG,
|
||||
MessageType.NOSTR_CARRIER,
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME -> null
|
||||
|
||||
@ -306,7 +306,8 @@ fun AboutSheet(
|
||||
bridgeEnabled: Boolean,
|
||||
onBridgeEnabledChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onShowDebug: (() -> Unit)? = null
|
||||
onShowDebug: (() -> Unit)? = null,
|
||||
onShowMeshTopology: (() -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@ -1271,6 +1272,16 @@ fun AboutSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onShowMeshTopology != null) {
|
||||
TextButton(onClick = onShowMeshTopology) {
|
||||
Text(
|
||||
text = "network → mesh topology",
|
||||
fontSize = 13.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_footer),
|
||||
fontSize = 11.sp,
|
||||
|
||||
@ -866,12 +866,14 @@ private fun ChatDialogs(
|
||||
|
||||
// About sheet
|
||||
var showDebugSheet by remember { mutableStateOf(false) }
|
||||
var showMeshTopology by remember { mutableStateOf(false) }
|
||||
AboutSheet(
|
||||
isPresented = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss,
|
||||
onShowDebug = { showDebugSheet = true },
|
||||
bridgeEnabled = bridgeUiState.enabled,
|
||||
onBridgeEnabledChange = viewModel::setBridgeEnabled
|
||||
onBridgeEnabledChange = viewModel::setBridgeEnabled,
|
||||
onShowMeshTopology = { showMeshTopology = true },
|
||||
)
|
||||
if (showDebugSheet) {
|
||||
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
||||
@ -880,6 +882,11 @@ private fun ChatDialogs(
|
||||
meshService = viewModel.meshService
|
||||
)
|
||||
}
|
||||
MeshTopologySheet(
|
||||
isPresented = showMeshTopology,
|
||||
onDismiss = { showMeshTopology = false },
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
)
|
||||
|
||||
// Location channels sheet
|
||||
if (showLocationChannelsSheet) {
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -29,7 +30,9 @@ class CommandProcessor(
|
||||
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
||||
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
||||
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
|
||||
CommandSuggestion("/ping", emptyList(), "<nickname>", "measure mesh round-trip time"),
|
||||
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
|
||||
CommandSuggestion("/trace", emptyList(), "<nickname>", "estimate the mesh path"),
|
||||
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
|
||||
CommandSuggestion("/w", emptyList(), null, "see who's online")
|
||||
)
|
||||
@ -54,6 +57,8 @@ class CommandProcessor(
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/channels" -> handleChannelsCommand()
|
||||
"/group" -> handleGroupCommand(parts, viewModel)
|
||||
"/ping" -> handlePingCommand(parts, meshService)
|
||||
"/trace" -> handleTraceCommand(parts, meshService)
|
||||
else -> handleUnknownCommand(cmd)
|
||||
}
|
||||
|
||||
@ -494,6 +499,104 @@ class CommandProcessor(
|
||||
else -> messageManager.addMessage(message)
|
||||
}
|
||||
}
|
||||
private fun handlePingCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (!isMeshContext()) {
|
||||
addCommandOutput("mesh diagnostics are only available in #mesh")
|
||||
return
|
||||
}
|
||||
val targetName = parts.getOrNull(1)?.removePrefix("@")
|
||||
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
|
||||
if (targetName == null) {
|
||||
addCommandOutput("usage: /ping <nickname>")
|
||||
return
|
||||
}
|
||||
if (peerID == null) {
|
||||
addCommandOutput("user '$targetName' not found")
|
||||
return
|
||||
}
|
||||
val destination = currentCommandDestination()
|
||||
addCommandOutput("pinging $targetName…", destination)
|
||||
meshService.sendMeshPing(peerID) { result ->
|
||||
val output = if (result == null) {
|
||||
"no reply from $targetName"
|
||||
} else {
|
||||
val hops = if (result.hopCount == 1) {
|
||||
"direct (1 hop)"
|
||||
} else {
|
||||
"${result.hopCount} hops"
|
||||
}
|
||||
"pong from $targetName: ${result.rttMillis} ms · $hops"
|
||||
}
|
||||
addCommandOutput(output, destination)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTraceCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (!isMeshContext()) {
|
||||
addCommandOutput("mesh diagnostics are only available in #mesh")
|
||||
return
|
||||
}
|
||||
val targetName = parts.getOrNull(1)?.removePrefix("@")
|
||||
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
|
||||
if (targetName == null) {
|
||||
addCommandOutput("usage: /trace <nickname>")
|
||||
return
|
||||
}
|
||||
if (peerID == null) {
|
||||
addCommandOutput("user '$targetName' not found")
|
||||
return
|
||||
}
|
||||
val graph = MeshGraphService.getInstance()
|
||||
val route = graph.computeRoute(meshService.myPeerID, peerID)
|
||||
?: meshService.getPeerInfo(peerID)
|
||||
?.takeIf { it.isConnected && it.isDirectConnection }
|
||||
?.let { listOf(meshService.myPeerID, peerID) }
|
||||
if (route == null) {
|
||||
addCommandOutput("no known path to $targetName")
|
||||
return
|
||||
}
|
||||
val labels = route.mapIndexed { index, id ->
|
||||
when {
|
||||
index == 0 -> "you"
|
||||
id == peerID -> targetName
|
||||
else -> meshService.getPeerNicknames()[id] ?: id.take(8)
|
||||
}
|
||||
}
|
||||
val hopCount = route.size - 1
|
||||
val hops = if (hopCount == 1) "1 hop" else "$hopCount hops"
|
||||
addCommandOutput("estimated path: ${labels.joinToString(" → ")} ($hops)")
|
||||
}
|
||||
|
||||
private sealed interface CommandDestination {
|
||||
data object Main : CommandDestination
|
||||
data class Channel(val name: String) : CommandDestination
|
||||
data class Private(val peerID: String) : CommandDestination
|
||||
}
|
||||
|
||||
private fun currentCommandDestination(): CommandDestination =
|
||||
state.getSelectedPrivateChatPeerValue()?.let(CommandDestination::Private)
|
||||
?: state.getCurrentChannelValue()?.let(CommandDestination::Channel)
|
||||
?: CommandDestination.Main
|
||||
|
||||
private fun addCommandOutput(
|
||||
text: String,
|
||||
destination: CommandDestination = currentCommandDestination(),
|
||||
) {
|
||||
val message = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
)
|
||||
when (destination) {
|
||||
CommandDestination.Main -> messageManager.addMessage(message)
|
||||
is CommandDestination.Channel -> messageManager.addChannelMessage(destination.name, message)
|
||||
is CommandDestination.Private -> messageManager.addPrivateMessage(destination.peerID, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMeshContext(): Boolean =
|
||||
state.selectedLocationChannel.value.let { it == null || it is ChannelID.Mesh }
|
||||
|
||||
private fun handleUnknownCommand(cmd: String) {
|
||||
val systemMessage = BitchatMessage(
|
||||
@ -558,6 +661,7 @@ class CommandProcessor(
|
||||
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||
return (baseCommands + channelCommands).filterNot {
|
||||
(isPublicGeohash && it.command == "/pay") ||
|
||||
(!isMeshContext() && it.command in setOf("/ping", "/trace")) ||
|
||||
(it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location ||
|
||||
state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true))
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.ui.debug.MeshTopologySection
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeshTopologySheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
meshService: MeshService,
|
||||
) {
|
||||
if (!isPresented) return
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text("mesh topology", fontFamily = FontFamily.Monospace)
|
||||
MeshTopologySection(
|
||||
localPeerID = meshService.myPeerID,
|
||||
blePeerIDs = meshService.getDirectBlePeerIDs(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
|
||||
import com.bitchat.android.mesh.FragmentingPacketSender
|
||||
import com.bitchat.android.mesh.MeshCore
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.MeshPingResult
|
||||
import com.bitchat.android.mesh.MeshTransport
|
||||
import com.bitchat.android.mesh.PeerInfo
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
@ -1495,6 +1496,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendAnnouncementToPeer(peerID)
|
||||
}
|
||||
|
||||
override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
|
||||
meshCore.sendMeshPing(peerID, callback)
|
||||
}
|
||||
|
||||
/** @return Mapping of peer IDs to nicknames. */
|
||||
override fun getPeerNicknames(): Map<String, String> = meshCore.getPeerNicknames()
|
||||
|
||||
|
||||
@ -6,7 +6,9 @@ import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PeerInfo
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
@ -45,6 +47,7 @@ class CommandProcessorTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MeshGraphService.resetForTesting()
|
||||
commandProcessor = CommandProcessor(
|
||||
state = chatState,
|
||||
messageManager = messageManager,
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshPingManagerTest {
|
||||
private val localPeerID = "1111111111111111"
|
||||
private val remotePeerID = "2222222222222222"
|
||||
|
||||
@Test
|
||||
fun `pong received on another transport completes pending ping`() = runTest {
|
||||
var outbound: BitchatPacket? = null
|
||||
var callbackCount = 0
|
||||
var result: MeshPingResult? = null
|
||||
val originTransport = MeshPingManager(localPeerID, this) { outbound = it }
|
||||
val returnTransport = MeshPingManager(localPeerID, this) {}
|
||||
|
||||
originTransport.ping(remotePeerID) {
|
||||
callbackCount += 1
|
||||
result = it
|
||||
}
|
||||
|
||||
val ping = requireNotNull(outbound)
|
||||
returnTransport.handlePong(
|
||||
RoutedPacket(
|
||||
packet = ping.copy(
|
||||
type = MessageType.PONG.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(remotePeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(localPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
ttl = (MeshDiagnosticsConstants.TTL - 1u).toUByte(),
|
||||
),
|
||||
peerID = remotePeerID,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, callbackCount)
|
||||
assertNotNull(result)
|
||||
assertEquals(2, result?.hopCount)
|
||||
assertTrue(requireNotNull(result).rttMillis >= 0)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MeshDiagnosticsConstants
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@ -12,6 +13,7 @@ import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
@ -34,9 +36,13 @@ class PacketRelayManagerTest {
|
||||
whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0))
|
||||
}
|
||||
|
||||
private fun createPacket(route: List<ByteArray>?, recipient: String? = null): BitchatPacket {
|
||||
private fun createPacket(
|
||||
route: List<ByteArray>?,
|
||||
recipient: String? = null,
|
||||
type: MessageType = MessageType.MESSAGE,
|
||||
): BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
type = type.value,
|
||||
senderID = hexStringToPeerBytes(otherPeerID),
|
||||
recipientID = recipient?.let { hexStringToPeerBytes(it) },
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
@ -103,6 +109,57 @@ class PacketRelayManagerTest {
|
||||
verify(delegate).broadcastPacket(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diagnostic transit relay budget is enforced per ingress link`() = runTest {
|
||||
val attempts = MeshDiagnosticsConstants.INBOUND_RATE_LIMIT + 1
|
||||
|
||||
repeat(attempts) { index ->
|
||||
val packet = createPacket(
|
||||
route = null,
|
||||
recipient = finalRecipientID,
|
||||
type = MessageType.PING,
|
||||
).copy(payload = byteArrayOf(index.toByte()))
|
||||
packetRelayManager.handlePacketRelay(
|
||||
RoutedPacket(
|
||||
packet = packet,
|
||||
peerID = otherPeerID,
|
||||
ingressLinkID = "ingress-link",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
verify(delegate, times(MeshDiagnosticsConstants.INBOUND_RATE_LIMIT))
|
||||
.broadcastPacket(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diagnostic source route uses the same ingress relay budget`() = runTest {
|
||||
whenever(delegate.sendToPeer(any(), any())).thenReturn(true)
|
||||
val route = listOf(
|
||||
hexStringToPeerBytes(myPeerID),
|
||||
hexStringToPeerBytes(nextHopPeerID),
|
||||
)
|
||||
val attempts = MeshDiagnosticsConstants.INBOUND_RATE_LIMIT + 1
|
||||
|
||||
repeat(attempts) { index ->
|
||||
val packet = createPacket(
|
||||
route = route,
|
||||
recipient = finalRecipientID,
|
||||
type = MessageType.PONG,
|
||||
).copy(payload = byteArrayOf(index.toByte()))
|
||||
packetRelayManager.handlePacketRelay(
|
||||
RoutedPacket(
|
||||
packet = packet,
|
||||
peerID = otherPeerID,
|
||||
ingressLinkID = "source-route-ingress",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
verify(delegate, times(MeshDiagnosticsConstants.INBOUND_RATE_LIMIT))
|
||||
.sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any())
|
||||
}
|
||||
|
||||
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
var idx = 0
|
||||
|
||||
@ -104,7 +104,7 @@ class IdentityAnnouncementTest {
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x39, 0x01),
|
||||
byteArrayOf(0x05, 0x02, 0x79, 0x01),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MeshPingPayloadTest {
|
||||
private val nonce = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)
|
||||
|
||||
@Test
|
||||
fun `payload round trips with iOS wire layout`() {
|
||||
val encoded = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL).encode()
|
||||
|
||||
assertEquals(MeshDiagnosticsConstants.PAYLOAD_SIZE, encoded.size)
|
||||
assertArrayEquals(nonce, encoded.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE))
|
||||
assertEquals(MeshDiagnosticsConstants.TTL.toByte(), encoded.last())
|
||||
assertEquals(MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL), MeshPingPayload.decode(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoder rejects truncation and tolerates trailing bytes`() {
|
||||
assertNull(MeshPingPayload.decode(ByteArray(MeshDiagnosticsConstants.PAYLOAD_SIZE - 1)))
|
||||
val extended = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL).encode() + byteArrayOf(99)
|
||||
assertEquals(MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL), MeshPingPayload.decode(extended))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hop count uses origin and received ttl`() {
|
||||
val payload = MeshPingPayload(nonce, MeshDiagnosticsConstants.TTL)
|
||||
|
||||
assertEquals(1, payload.hopCount(MeshDiagnosticsConstants.TTL))
|
||||
assertEquals(3, payload.hopCount((MeshDiagnosticsConstants.TTL - 2u).toUByte()))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class MeshGraphRouteTest {
|
||||
private lateinit var graph: MeshGraphService
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MeshGraphService.resetForTesting()
|
||||
graph = MeshGraphService.getInstance()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
MeshGraphService.resetForTesting()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `route uses shortest confirmed path`() {
|
||||
graph.updateFromAnnouncement("a", "alice", listOf("b"), 1u)
|
||||
graph.updateFromAnnouncement("b", "bob", listOf("a", "c"), 2u)
|
||||
graph.updateFromAnnouncement("c", "carol", listOf("b"), 3u)
|
||||
|
||||
assertEquals(listOf("a", "b", "c"), graph.computeRoute("a", "c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `route ignores one-sided claims`() {
|
||||
graph.updateFromAnnouncement("a", "alice", listOf("b"), 1u)
|
||||
graph.updateFromAnnouncement("b", "bob", emptyList(), 2u)
|
||||
|
||||
assertNull(graph.computeRoute("a", "b"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user