mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 11: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:
@@ -0,0 +1,141 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh.
|
||||
* TLVs:
|
||||
* - 0x01: filename (UTF-8)
|
||||
* - 0x02: file size (8 bytes, UInt64)
|
||||
* - 0x03: mime type (UTF-8)
|
||||
* - 0x04: content (bytes) — may appear multiple times for large files
|
||||
*
|
||||
* Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs.
|
||||
* For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each.
|
||||
*
|
||||
* Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this
|
||||
* TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes.
|
||||
* Transport-level fragmentation then splits the final packet for BLE MTU.
|
||||
*/
|
||||
data class BitchatFilePacket(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val mimeType: String,
|
||||
val content: ByteArray
|
||||
) {
|
||||
private enum class TLVType(val v: UByte) {
|
||||
FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u);
|
||||
companion object { fun from(value: UByte) = values().find { it.v == value } }
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
try {
|
||||
android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType")
|
||||
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
|
||||
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
|
||||
// Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked.
|
||||
if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)")
|
||||
return null
|
||||
}
|
||||
if (content.size > 0xFFFF) {
|
||||
android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs")
|
||||
} else {
|
||||
android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}")
|
||||
}
|
||||
val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes)
|
||||
val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested
|
||||
|
||||
// Compute capacity: header TLVs + single CONTENT TLV with 4-byte length
|
||||
val contentTLVBytes = 1 + contentLenFieldLen + content.size
|
||||
val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes
|
||||
val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
// FILE_NAME
|
||||
buf.put(TLVType.FILE_NAME.v.toByte())
|
||||
buf.putShort(nameBytes.size.toShort())
|
||||
buf.put(nameBytes)
|
||||
|
||||
// FILE_SIZE (4 bytes)
|
||||
buf.put(TLVType.FILE_SIZE.v.toByte())
|
||||
buf.putShort(sizeFieldLen.toShort())
|
||||
buf.putInt(fileSize.toInt())
|
||||
|
||||
// MIME_TYPE
|
||||
buf.put(TLVType.MIME_TYPE.v.toByte())
|
||||
buf.putShort(mimeBytes.size.toShort())
|
||||
buf.put(mimeBytes)
|
||||
|
||||
// CONTENT (single TLV with 4-byte length)
|
||||
buf.put(TLVType.CONTENT.v.toByte())
|
||||
buf.putInt(content.size)
|
||||
buf.put(content)
|
||||
|
||||
val result = buf.array()
|
||||
android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(data: ByteArray): BitchatFilePacket? {
|
||||
android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes")
|
||||
try {
|
||||
var off = 0
|
||||
var name: String? = null
|
||||
var size: Long? = null
|
||||
var mime: String? = null
|
||||
var contentBytes: ByteArray? = null
|
||||
while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length)
|
||||
val t = TLVType.from(data[off].toUByte()) ?: return null
|
||||
off += 1
|
||||
// CONTENT uses 4-byte length; others use 2-byte length
|
||||
val len: Int
|
||||
if (t == TLVType.CONTENT) {
|
||||
if (off + 4 > data.size) return null
|
||||
len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF)
|
||||
off += 4
|
||||
} else {
|
||||
if (off + 2 > data.size) return null
|
||||
len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF)
|
||||
off += 2
|
||||
}
|
||||
if (len < 0 || off + len > data.size) return null
|
||||
val value = data.copyOfRange(off, off + len)
|
||||
off += len
|
||||
when (t) {
|
||||
TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8)
|
||||
TLVType.FILE_SIZE -> {
|
||||
if (len != 4) return null
|
||||
val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
|
||||
size = bb.int.toLong()
|
||||
}
|
||||
TLVType.MIME_TYPE -> mime = String(value, Charsets.UTF_8)
|
||||
TLVType.CONTENT -> {
|
||||
// Expect a single CONTENT TLV
|
||||
if (contentBytes == null) contentBytes = value else {
|
||||
// If multiple CONTENT TLVs appear, concatenate for tolerance
|
||||
contentBytes = (contentBytes!! + value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val n = name ?: return null
|
||||
val c = contentBytes ?: return null
|
||||
val s = size ?: c.size.toLong()
|
||||
val m = mime ?: "application/octet-stream"
|
||||
val result = BitchatFilePacket(n, s, m, c)
|
||||
android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
enum class BitchatMessageType : Parcelable {
|
||||
Message,
|
||||
Audio,
|
||||
Image,
|
||||
File
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery status for messages - exact same as iOS version
|
||||
*/
|
||||
@@ -49,6 +57,7 @@ data class BitchatMessage(
|
||||
val id: String = UUID.randomUUID().toString().uppercase(),
|
||||
val sender: String,
|
||||
val content: String,
|
||||
val type: BitchatMessageType = BitchatMessageType.Message,
|
||||
val timestamp: Date,
|
||||
val isRelay: Boolean = false,
|
||||
val originalSender: String? = null,
|
||||
@@ -279,6 +288,7 @@ data class BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = content,
|
||||
type = BitchatMessageType.Message,
|
||||
timestamp = timestamp,
|
||||
isRelay = isRelay,
|
||||
originalSender = originalSender,
|
||||
@@ -306,6 +316,7 @@ data class BitchatMessage(
|
||||
if (id != other.id) return false
|
||||
if (sender != other.sender) return false
|
||||
if (content != other.content) return false
|
||||
if (type != other.type) return false
|
||||
if (timestamp != other.timestamp) return false
|
||||
if (isRelay != other.isRelay) return false
|
||||
if (originalSender != other.originalSender) return false
|
||||
@@ -328,6 +339,7 @@ data class BitchatMessage(
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + sender.hashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + type.hashCode()
|
||||
result = 31 * result + timestamp.hashCode()
|
||||
result = 31 * result + isRelay.hashCode()
|
||||
result = 31 * result + (originalSender?.hashCode() ?: 0)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Business logic for file sharing operations
|
||||
*/
|
||||
object FileSharingManager {
|
||||
|
||||
private const val TAG = "FileSharingManager"
|
||||
|
||||
/**
|
||||
* Create a file packet from URI for sending
|
||||
*/
|
||||
fun createFilePacketFromUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
originalName: String? = null
|
||||
): BitchatFilePacket? {
|
||||
return try {
|
||||
// Get file name from URI or use original name
|
||||
val fileName = originalName ?: getFileNameFromUri(context, uri) ?: "unknown_file"
|
||||
|
||||
// Copy file to our temp storage for sending
|
||||
val localPath = FileUtils.copyFileForSending(context, uri) ?: return null
|
||||
|
||||
// Determine MIME type
|
||||
val mimeType = FileUtils.getMimeTypeFromExtension(fileName)
|
||||
|
||||
// Read file content
|
||||
val file = File(localPath)
|
||||
val content = file.readBytes()
|
||||
val fileSize = file.length()
|
||||
|
||||
// Clean up temp file
|
||||
file.delete()
|
||||
|
||||
val packet = BitchatFilePacket(
|
||||
fileName = fileName,
|
||||
fileSize = fileSize,
|
||||
mimeType = mimeType,
|
||||
content = content
|
||||
)
|
||||
|
||||
Log.d(TAG, "Created file packet: name=$fileName, size=${FileUtils.formatFileSize(fileSize)}, mime=$mimeType")
|
||||
packet
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create file packet from URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URI
|
||||
*/
|
||||
private fun getFileNameFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
} ?: uri.lastPathSegment
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get filename from URI", e)
|
||||
uri.lastPathSegment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a received file packet and return file info
|
||||
*/
|
||||
data class ReceivedFileInfo(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val mimeType: String,
|
||||
val content: ByteArray
|
||||
)
|
||||
|
||||
fun processReceivedFile(packet: BitchatFilePacket): ReceivedFileInfo {
|
||||
return ReceivedFileInfo(
|
||||
fileName = packet.fileName,
|
||||
fileSize = packet.fileSize,
|
||||
mimeType = packet.mimeType,
|
||||
content = packet.content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import kotlinx.parcelize.Parcelize
|
||||
enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u); // Message was delivered
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): NoisePayloadType? {
|
||||
|
||||
@@ -9,5 +9,6 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
data class RoutedPacket(
|
||||
val packet: BitchatPacket,
|
||||
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
|
||||
val relayAddress: String? = null // Address it came from (for avoiding loopback)
|
||||
)
|
||||
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
|
||||
val transferId: String? = null // Optional stable transfer ID for progress tracking
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user