Merge 2367745f6e2a6eaed17be8c9638f6d2bca1786c8 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit 3e9fd741f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 144 additions and 10 deletions

View File

@ -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<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.
@ -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)

View File

@ -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() }
)
}
}