From 98a52269e3636284b1f1b34066122b427be090bc Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 08:43:12 +0530 Subject: [PATCH 1/4] Reassemble split file content in linear time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode tolerates a CONTENT TLV split across several TLVs by appending each chunk with `contentBytes + value`. That reallocates and copies the whole accumulator every time, so k chunks totalling N bytes cost O(k*N). The split is the sender's choice, and a CONTENT TLV costs only five header bytes, so one content byte per chunk maximises the chunk count. The decoder runs on the mesh handler for any FILE_TRANSFER from a verified peer — verification binds an identity, it does not authorise anything, and a peer mints one by signing its own announcement. Measured on a ~1.2 MB payload split into 200k single-byte chunks, which is the scale BLE reassembly already admits: 2435ms of copying, repeatable for the cost of sending the packet again. Collecting the chunks and joining once brings the same payload to 65ms. The surrounding decoder was already hardened against exactly this shape for unknown tags, which are skipped without copying and logged once rather than per TLV. This closes the same hole on the tag that carries the bytes. --- .../android/model/BitchatFilePacket.kt | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt index 710d72b1..0c01b05b 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt @@ -104,7 +104,9 @@ data class BitchatFilePacket( var name: String? = null var size: Long? = null var mime: String? = null - var contentBytes: ByteArray? = null + val contentChunks = ArrayList(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. @@ -150,10 +152,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 } } } @@ -162,7 +172,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) From 61fa6ee9cba6a89f554840e8f4101b52643e0c6e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 08:43:12 +0530 Subject: [PATCH 2/4] Cover split-content reassembly Pins ordering across chunks, the single-TLV round trip, an empty CONTENT TLV, rejection when CONTENT is absent, and correct reassembly at 200k chunks. Deliberately no wall-clock assertion: a threshold wide enough to be stable on slow CI is too wide to fail on the quadratic path, so it would only add flake. The 2435ms/65ms comparison was measured directly and is in the pull request instead. --- .../model/BitchatFilePacketContentTest.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/src/test/kotlin/com/bitchat/android/model/BitchatFilePacketContentTest.kt diff --git a/app/src/test/kotlin/com/bitchat/android/model/BitchatFilePacketContentTest.kt b/app/src/test/kotlin/com/bitchat/android/model/BitchatFilePacketContentTest.kt new file mode 100644 index 00000000..bfdd6750 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/BitchatFilePacketContentTest.kt @@ -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 { + 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() } + ) + } +} From fd55a7d8e8def690b79a8b90a548eac574941734 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 09:45:12 +0530 Subject: [PATCH 3/4] Correct the TLV header doc on the type it describes The class doc described neither what encode writes nor what decode reads: it claimed 2-byte lengths for every TLV and an 8-byte FILE_SIZE, where the code uses 4 bytes for both CONTENT and FILE_SIZE, and it presented multi-TLV chunking as the way large files are carried when v2 writes exactly one CONTENT TLV and fragments at the transport layer. docs/file_transfer.md already has this right. Bringing the doc on the type into line with it, and recording why the multi-TLV tolerance path has to stay linear, since that is what this branch changes. --- .../com/bitchat/android/model/BitchatFilePacket.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt index 0c01b05b..eaa38e7f 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt @@ -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