mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-26 23:45:21 +00:00
updates
This commit is contained in:
@@ -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) {
|
||||||
|
scope.launch {
|
||||||
|
sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendVoiceNoteAsync(
|
||||||
|
toPeerIDOrNull: String?,
|
||||||
|
channelOrNull: String?,
|
||||||
|
filePath: String
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
val filePacket = withContext(mediaWorkDispatcher) {
|
||||||
val file = java.io.File(filePath)
|
val file = java.io.File(filePath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||||
return
|
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) {
|
if (file.length() > MAX_FILE_SIZE) {
|
||||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||||
return
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
val filePacket = BitchatFilePacket(
|
BitchatFilePacket(
|
||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
fileSize = file.length(),
|
fileSize = file.length(),
|
||||||
mimeType = "audio/mp4",
|
mimeType = "audio/mp4",
|
||||||
content = file.readBytes()
|
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) {
|
||||||
|
scope.launch {
|
||||||
|
sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendImageNoteAsync(
|
||||||
|
toPeerIDOrNull: String?,
|
||||||
|
channelOrNull: String?,
|
||||||
|
filePath: String
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
val filePacket = withContext(mediaWorkDispatcher) {
|
||||||
Log.d(TAG, "🔄 Starting image send: $filePath")
|
Log.d(TAG, "🔄 Starting image send: $filePath")
|
||||||
val file = java.io.File(filePath)
|
val file = java.io.File(filePath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||||
return
|
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) {
|
if (file.length() > MAX_FILE_SIZE) {
|
||||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||||
return
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
val filePacket = BitchatFilePacket(
|
BitchatFilePacket(
|
||||||
fileName = file.name,
|
fileName = file.name,
|
||||||
fileSize = file.length(),
|
fileSize = file.length(),
|
||||||
mimeType = "image/jpeg",
|
mimeType = "image/jpeg",
|
||||||
content = file.readBytes()
|
content = file.readBytes()
|
||||||
)
|
)
|
||||||
|
} ?: return
|
||||||
|
|
||||||
if (toPeerIDOrNull != null) {
|
if (toPeerIDOrNull != null) {
|
||||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
|
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
|
||||||
@@ -153,18 +181,29 @@ 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 {
|
||||||
|
val filePacket = withContext(mediaWorkDispatcher) {
|
||||||
Log.d(TAG, "🔄 Starting file send: $filePath")
|
Log.d(TAG, "🔄 Starting file send: $filePath")
|
||||||
val file = java.io.File(filePath)
|
val file = java.io.File(filePath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||||
return
|
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) {
|
if (file.length() > MAX_FILE_SIZE) {
|
||||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||||
return
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the real MIME type based on extension; fallback to octet-stream
|
// Use the real MIME type based on extension; fallback to octet-stream
|
||||||
@@ -179,23 +218,30 @@ class MediaSendingManager(
|
|||||||
val originalName = run {
|
val originalName = run {
|
||||||
val name = file.name
|
val name = file.name
|
||||||
val base = name.substringBeforeLast('.')
|
val base = name.substringBeforeLast('.')
|
||||||
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
|
val ext = name.substringAfterLast('.', "").let {
|
||||||
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
|
if (it.isNotBlank()) ".${it}" else ""
|
||||||
|
}
|
||||||
|
val stripped = Regex("^send_\\d+_(.+)$")
|
||||||
|
.matchEntire(base)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?: base
|
||||||
stripped + ext
|
stripped + ext
|
||||||
}
|
}
|
||||||
Log.d(TAG, "📝 Original filename: $originalName")
|
Log.d(TAG, "📝 Original filename: $originalName")
|
||||||
|
|
||||||
val filePacket = BitchatFilePacket(
|
BitchatFilePacket(
|
||||||
fileName = originalName,
|
fileName = originalName,
|
||||||
fileSize = file.length(),
|
fileSize = file.length(),
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
content = file.readBytes()
|
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 {
|
||||||
|
withContext(mediaWorkDispatcher) {
|
||||||
meshService.prepareFilePrivate(
|
meshService.prepareFilePrivate(
|
||||||
recipientPeerID = pending.peerID,
|
recipientPeerID = pending.peerID,
|
||||||
file = pending.filePacket,
|
file = pending.filePacket,
|
||||||
transferId = pending.transferId,
|
transferId = pending.transferId,
|
||||||
allowLegacyFallback = pending.allowLegacyFallback
|
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")
|
||||||
|
withContext(mediaWorkDispatcher) {
|
||||||
meshService.sendFileBroadcast(filePacket)
|
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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user