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 710d72b1f..eaa38e7f9 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 @@ -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(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 +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 } } } @@ -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) 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 000000000..bfdd67505 --- /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() } + ) + } +}