From d2d9ab6c69c937b8231e987261725f8fcb46a0de Mon Sep 17 00:00:00 2001 From: CC Date: Tue, 9 Jun 2026 00:02:37 +0200 Subject: [PATCH] new files --- .../android/mesh/FragmentingPacketSender.kt | 138 ++++++++++++++++++ .../bitchat/android/ui/MeshPeerListSheet.kt | 4 +- .../android/ui/debug/DebugSettingsSheet.kt | 14 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 86 ++++++++--- .../android/mesh/MessageHandlerTest.kt | 117 +++++++++++++++ 5 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt new file mode 100644 index 00000000..115254dc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -0,0 +1,138 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared transport send wrapper that applies bitchat packet fragmentation and + * transfer progress before a transport writes packets to its concrete medium. + */ +class FragmentingPacketSender( + private val scope: CoroutineScope, + private val fragmentManager: FragmentManager?, + private val logTag: String, + private val interFragmentDelayMs: Long = 20L +) { + private val transferJobs = ConcurrentHashMap() + + fun send( + routed: RoutedPacket, + description: String, + sendSingle: (RoutedPacket) -> Boolean + ): Boolean { + val transferId = transferIdFor(routed) + val packets = packetsForTransport(routed.packet) ?: return false + val total = packets.size + + if (total <= 1) { + if (transferId != null) { + TransferProgressManager.start(transferId, 1) + } + val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId)) + if (sent && transferId != null) { + TransferProgressManager.progress(transferId, 1, 1) + TransferProgressManager.complete(transferId, 1) + } + return sent + } + + Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description") + if (transferId != null) { + TransferProgressManager.start(transferId, total) + } + + val job = scope.launch(start = CoroutineStart.LAZY) { + var sent = 0 + for (packet in packets) { + if (!isActive) return@launch + if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch + + val fragment = routed.copy(packet = packet, transferId = transferId) + val delivered = try { + sendSingle(fragment) + } catch (e: Exception) { + Log.e(logTag, "Fragment send failed for $description: ${e.message}", e) + false + } + + if (!delivered) { + Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments") + return@launch + } + + sent += 1 + if (transferId != null) { + TransferProgressManager.progress(transferId, sent, total) + } + if (sent < total) { + delay(interFragmentDelayMs) + } + } + + if (transferId != null) { + TransferProgressManager.complete(transferId, total) + } + } + + if (transferId != null) { + transferJobs[transferId] = job + job.invokeOnCompletion { transferJobs.remove(transferId, job) } + } + job.start() + return true + } + + fun cancelTransfer(transferId: String): Boolean { + val job = transferJobs.remove(transferId) ?: return false + job.cancel() + return true + } + + private fun packetsForTransport(packet: BitchatPacket): List? { + if (packet.type == MessageType.FRAGMENT.value) { + return listOf(packet) + } + + val manager = fragmentManager ?: return listOf(packet) + return try { + val fragments = manager.createFragments(packet) + if (fragments.isEmpty()) { + Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}") + null + } else { + fragments + } + } catch (e: Exception) { + Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e) + null + } + } + + private fun transferIdFor(routed: RoutedPacket): String? { + routed.transferId?.let { return it } + val packet = routed.packet + return if (packet.type == MessageType.FILE_TRANSFER.value) { + sha256Hex(packet.payload) + } else { + null + } + } + + private fun sha256Hex(bytes: ByteArray): String = try { + val md = MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index a8e1a5cf..aee2ff22 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -636,7 +636,7 @@ private fun PeerItem( else -> "Routed" }, modifier = Modifier.size(16.dp), - tint = if (isWifiAware) Color(0xFF9C27B0) else colorScheme.onSurface.copy(alpha = 0.6f) + tint = colorScheme.onSurface.copy(alpha = 0.6f) ) } @@ -673,7 +673,7 @@ private fun PeerItem( imageVector = Icons.Filled.Wifi, contentDescription = "Direct Wi-Fi Aware", modifier = Modifier.size(13.dp), - tint = Color(0xFF9C27B0).copy(alpha = 0.8f) + tint = colorScheme.onSurface.copy(alpha = 0.8f) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 967830f4..4d1d6a42 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -42,7 +42,10 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle @Composable -fun MeshTopologySection(localPeerID: String? = null) { +fun MeshTopologySection( + localPeerID: String? = null, + blePeerIDs: Set = emptySet(), +) { val colorScheme = MaterialTheme.colorScheme val graphService = remember { MeshGraphService.getInstance() } val snapshot by graphService.graphState.collectAsState() @@ -65,6 +68,7 @@ fun MeshTopologySection(localPeerID: String? = null) { nodes = nodes, edges = edges, wifiAwarePeerIDs = wifiAwarePeerIDs, + blePeerIDs = blePeerIDs, localPeerID = localPeerID, modifier = Modifier .fillMaxWidth() @@ -225,7 +229,13 @@ fun DebugSettingsSheet( // Mesh topology visualization (moved below verbose logging) item { - MeshTopologySection(localPeerID = meshService.myPeerID) + val blePeerIDs = remember(connectedDevices) { + connectedDevices.mapNotNull { it.peerID }.toSet() + } + MeshTopologySection( + localPeerID = meshService.myPeerID, + blePeerIDs = blePeerIDs, + ) } // GATT controls diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 3a4d6754..44c7ef55 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -37,6 +38,44 @@ private const val DAMPING = 0.85f private const val MAX_VELOCITY = 30f private const val PULSE_DECAY = 0.05f private const val ROUTE_DECAY = 0.02f +private val EDGE_ICON_OFFSET = 14.dp + +private enum class EdgeTransport { WIFI, BLE } + +private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport) + +private fun shouldMarkDirectEdge( + edge: MeshGraphService.GraphEdge, + transportPeerIDs: Set, + localID: String? +): Boolean { + if (transportPeerIDs.isEmpty()) return false + return if (localID != null) { + (edge.a == localID && edge.b in transportPeerIDs) || + (edge.b == localID && edge.a in transportPeerIDs) + } else { + edge.a in transportPeerIDs || edge.b in transportPeerIDs + } +} + +private fun edgeMarkerPosition( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + offsetPx: Float, + sideSign: Float +): Pair { + val midX = (x1 + x2) / 2f + val midY = (y1 + y2) / 2f + val dx = x2 - x1 + val dy = y2 - y1 + val len = sqrt(dx * dx + dy * dy) + if (len < 0.1f) return midX to midY + val px = -dy / len * offsetPx * sideSign + val py = dx / len * offsetPx * sideSign + return (midX + px) to (midY + py) +} private class GraphNodeState( val id: String, @@ -219,6 +258,7 @@ fun ForceDirectedMeshGraph( nodes: List, edges: List, wifiAwarePeerIDs: Set = emptySet(), + blePeerIDs: Set = emptySet(), localPeerID: String? = null, modifier: Modifier = Modifier ) { @@ -417,38 +457,46 @@ fun ForceDirectedMeshGraph( val iconSize = 16.dp val iconSizePx = with(density) { iconSize.toPx() } val halfIconSizePx = iconSizePx / 2f + val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() } val localID = localPeerID - val shouldMarkWifiEdge: (MeshGraphService.GraphEdge) -> Boolean = { edge -> - if (localID != null) { - (edge.a == localID && edge.b in wifiAwarePeerIDs) || - (edge.b == localID && edge.a in wifiAwarePeerIDs) - } else { - edge.a in wifiAwarePeerIDs || edge.b in wifiAwarePeerIDs - } - } - val wifiEdgeMidpoints = tick.let { - simulation.edges.mapNotNull { edge -> + val edgeMarkers = tick.let { + simulation.edges.flatMap { edge -> val n1 = simulation.nodes[edge.a] val n2 = simulation.nodes[edge.b] - if (n1 != null && n2 != null && shouldMarkWifiEdge(edge)) { - ((n1.x + n2.x) / 2f) to ((n1.y + n2.y) / 2f) - } else { - null + if (n1 == null || n2 == null) return@flatMap emptyList() + + val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID) + val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID) + if (!isWifi && !isBle) return@flatMap emptyList() + + val markers = mutableListOf() + if (isWifi) { + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f) + markers.add(EdgeMarker(x, y, EdgeTransport.WIFI)) } + if (isBle) { + val side = if (isWifi) -1f else 1f + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side) + markers.add(EdgeMarker(x, y, EdgeTransport.BLE)) + } + markers } } - wifiEdgeMidpoints.forEach { (midX, midY) -> + edgeMarkers.forEach { marker -> Icon( - imageVector = Icons.Filled.Wifi, + imageVector = when (marker.transport) { + EdgeTransport.WIFI -> Icons.Filled.Wifi + EdgeTransport.BLE -> Icons.Outlined.Bluetooth + }, contentDescription = null, - tint = Color(0xFF9C27B0).copy(alpha = 0.82f), + tint = colorScheme.onSurface.copy(alpha = 0.82f), modifier = Modifier .offset { IntOffset( - x = (midX - halfIconSizePx).roundToInt(), - y = (midY - halfIconSizePx).roundToInt() + x = (marker.x - halfIconSizePx).roundToInt(), + y = (marker.y - halfIconSizePx).roundToInt() ) } .size(iconSize) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt new file mode 100644 index 00000000..2c093353 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.services.meshgraph.MeshGraphService +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageHandlerTest { + private lateinit var handler: MessageHandler + private lateinit var delegate: MessageHandlerDelegate + + private val myPeerID = "1111222233334444" + private val peerID = "aaaabbbbccccdddd" + private val nickname = "peer" + private val noiseKey = ByteArray(32) { 0x0B } + private val signingKey = ByteArray(32) { 0x0A } + private val signature = ByteArray(64) { 1 } + private val announceClockSkewToleranceMs = 10 * 60 * 1000L + + @Before + fun setup() { + MeshGraphService.resetForTesting() + handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication()) + delegate = mock() + handler.delegate = delegate + + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true) + whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true) + } + + @After + fun tearDown() { + MeshGraphService.resetForTesting() + } + + @Test + fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking { + val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull()) + } + + @Test + fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Future announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + Unit + } + + @Test + fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + + assertFalse("Announce older than clock skew tolerance should not store peer identity", result) + verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any()) + verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any()) + } + + private fun announcePacket( + ageMs: Long, + ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte() + ): BitchatPacket { + val announcement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noiseKey, + signingPublicKey = signingKey + ) + return BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = peerID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - ageMs).toULong(), + payload = announcement.encode()!!, + signature = signature, + ttl = ttl + ) + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +}