mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:25:19 +00:00
new files
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user