mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-26 19:05:26 +00:00
updates
This commit is contained in:
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user