Media transfers (#440)

* tor voice wip

* worky BLE a bit

* can send sound

* remove tor

* ui cleanup

* recording time

* progress bar color

* nicknames for audio

* onboarding permissions no microphone

* may work

* fix destionation

* extend

* refactor voice input component

* fix keyboard collapse issue

* send images

* wip image open

* image sending works

* wip waveforms

* better

* better animation

* fix cursor for sending audio

* image sending animation

* image sending animation

* full screen image viewer

* gossip sync for fragments too

* reduce delays

* fix keyboard focus

* use v2 for file transfers

* do not sync fragments

* scrollable image viewer

* ui

* ui adjustments

* nicer animation

* seek through audio

* add spec

* add more details to documentation

* File sharing E2E:
- Add TLV BitchatFilePacket, FileSharingManager
- Implement sendFileNote in ChatViewModel
- File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer
- SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions
- Add FileViewerDialog with system open/save, FileProvider and file_paths
- Hook transfer progress to file sending UI
- Manifest: READ_MEDIA_* and FileProvider
- Fix MessageHandler saving and prefix for non-image payloads
- Add helper utils (FileUtils)

* kinda wip

* fix buttons

* files half working

* wip file transfer

* file packet has 2-byte TLV and chunks. it wokrs but it sucks

* clean

* remove gossip sync for fragments

* fix audio and image rendering

* adjust FILE_SIZE TLV size too

* cleanup

* haptic

* private messages media

* read receipts for media

* use enum for message type not string

* delivery ack checks dont push content

* check

* animation fix

* refactor

* ui adjustments

* comments

* refactor

* fix crash on send and receive of the same file

* refactor notifications

* tests
This commit is contained in:
callebtc
2025-09-19 22:46:14 +02:00
committed by GitHub
parent 1178fc254a
commit 633a506753
57 changed files with 4801 additions and 138 deletions
@@ -29,7 +29,7 @@ class BluetoothConnectionManager(
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
// Power management
private val powerManager = PowerManager(context)
private val powerManager = PowerManager(context.applicationContext)
// Coroutines
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -248,6 +248,10 @@ class BluetoothConnectionManager(
)
}
fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId)
}
/**
* Send a packet directly to a specific peer, without broadcasting to others.
*/
@@ -48,7 +48,7 @@ class BluetoothMeshService(private val context: Context) {
private val fragmentManager = FragmentManager()
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
@@ -452,6 +452,13 @@ class BluetoothMeshService(private val context: Context) {
}
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
// Track broadcast fragments for gossip sync
try {
val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
gossipSyncManager.onPublicPacketSeen(packet)
}
} catch (_: Exception) { }
return fragmentManager.handleFragment(packet)
}
@@ -609,6 +616,119 @@ class BluetoothMeshService(private val context: Context) {
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
}
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
val payload = file.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet in sendFileBroadcast")
return
}
Log.d(TAG, "📦 Encoded payload: ${payload.size} bytes")
serviceScope.launch {
val packet = BitchatPacket(
version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the file TLV payload for progress tracking
val transferId = sha256Hex(payload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFileBroadcast failed: ${e.message}", e)
Log.e(TAG, "❌ File: name=${file.fileName}, size=${file.fileSize}")
}
}
/**
* Send a file as an encrypted private message using Noise protocol
*/
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
serviceScope.launch {
// Check if we have an established Noise session
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
// Encode the file packet as TLV
val filePayload = file.encode()
if (filePayload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return@launch
}
Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes")
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
val noisePayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
data = filePayload
)
// Encrypt the payload using Noise
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
if (encrypted == null) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
return@launch
}
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = 7u
)
// Sign and send the encrypted packet
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
val transferId = sha256Hex(filePayload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e)
}
} else {
// No session - initiate handshake but don't queue file
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
}
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
}
}
fun cancelFileTransfer(transferId: String): Boolean {
return connectionManager.cancelTransfer(transferId)
}
// Local helper to hash payloads to a stable hex ID for progress mapping
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Send private message - SIMPLIFIED iOS-compatible version
@@ -18,6 +18,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.Job
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.channels.actor
/**
@@ -100,6 +102,7 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val transferJobs = ConcurrentHashMap<String, Job>()
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
@@ -122,24 +125,70 @@ class BluetoothPacketBroadcaster(
characteristic: BluetoothGattCharacteristic?
) {
val packet = routed.packet
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
// Check if we need to fragment
if (fragmentManager != null) {
val fragments = fragmentManager.createFragments(packet)
val fragments = try {
fragmentManager.createFragments(packet)
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
if (isFile) {
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
}
return
}
if (fragments.size > 1) {
if (isFile) {
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
}
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
connectionScope.launch {
if (transferId != null) {
TransferProgressManager.start(transferId, fragments.size)
}
val job = connectionScope.launch {
var sent = 0
fragments.forEach { fragment ->
broadcastSinglePacket(RoutedPacket(fragment), gattServer, characteristic)
// 20ms delay between fragments (matching iOS/Rust)
delay(200)
if (!isActive) return@launch
// If cancelled, stop sending remaining fragments
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
// 20ms delay between fragments
delay(20)
if (transferId != null) {
sent += 1
TransferProgressManager.progress(transferId, sent, fragments.size)
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
}
}
}
if (transferId != null) {
transferJobs[transferId] = job
job.invokeOnCompletion { transferJobs.remove(transferId) }
}
return
}
}
// Send single packet if no fragmentation needed
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
broadcastSinglePacket(routed, gattServer, characteristic)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()
return true
}
/**
@@ -154,6 +203,15 @@ class BluetoothPacketBroadcaster(
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
@@ -166,6 +224,10 @@ class BluetoothPacketBroadcaster(
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@@ -176,6 +238,10 @@ class BluetoothPacketBroadcaster(
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@@ -183,6 +249,12 @@ class BluetoothPacketBroadcaster(
return false
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Public entry point for broadcasting - submits request to actor for serialization
@@ -48,10 +48,23 @@ class FragmentManager {
* Matches iOS sendFragmentedPacket() implementation exactly
*/
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
val encoded = packet.toBinaryData() ?: return emptyList()
try {
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
val encoded = packet.toBinaryData()
if (encoded == null) {
Log.e(TAG, "❌ Failed to encode packet to binary data")
return emptyList()
}
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = MessagePadding.unpad(encoded)
val fullData = try {
MessagePadding.unpad(encoded)
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
return emptyList()
}
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
@@ -98,7 +111,13 @@ class FragmentManager {
fragments.add(fragmentPacket)
}
return fragments
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
return fragments
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
Log.e(TAG, "❌ Packet type: ${packet.type}, payload: ${packet.payload.size} bytes")
return emptyList()
}
}
/**
@@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
@@ -15,7 +16,7 @@ import kotlin.random.Random
* Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns
*/
class MessageHandler(private val myPeerID: String) {
class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) {
companion object {
private const val TAG = "MessageHandler"
@@ -110,6 +111,35 @@ class MessageHandler(private val myPeerID: String) {
}
}
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
// Handle encrypted file transfer; generate unique message ID
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
if (file != null) {
Log.d(TAG, "🔓 Decrypted encrypted file from $peerID: name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}'")
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = uniqueMsgId,
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = java.util.Date(packet.timestamp.toLong()),
isRelay = false,
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID
)
Log.d(TAG, "📄 Saved encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
delegate?.onMessageReceived(message)
// Send delivery ACK with generated message ID
sendDeliveryAck(uniqueMsgId, peerID)
} else {
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS
val messageID = String(noisePayload.data, Charsets.UTF_8)
@@ -338,16 +368,37 @@ class MessageHandler(private val myPeerID: String) {
}
try {
// Parse message
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
Log.d(TAG, "📥 FILE_TRANSFER decode success (broadcast): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
}
// Fallback: plain text
val message = BitchatMessage(
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
}
@@ -364,7 +415,32 @@ class MessageHandler(private val myPeerID: String) {
return
}
// Parse message
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
Log.d(TAG, "📥 FILE_TRANSFER decode success (private): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong()),
isPrivate = true,
recipientNickname = delegate?.getMyNickname()
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
}
// Fallback: plain text
val message = BitchatMessage(
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
@@ -377,6 +453,8 @@ class MessageHandler(private val myPeerID: String) {
Log.e(TAG, "Failed to process private message from $peerID: ${e.message}")
}
}
/**
* Handle leave message
@@ -144,6 +144,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@@ -153,6 +154,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
validPacket = false
Log.w(TAG, "Unknown message type: ${packet.type}")
@@ -165,7 +165,7 @@ class StoreForwardManager {
// Send with delays to avoid overwhelming the connection
messagesToSend.forEachIndexed { index, storedMessage ->
delay(index * 100L) // 100ms between messages
delay(index * 10L) // 10ms between messages
delegate?.sendPacket(storedMessage.packet)
}
@@ -0,0 +1,30 @@
package com.bitchat.android.mesh
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
data class TransferProgressEvent(
val transferId: String,
val sent: Int,
val total: Int,
val completed: Boolean
)
object TransferProgressManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _events = MutableSharedFlow<TransferProgressEvent>(replay = 0, extraBufferCapacity = 32)
val events: SharedFlow<TransferProgressEvent> = _events
fun start(id: String, total: Int) { emit(id, 0, total, false) }
fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) }
fun complete(id: String, total: Int) { emit(id, total, total, true) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) }
}
}