This commit is contained in:
callebtc
2026-07-26 13:02:35 +02:00
parent eb55cdb6a7
commit 58ff730c9e
8 changed files with 335 additions and 112 deletions
@@ -323,10 +323,10 @@ class BluetoothConnectionManager(
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(routed: RoutedPacket) {
if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket(
fun broadcastPacket(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastPacket(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
@@ -180,10 +180,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.sendPacketToPeer(peerID, packet)
}
private fun broadcastRoutedPacket(routed: RoutedPacket) {
if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(routed)
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false
val queued = connectionManager.broadcastPacket(routed)
if (!queued) return false
TransportBridgeService.broadcast("BLE", routed)
return true
}
private fun isBleTransportEnabled(): Boolean {
@@ -951,11 +953,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
)
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
if (!isBleTransportEnabled()) {
if (!isActive || terminated || !isBleTransportEnabled()) {
false
} else {
broadcastRoutedPacket(routed)
true
}
}
)
@@ -136,8 +136,8 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
) {
fragmentingSender.send(routed, "BLE broadcast") { packet ->
): Boolean {
return fragmentingSender.send(routed, "BLE broadcast") { packet ->
broadcastSinglePacket(packet, gattServer, characteristic)
true
}
@@ -89,6 +89,20 @@ internal class PrivateMediaTransferPreparer(
allowLegacyFallback: Boolean,
generationRetriesRemaining: Int
): PrivateMediaBuildOutcome {
val maxPrivateFragments =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
val absolutePayloadUpperBound =
maxPrivateFragments.toLong() *
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE.toLong()
// The file content alone cannot exceed the total bytes carried by every
// possible fragment. Reject before TLV encoding, encryption, signing,
// and packet serialization make additional full-size copies.
if (file.content.size.toLong() > absolutePayloadUpperBound) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
val policy = policyProvider(recipientPeerID)
val mode = when (policy) {
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
@@ -182,8 +196,6 @@ internal class PrivateMediaTransferPreparer(
)
}
val maxPrivateFragments =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
val fragments = fragment(finalized, maxPrivateFragments)
if (fragments.isEmpty()) {
return PrivateMediaBuildOutcome.Rejected(
@@ -9,12 +9,15 @@ import com.bitchat.android.mesh.PrivateMediaPreparation
import java.util.Date
import java.security.MessageDigest
import java.util.UUID
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LegacyPrivateMediaConsentRequest(
val requestId: String,
@@ -32,6 +35,7 @@ class MediaSendingManager(
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val scope: CoroutineScope,
private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
@@ -81,25 +85,37 @@ class MediaSendingManager(
* Send a voice note (audio file)
*/
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
scope.launch {
sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
private suspend fun sendVoiceNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
val filePacket = withContext(mediaWorkDispatcher) {
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
} ?: return
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
@@ -115,26 +131,38 @@ class MediaSendingManager(
* Send an image file
*/
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
Log.d(TAG, "🔄 Starting image send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
scope.launch {
sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "image/jpeg",
content = file.readBytes()
)
private suspend fun sendImageNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
val filePacket = withContext(mediaWorkDispatcher) {
Log.d(TAG, "🔄 Starting image send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "image/jpeg",
content = file.readBytes()
)
} ?: return
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
@@ -153,49 +181,67 @@ class MediaSendingManager(
* Send a generic file
*/
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
scope.launch {
sendFileNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
private suspend fun sendFileNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
Log.d(TAG, "🔄 Starting file send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
val filePacket = withContext(mediaWorkDispatcher) {
Log.d(TAG, "🔄 Starting file send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
// Use the real MIME type based on extension; fallback to octet-stream
val mimeType = try {
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
} catch (_: Exception) {
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
// Try to preserve the original file name if our copier prefixed it earlier
val originalName = run {
val name = file.name
val base = name.substringBeforeLast('.')
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
stripped + ext
}
Log.d(TAG, "📝 Original filename: $originalName")
// Use the real MIME type based on extension; fallback to octet-stream
val mimeType = try {
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
} catch (_: Exception) {
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
val filePacket = BitchatFilePacket(
fileName = originalName,
fileSize = file.length(),
mimeType = mimeType,
content = file.readBytes()
)
// Try to preserve the original file name if our copier prefixed it earlier
val originalName = run {
val name = file.name
val base = name.substringBeforeLast('.')
val ext = name.substringAfterLast('.', "").let {
if (it.isNotBlank()) ".${it}" else ""
}
val stripped = Regex("^send_\\d+_(.+)$")
.matchEntire(base)
?.groupValues
?.getOrNull(1)
?: base
stripped + ext
}
Log.d(TAG, "📝 Original filename: $originalName")
BitchatFilePacket(
fileName = originalName,
fileSize = file.length(),
mimeType = mimeType,
content = file.readBytes()
)
} ?: return
Log.d(TAG, "📦 Created file packet successfully")
val messageType = when {
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
else -> BitchatMessageType.File
}
@@ -215,21 +261,22 @@ class MediaSendingManager(
/**
* Send a file privately (encrypted)
*/
private fun sendPrivateFile(
private suspend fun sendPrivateFile(
toPeerID: String,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return
}
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
?: run {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return
}
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
sha256Hex(payload) to sha256Hex(filePacket.content)
}
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}")
@@ -258,6 +305,12 @@ class MediaSendingManager(
* this send to encrypted rather than forcing the legacy path.
*/
fun approveLegacyPrivateMedia(requestId: String) {
scope.launch {
approveLegacyPrivateMediaAsync(requestId)
}
}
private suspend fun approveLegacyPrivateMediaAsync(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
@@ -298,7 +351,7 @@ class MediaSendingManager(
scope.launch { retryPendingPrivateMediaOnScope(peerID) }
}
private fun retryPendingPrivateMediaOnScope(peerID: String) {
private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
@@ -306,7 +359,7 @@ class MediaSendingManager(
evaluateAutomaticPending(pending)
}
private fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
val acquired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) {
return@synchronized false
@@ -322,12 +375,14 @@ class MediaSendingManager(
while (true) {
val preparation = try {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
)
withContext(mediaWorkDispatcher) {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
)
}
} catch (error: Exception) {
PrivateMediaPreparation.Rejected(
error.message ?: "Secure private-media preparation failed"
@@ -565,21 +620,22 @@ class MediaSendingManager(
/**
* Send a file publicly (broadcast or channel)
*/
private fun sendPublicFile(
private suspend fun sendPublicFile(
channelOrNull: String?,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return
}
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
?: run {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return
}
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
sha256Hex(payload) to sha256Hex(filePacket.content)
}
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}")
@@ -612,7 +668,9 @@ class MediaSendingManager(
)
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket)
withContext(mediaWorkDispatcher) {
meshService.sendFileBroadcast(filePacket)
}
Log.d(TAG, "✅ File broadcast completed successfully")
}
@@ -0,0 +1,45 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class BluetoothConnectionManagerTest {
private lateinit var manager: BluetoothConnectionManager
@Before
fun setUp() {
manager = BluetoothConnectionManager(
RuntimeEnvironment.getApplication(),
"0011223344556677"
)
}
@After
fun tearDown() {
manager.stopServices()
}
@Test
fun `inactive manager rejects a broadcast instead of reporting it queued`() {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7),
recipientID = null,
timestamp = 1uL,
payload = byteArrayOf(1),
ttl = 7u
)
assertFalse(manager.broadcastPacket(RoutedPacket(packet)))
}
}
@@ -160,6 +160,51 @@ class PrivateMediaTransferPreparerTest {
assertTrue(!fragmented)
}
@Test
fun `impossible content size rejects before policy encryption signing or fragmentation`() {
var policyChecked = false
var encrypted = false
var finalized = false
var fragmented = false
val absolutePayloadUpperBound =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID *
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = {
policyChecked = true
PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
},
encrypt = { _, _, _ ->
encrypted = true
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { _, _ ->
fragmented = true
emptyList()
}
)
val outcome = preparer.prepare(
"peer",
recipientID,
file(absolutePayloadUpperBound + 1),
false
)
assertTrue(outcome is PrivateMediaBuildOutcome.Rejected)
assertTrue((outcome as PrivateMediaBuildOutcome.Rejected).reason.contains("256"))
assertTrue(!policyChecked)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null)
@@ -7,6 +7,7 @@ import com.bitchat.android.mesh.PrivateMediaWireMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -23,7 +24,11 @@ import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@RunWith(RobolectricTestRunner::class)
class MediaSendingManagerMigrationTest {
@@ -45,6 +50,7 @@ class MediaSendingManagerMigrationTest {
MessageManager(state),
mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mediaWorkDispatcher = Dispatchers.Unconfined,
getMeshService = { mesh }
)
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
@@ -72,6 +78,40 @@ class MediaSendingManagerMigrationTest {
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `private preparation runs on the configured media worker`() {
val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "private-media-test-worker")
}
val dispatcher = executor.asCoroutineDispatcher()
try {
val preparationThread = AtomicReference<String>()
val prepared = CountDownLatch(1)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer {
preparationThread.set(Thread.currentThread().name)
prepared.countDown()
PrivateMediaPreparation.Rejected("test complete")
}
val asynchronousManager = MediaSendingManager(
state,
MessageManager(state),
mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mediaWorkDispatcher = dispatcher,
getMeshService = { mesh }
)
asynchronousManager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(prepared.await(5, TimeUnit.SECONDS))
assertTrue(preparationThread.get().contains("private-media-test-worker"))
} finally {
dispatcher.close()
executor.shutdownNow()
}
}
@Test
fun `pinned downgrade rejection is visible without a file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
@@ -225,6 +265,28 @@ class MediaSendingManagerMigrationTest {
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
}
@Test
fun `failed prepared commit rolls back the local file echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
false
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("could not be committed"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
}
@Test
fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))