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 * Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits * Automatically fragments large packets to fit within BLE MTU limits
*/ */
fun broadcastPacket(routed: RoutedPacket) { fun broadcastPacket(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return if (!isActive || !isBleTransportEnabled()) return false
packetBroadcaster.broadcastPacket( return packetBroadcaster.broadcastPacket(
routed, routed,
serverManager.getGattServer(), serverManager.getGattServer(),
serverManager.getCharacteristic() serverManager.getCharacteristic()
@@ -180,10 +180,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.sendPacketToPeer(peerID, packet) connectionManager.sendPacketToPeer(peerID, packet)
} }
private fun broadcastRoutedPacket(routed: RoutedPacket) { private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return if (!isBleTransportEnabled()) return false
connectionManager.broadcastPacket(routed) val queued = connectionManager.broadcastPacket(routed)
if (!queued) return false
TransportBridgeService.broadcast("BLE", routed) TransportBridgeService.broadcast("BLE", routed)
return true
} }
private fun isBleTransportEnabled(): Boolean { private fun isBleTransportEnabled(): Boolean {
@@ -951,11 +953,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
) )
PrivateMediaPreparation.Ready( PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(transferId, built.wireMode) { PreparedPrivateMediaTransfer(transferId, built.wireMode) {
if (!isBleTransportEnabled()) { if (!isActive || terminated || !isBleTransportEnabled()) {
false false
} else { } else {
broadcastRoutedPacket(routed) broadcastRoutedPacket(routed)
true
} }
} }
) )
@@ -136,8 +136,8 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket, routed: RoutedPacket,
gattServer: BluetoothGattServer?, gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic? characteristic: BluetoothGattCharacteristic?
) { ): Boolean {
fragmentingSender.send(routed, "BLE broadcast") { packet -> return fragmentingSender.send(routed, "BLE broadcast") { packet ->
broadcastSinglePacket(packet, gattServer, characteristic) broadcastSinglePacket(packet, gattServer, characteristic)
true true
} }
@@ -89,6 +89,20 @@ internal class PrivateMediaTransferPreparer(
allowLegacyFallback: Boolean, allowLegacyFallback: Boolean,
generationRetriesRemaining: Int generationRetriesRemaining: Int
): PrivateMediaBuildOutcome { ): 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 policy = policyProvider(recipientPeerID)
val mode = when (policy) { val mode = when (policy) {
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 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) val fragments = fragment(finalized, maxPrivateFragments)
if (fragments.isEmpty()) { if (fragments.isEmpty()) {
return PrivateMediaBuildOutcome.Rejected( return PrivateMediaBuildOutcome.Rejected(
@@ -9,12 +9,15 @@ import com.bitchat.android.mesh.PrivateMediaPreparation
import java.util.Date import java.util.Date
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LegacyPrivateMediaConsentRequest( data class LegacyPrivateMediaConsentRequest(
val requestId: String, val requestId: String,
@@ -32,6 +35,7 @@ class MediaSendingManager(
private val messageManager: MessageManager, private val messageManager: MessageManager,
private val channelManager: ChannelManager, private val channelManager: ChannelManager,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val getMeshService: () -> MeshService private val getMeshService: () -> MeshService
) { ) {
// Helper to get current mesh service (may change after panic clear) // Helper to get current mesh service (may change after panic clear)
@@ -81,25 +85,37 @@ class MediaSendingManager(
* Send a voice note (audio file) * Send a voice note (audio file)
*/ */
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try { scope.launch {
val file = java.io.File(filePath) sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, 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 = BitchatFilePacket( private suspend fun sendVoiceNoteAsync(
fileName = file.name, toPeerIDOrNull: String?,
fileSize = file.length(), channelOrNull: String?,
mimeType = "audio/mp4", filePath: String
content = file.readBytes() ) {
) 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) { if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio) sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
@@ -115,26 +131,38 @@ class MediaSendingManager(
* Send an image file * Send an image file
*/ */
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try { scope.launch {
Log.d(TAG, "🔄 Starting image send: $filePath") sendImageNoteAsync(toPeerIDOrNull, channelOrNull, 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 = BitchatFilePacket( private suspend fun sendImageNoteAsync(
fileName = file.name, toPeerIDOrNull: String?,
fileSize = file.length(), channelOrNull: String?,
mimeType = "image/jpeg", filePath: String
content = file.readBytes() ) {
) 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) { if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image) sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
@@ -153,49 +181,67 @@ class MediaSendingManager(
* Send a generic file * Send a generic file
*/ */
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { 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 { try {
Log.d(TAG, "🔄 Starting file send: $filePath") val filePacket = withContext(mediaWorkDispatcher) {
val file = java.io.File(filePath) Log.d(TAG, "🔄 Starting file send: $filePath")
if (!file.exists()) { val file = java.io.File(filePath)
Log.e(TAG, "❌ File does not exist: $filePath") if (!file.exists()) {
return Log.e(TAG, "❌ File does not exist: $filePath")
} return@withContext null
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") }
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
}
// Use the real MIME type based on extension; fallback to octet-stream if (file.length() > MAX_FILE_SIZE) {
val mimeType = try { Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name) return@withContext null
} catch (_: Exception) { }
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
// Try to preserve the original file name if our copier prefixed it earlier // Use the real MIME type based on extension; fallback to octet-stream
val originalName = run { val mimeType = try {
val name = file.name com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
val base = name.substringBeforeLast('.') } catch (_: Exception) {
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" } "application/octet-stream"
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base }
stripped + ext Log.d(TAG, "🏷️ MIME type: $mimeType")
}
Log.d(TAG, "📝 Original filename: $originalName")
val filePacket = BitchatFilePacket( // Try to preserve the original file name if our copier prefixed it earlier
fileName = originalName, val originalName = run {
fileSize = file.length(), val name = file.name
mimeType = mimeType, val base = name.substringBeforeLast('.')
content = file.readBytes() 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") Log.d(TAG, "📦 Created file packet successfully")
val messageType = when { val messageType = when {
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
else -> BitchatMessageType.File else -> BitchatMessageType.File
} }
@@ -215,21 +261,22 @@ class MediaSendingManager(
/** /**
* Send a file privately (encrypted) * Send a file privately (encrypted)
*/ */
private fun sendPrivateFile( private suspend fun sendPrivateFile(
toPeerID: String, toPeerID: String,
filePacket: BitchatFilePacket, filePacket: BitchatFilePacket,
filePath: String, filePath: String,
messageType: BitchatMessageType messageType: BitchatMessageType
) { ) {
val payload = filePacket.encode() val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
if (payload == null) { ?: run {
Log.e(TAG, "❌ Failed to encode file packet for private send") Log.e(TAG, "❌ Failed to encode file packet for private send")
return return
} }
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes") Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
val transferId = sha256Hex(payload) val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
val contentHash = sha256Hex(filePacket.content) 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)}") 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. * this send to encrypted rather than forcing the legacy path.
*/ */
fun approveLegacyPrivateMedia(requestId: String) { fun approveLegacyPrivateMedia(requestId: String) {
scope.launch {
approveLegacyPrivateMediaAsync(requestId)
}
}
private suspend fun approveLegacyPrivateMediaAsync(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return val pending = consumePendingConsent(requestId) ?: return
val automatic = PendingAutomaticPrivateMedia( val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(), requestId = UUID.randomUUID().toString(),
@@ -298,7 +351,7 @@ class MediaSendingManager(
scope.launch { retryPendingPrivateMediaOnScope(peerID) } scope.launch { retryPendingPrivateMediaOnScope(peerID) }
} }
private fun retryPendingPrivateMediaOnScope(peerID: String) { private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) { val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID } ?.takeIf { it.peerID == peerID }
@@ -306,7 +359,7 @@ class MediaSendingManager(
evaluateAutomaticPending(pending) evaluateAutomaticPending(pending)
} }
private fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) { private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
val acquired = synchronized(pendingConsentLock) { val acquired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) { if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) {
return@synchronized false return@synchronized false
@@ -322,12 +375,14 @@ class MediaSendingManager(
while (true) { while (true) {
val preparation = try { val preparation = try {
meshService.prepareFilePrivate( withContext(mediaWorkDispatcher) {
recipientPeerID = pending.peerID, meshService.prepareFilePrivate(
file = pending.filePacket, recipientPeerID = pending.peerID,
transferId = pending.transferId, file = pending.filePacket,
allowLegacyFallback = pending.allowLegacyFallback transferId = pending.transferId,
) allowLegacyFallback = pending.allowLegacyFallback
)
}
} catch (error: Exception) { } catch (error: Exception) {
PrivateMediaPreparation.Rejected( PrivateMediaPreparation.Rejected(
error.message ?: "Secure private-media preparation failed" error.message ?: "Secure private-media preparation failed"
@@ -565,21 +620,22 @@ class MediaSendingManager(
/** /**
* Send a file publicly (broadcast or channel) * Send a file publicly (broadcast or channel)
*/ */
private fun sendPublicFile( private suspend fun sendPublicFile(
channelOrNull: String?, channelOrNull: String?,
filePacket: BitchatFilePacket, filePacket: BitchatFilePacket,
filePath: String, filePath: String,
messageType: BitchatMessageType messageType: BitchatMessageType
) { ) {
val payload = filePacket.encode() val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
if (payload == null) { ?: run {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send") Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return return
} }
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes") Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
val transferId = sha256Hex(payload) val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
val contentHash = sha256Hex(filePacket.content) 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)}") 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") Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket) withContext(mediaWorkDispatcher) {
meshService.sendFileBroadcast(filePacket)
}
Log.d(TAG, "✅ File broadcast completed successfully") 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) 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 @Test
fun `no route accepts 256 final fragments and rejects 257`() { fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null) assertExactBoundary(route = null)
@@ -7,6 +7,7 @@ import com.bitchat.android.mesh.PrivateMediaWireMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -23,7 +24,11 @@ import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.io.File 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.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class MediaSendingManagerMigrationTest { class MediaSendingManagerMigrationTest {
@@ -45,6 +50,7 @@ class MediaSendingManagerMigrationTest {
MessageManager(state), MessageManager(state),
mock(), mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mediaWorkDispatcher = Dispatchers.Unconfined,
getMeshService = { mesh } getMeshService = { mesh }
) )
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply { file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
@@ -72,6 +78,40 @@ class MediaSendingManagerMigrationTest {
verify(mesh, never()).sendFilePrivate(any(), any()) 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 @Test
fun `pinned downgrade rejection is visible without a file echo or send`() { fun `pinned downgrade rejection is visible without a file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
@@ -225,6 +265,28 @@ class MediaSendingManagerMigrationTest {
assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) 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 @Test
fun `cancelled consent cannot later send or echo`() { fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))