new files

This commit is contained in:
CC
2026-06-09 00:02:37 +02:00
parent efe15967b8
commit d2d9ab6c69
5 changed files with 336 additions and 23 deletions
@@ -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<String, Job>()
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<BitchatPacket>? {
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)
}
}
@@ -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)
)
}
}
@@ -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<String> = 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
@@ -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<String>,
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<Float, Float> {
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<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>,
wifiAwarePeerIDs: Set<String> = emptySet(),
blePeerIDs: Set<String> = 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<EdgeMarker>()
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)
@@ -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()
}
}