Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import java.nio.ByteOrder
* BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh.
* TLVs:
* - 0x01: filename (UTF-8)
* - 0x02: file size (8 bytes, UInt64)
* - 0x02: file size (4 bytes, UInt32)
* - 0x03: mime type (UTF-8)
* - 0x04: content (bytes) — may appear multiple times for large files
* - 0x04: content (bytes)
*
* 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.
* Length fields are 2 bytes (UInt16, big-endian), except CONTENT, which uses 4 bytes so a
* payload can exceed 64 KiB without TLV-level chunking. This matches `docs/file_transfer.md`;
* the previous text here described neither what `encode` writes nor what `decode` reads.
*
* v2 writes exactly one CONTENT TLV. `decode` still accepts several and concatenates them in
* order as defensive tolerance for legacy senders, but the chunk count is the sender's to
* choose, so that reassembly must stay linear in the payload rather than in chunks x bytes.
*
* Unknown TLV types are SKIPPED, not rejected: the tag list above is a floor,
* not a ceiling, and a decoder that bails on the first tag it does not know
Expand Down Expand Up @@ -104,7 +109,9 @@ data class BitchatFilePacket(
var name: String? = null
var size: Long? = null
var mime: String? = null
var contentBytes: ByteArray? = null
val contentChunks = ArrayList<ByteArray>(1)
var contentLength = 0
var sawContent = false
var skippedUnknownTLVs = 0
while (off < data.size) {
// Every TLV needs at least a type and a 2-byte length.
Expand Down Expand Up @@ -150,10 +157,18 @@ data class BitchatFilePacket(
}
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)
// Expect a single CONTENT TLV, but tolerate a split one.
//
// `contentBytes + value` reallocates and copies the whole
// accumulator per chunk, so k chunks totalling N bytes cost
// O(k*N). The chunk count is the sender's to choose — a 6-byte
// TLV carries one content byte — so at the 1 MB BLE reassembly
// ceiling one packet buys ~10^10 byte copies on the mesh handler
// thread. Collect the chunks and join once instead.
sawContent = true
if (value.isNotEmpty()) {
contentChunks.add(value)
contentLength += value.size
}
}
}
Expand All @@ -162,7 +177,18 @@ data class BitchatFilePacket(
android.util.Log.d("BitchatFilePacket", "⏭️ Skipped $skippedUnknownTLVs unknown TLV(s)")
}
val n = name ?: return null
val c = contentBytes ?: return null
if (!sawContent) return null
val c = when (contentChunks.size) {
0 -> ByteArray(0)
1 -> contentChunks[0]
else -> ByteArray(contentLength).also { joined ->
var at = 0
for (chunk in contentChunks) {
System.arraycopy(chunk, 0, joined, at, chunk.size)
at += chunk.size
}
}
}
val s = size ?: c.size.toLong()
val m = mime ?: "application/octet-stream"
val result = BitchatFilePacket(n, s, m, c)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.bitchat.android.model

import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayOutputStream

/**
* A FILE_TRANSFER payload arrives from any verified peer, and the sender chooses how many CONTENT
* TLVs it is split into. Reassembly therefore has to be linear in the payload, not in the chunk
* count times the payload.
*/
class BitchatFilePacketContentTest {

private fun shortTlv(tag: Int, value: ByteArray): ByteArray =
byteArrayOf(
tag.toByte(),
((value.size ushr 8) and 0xFF).toByte(),
(value.size and 0xFF).toByte()
) + value

/** CONTENT carries a 4-byte length, unlike the other tags. */
private fun contentTlv(value: ByteArray): ByteArray =
byteArrayOf(
0x04,
((value.size ushr 24) and 0xFF).toByte(),
((value.size ushr 16) and 0xFF).toByte(),
((value.size ushr 8) and 0xFF).toByte(),
(value.size and 0xFF).toByte()
) + value

private fun payload(contentChunks: List<ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
out.write(shortTlv(0x01, "photo.jpg".toByteArray()))
out.write(shortTlv(0x03, "image/jpeg".toByteArray()))
contentChunks.forEach { out.write(contentTlv(it)) }
return out.toByteArray()
}

@Test
fun `a split content tlv is reassembled in order`() {
val chunks = listOf("abc".toByteArray(), "de".toByteArray(), "fgh".toByteArray())

val decoded = BitchatFilePacket.decode(payload(chunks))

assertNotNull(decoded)
assertArrayEquals("abcdefgh".toByteArray(), decoded!!.content)
assertEquals("photo.jpg", decoded.fileName)
assertEquals("image/jpeg", decoded.mimeType)
// No FILE_SIZE TLV, so the size falls back to the reassembled length.
assertEquals(8L, decoded.fileSize)
}

@Test
fun `a single content tlv round trips through encode`() {
val original = BitchatFilePacket(
fileName = "note.txt",
fileSize = 5L,
mimeType = "text/plain",
content = "hello".toByteArray()
)

val decoded = BitchatFilePacket.decode(original.encode()!!)

assertNotNull(decoded)
assertArrayEquals(original.content, decoded!!.content)
assertEquals(original.fileName, decoded.fileName)
assertEquals(original.fileSize, decoded.fileSize)
}

@Test
fun `an empty content tlv still decodes to an empty file`() {
val decoded = BitchatFilePacket.decode(payload(listOf(ByteArray(0))))

assertNotNull(decoded)
assertEquals(0, decoded!!.content.size)
}

@Test
fun `a payload with no content tlv is rejected`() {
assertNull(BitchatFilePacket.decode(payload(emptyList())))
}

@Test
fun `heavily split content reassembles correctly at scale`() {
// A CONTENT TLV costs 5 header bytes, so one content byte per chunk is the sender's
// cheapest way to maximise the chunk count. 200k chunks is ~1.2 MB on the wire, the
// scale BLE reassembly already lets through.
//
// No timing assertion here on purpose: a wall-clock threshold wide enough to stay stable
// on slow CI is too wide to fail on the quadratic path, so it would only add flake. The
// cost was measured directly instead and is recorded in the pull request.
val chunkCount = 200_000
val data = payload(List(chunkCount) { byteArrayOf(it.toByte()) })

val decoded = BitchatFilePacket.decode(data)

assertNotNull(decoded)
assertEquals(chunkCount, decoded!!.content.size)
assertTrue(
"every chunk must land at its own offset",
decoded.content.withIndex().all { (i, b) -> b == i.toByte() }
)
}
}