mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 2367745f6e2a6eaed17be8c9638f6d2bca1786c8 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
3e9fd741f2
@ -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)
|
||||
|
||||
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user