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
@@ -16,7 +16,8 @@ enum class MessageType(val value: UByte) {
NOISE_HANDSHAKE(0x10u), // Noise handshake
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets
REQUEST_SYNC(0x21u); // GCS-based sync request
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
companion object {
fun fromValue(value: UByte): MessageType? {
@@ -33,15 +34,15 @@ object SpecialRecipients {
}
/**
* Binary packet format - 100% compatible with iOS version
*
* Header (Fixed 13 bytes):
* Binary packet format - 100% backward compatible with iOS version
*
* Header (13 bytes for v1, 15 bytes for v2):
* - Version: 1 byte
* - Type: 1 byte
* - Type: 1 byte
* - TTL: 1 byte
* - Timestamp: 8 bytes (UInt64, big-endian)
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed)
* - PayloadLength: 2 bytes (UInt16, big-endian)
* - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian)
*
* Variable sections:
* - SenderID: 8 bytes (fixed)
@@ -166,19 +167,27 @@ data class BitchatPacket(
}
/**
* Binary Protocol implementation - exact same format as iOS version
* Binary Protocol implementation - supports v1 and v2, backward compatible
*/
object BinaryProtocol {
private const val HEADER_SIZE = 13
private const val HEADER_SIZE_V1 = 13
private const val HEADER_SIZE_V2 = 15
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
object Flags {
const val HAS_RECIPIENT: UByte = 0x01u
const val HAS_SIGNATURE: UByte = 0x02u
const val IS_COMPRESSED: UByte = 0x04u
}
private fun getHeaderSize(version: UByte): Int {
return when (version) {
1u.toUByte() -> HEADER_SIZE_V1
else -> HEADER_SIZE_V2 // v2+ will use 4-byte payload length
}
}
fun encode(packet: BitchatPacket): ByteArray? {
try {
@@ -195,7 +204,13 @@ object BinaryProtocol {
}
}
val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) }
// Compute a safe capacity for the unpadded frame
val headerSize = getHeaderSize(packet.version)
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
val payloadBytes = payload.size + if (isCompressed) 2 else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
buffer.put(packet.version.toByte())
@@ -218,9 +233,13 @@ object BinaryProtocol {
}
buffer.put(flags.toByte())
// Payload length (2 bytes, big-endian) - includes original size if compressed
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
buffer.putShort(payloadDataSize.toShort())
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
// SenderID (exactly 8 bytes)
val senderBytes = packet.senderID.take(SENDER_ID_SIZE).toByteArray()
@@ -284,34 +303,40 @@ object BinaryProtocol {
*/
private fun decodeCore(raw: ByteArray): BitchatPacket? {
try {
if (raw.size < HEADER_SIZE + SENDER_ID_SIZE) return null
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
val buffer = ByteBuffer.wrap(raw).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
val version = buffer.get().toUByte()
if (version != 1u.toUByte()) return null
if (version.toUInt() != 1u && version.toUInt() != 2u) return null // Support v1 and v2
val headerSize = getHeaderSize(version)
val type = buffer.get().toUByte()
val ttl = buffer.get().toUByte()
// Timestamp
val timestamp = buffer.getLong().toULong()
// Flags
val flags = buffer.get().toUByte()
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
// Payload length
val payloadLength = buffer.getShort().toUShort()
// Payload length - version-dependent (2 or 4 bytes)
val payloadLength = if (version >= 2u.toUByte()) {
buffer.getInt().toUInt() // 4 bytes for v2+
} else {
buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt
}
// Calculate expected total size
var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt()
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
if (hasSignature) expectedSize += SIGNATURE_SIZE
if (raw.size < expectedSize) return null
// SenderID