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