From 58ff730c9ee06cb8643e8ac5f36520438c8ef88c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:02:35 +0200 Subject: [PATCH] updates --- .../mesh/BluetoothConnectionManager.kt | 8 +- .../android/mesh/BluetoothMeshService.kt | 11 +- .../mesh/BluetoothPacketBroadcaster.kt | 4 +- .../android/mesh/PrivateMediaTransfer.kt | 16 +- .../bitchat/android/ui/MediaSendingManager.kt | 256 +++++++++++------- .../mesh/BluetoothConnectionManagerTest.kt | 45 +++ .../mesh/PrivateMediaTransferPreparerTest.kt | 45 +++ .../ui/MediaSendingManagerMigrationTest.kt | 62 +++++ 8 files changed, 335 insertions(+), 112 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 328d361f..9bf41c68 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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() 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 10a30957..08ca4390 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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 } } ) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 3c49315e..7aa987ab 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt index 82394711..65f21acb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index b38b8a53..d6c9d7e2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -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") } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt new file mode 100644 index 00000000..3c6d5d13 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt @@ -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))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt index 9d430f97..b84d641b 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 88fdbae3..ebe5f744 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -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() + 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)))