Add mesh ping and route diagnostics

This commit is contained in:
callebtc 2026-09-07 23:54:21 +03:00
parent 2243b4f416
commit 71092a5e17
25 changed files with 641 additions and 9 deletions

View File

@ -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
*/

View File

@ -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()

View 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,
)
}

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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
}

View File

@ -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())

View File

@ -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

View File

@ -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? {

View File

@ -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
}

View File

@ -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(),
)
}
}
}

View File

@ -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>()

View File

@ -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

View File

@ -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,

View File

@ -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) {

View File

@ -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))
}

View File

@ -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(),
)
}
}
}

View File

@ -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()

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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

View File

@ -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!!

View File

@ -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()))
}
}

View File

@ -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"))
}
}