diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index d2ec0f2c..6c0e7c02 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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(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 */ diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index d4d8d859..b0165814 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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 = emptyList(), channel: String? = null) { if (content.isEmpty()) return val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt new file mode 100644 index 00000000..2773cb15 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPingManager.kt @@ -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() + } + + private data class Pending( + val peerID: String, + val startedNanos: Long, + val callback: (MeshPingResult?) -> Unit, + val timeout: Job, + ) + + private val inboundByLink = ConcurrentHashMap>() + + 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, + ) +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 2b138b06..c2593a00 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -39,6 +39,7 @@ interface MeshService { fun sendBroadcastAnnounce() fun sendAnnouncementToPeer(peerID: String) + fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) fun getPeerNicknames(): Map fun getPeerRSSI(): Map @@ -77,6 +78,7 @@ interface MeshService { fun getDeviceAddressForPeer(peerID: String): String? fun getDeviceAddressToPeerMapping(): Map + fun getDirectBlePeerIDs(): Set = emptySet() fun printDeviceAddressesForPeers(): String fun getDebugStatus(): String diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 5b2f9219..8d175a06 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index 6b5c6d16..4d87366b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -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>() /** * 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") diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 338f01f1..ea0a7c46 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 488fd5f2..7d8b6fd3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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 { val merged = linkedMapOf() try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { } @@ -429,6 +439,13 @@ class UnifiedMeshService( return merged } + override fun getDirectBlePeerIDs(): Set = + try { + bluetooth.getDeviceAddressToPeerMapping().values.toSet() + } catch (_: Exception) { + emptySet() + } + override fun printDeviceAddressesForPeers(): String { return buildString { appendLine(bluetooth.printDeviceAddressesForPeers()) diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index 9775596b..a14444bd 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 45612907..9e08b49c 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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? { diff --git a/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt b/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt new file mode 100644 index 00000000..62935dbd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/MeshDiagnosticsConstants.kt @@ -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 +} diff --git a/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt b/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt new file mode 100644 index 00000000..1378dbb7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/MeshPingPayload.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt index 785c249d..013660fd 100644 --- a/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt +++ b/app/src/main/java/com/bitchat/android/services/meshgraph/MeshGraphService.kt @@ -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? { + if (fromPeerID == toPeerID) return listOf(fromPeerID) + val adjacency = mutableMapOf>() + 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>() + 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() diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt index 179cd69b..f7cf1504 100644 --- a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f01c071b..c3a2fdff 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index b7abfaa6..4ab7aeb8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index f7c970bf..da35cef3 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -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"), "", "join or create a channel"), CommandSuggestion("/m", listOf("/msg"), " [message]", "send private message"), CommandSuggestion("/pay", emptyList(), " [public]", "send a Cashu ecash token"), + CommandSuggestion("/ping", emptyList(), "", "measure mesh round-trip time"), CommandSuggestion("/slap", emptyList(), "", "slap someone with a trout"), + CommandSuggestion("/trace", emptyList(), "", "estimate the mesh path"), CommandSuggestion("/unblock", emptyList(), "", "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, 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 ") + 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, 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 ") + 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)) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt new file mode 100644 index 00000000..afb10960 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshTopologySheet.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 69ff3835..2a8da6a3 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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 = meshCore.getPeerNicknames() diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 2e52b750..fd2a11ee 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt new file mode 100644 index 00000000..91a22b44 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MeshPingManagerTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt index ae896e53..c962542f 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketRelayManagerTest.kt @@ -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?, recipient: String? = null): BitchatPacket { + private fun createPacket( + route: List?, + 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 diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 911c4cba..85492d36 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -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!! diff --git a/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt b/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt new file mode 100644 index 00000000..f41fdb28 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/protocol/MeshPingPayloadTest.kt @@ -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())) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt new file mode 100644 index 00000000..451f7081 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/meshgraph/MeshGraphRouteTest.kt @@ -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")) + } +}