mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 05:05:20 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user