fix: align NIP-BE chunk format with KoalaSat/samiz reference implementation

Reviewed the samiz BLE implementation and fixed compatibility issues:

- Chunk index: changed from 2 bytes to 1 byte (matching samiz/NIP-BE example)
- Chunk overhead: 2 bytes total (1 index + 1 total count), not 3
- chunkSize parameter now means payload size (500), not total chunk size
- Android: TX power HIGH, advertise timeout indefinite (matching samiz)
- Android: request MTU 512 and CONNECTION_PRIORITY_HIGH on connect
- Android: discover services after MTU negotiation (samiz flow)
- Android: set WRITE_TYPE_DEFAULT on write characteristic
- MTU-to-chunkSize: properly accounts for ATT overhead (3) + chunk overhead (2)

These changes ensure wire-level compatibility with samiz devices.

https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT
This commit is contained in:
Claude
2026-03-30 02:12:52 +00:00
parent fb78c1a4c4
commit 57aec95e8d
5 changed files with 44 additions and 27 deletions
@@ -103,7 +103,8 @@ class AndroidBleTransport(
.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setTimeout(0)
.build()
val data =
@@ -158,6 +159,7 @@ class AndroidBleTransport(
BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_WRITE,
)
writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
readCharacteristic =
BluetoothGattCharacteristic(
@@ -364,7 +366,8 @@ class AndroidBleTransport(
val peer = peerMap[peerUuid] ?: return
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices()
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
gatt.requestMtu(BleConfig.DEFAULT_MTU)
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
listener?.onPeerDisconnected(peer)
}
@@ -428,6 +431,8 @@ class AndroidBleTransport(
val peer = peerMap[peerUuid] ?: return
listener?.onMtuChanged(peer, mtu)
}
// Discover services after MTU negotiation (matching samiz flow)
gatt.discoverServices()
}
}
@@ -42,9 +42,15 @@ object BleConfig {
/** Maximum compressed message size (64KB). */
const val MAX_MESSAGE_SIZE = 64 * 1024
/** Default chunk size for message batching. */
/**
* Default payload size per chunk in bytes (excluding 2-byte overhead).
* Each transmitted chunk is `DEFAULT_CHUNK_SIZE + 2` bytes on the wire.
*/
const val DEFAULT_CHUNK_SIZE = 500
/** Default MTU to request during BLE connection. */
const val DEFAULT_MTU = 512
/** Minimum MTU for BLE 4.2. */
const val MIN_MTU_BLE_4_2 = 20
@@ -223,7 +223,8 @@ class BleMeshManager(
peer: BlePeer,
mtu: Int,
) {
val chunkSize = mtu - 3
// ATT overhead is 3 bytes, chunk framing is 2 bytes (index + total count)
val chunkSize = mtu - 3 - BleMessageChunker.CHUNK_OVERHEAD
clients[peer.deviceUuid]?.chunkSize = chunkSize
servers[peer.deviceUuid]?.chunkSize = chunkSize
}
@@ -27,18 +27,22 @@ import com.vitorpamplona.quartz.utils.Deflate
*
* NIP-BE messages are DEFLATE-compressed and split into chunks with the format:
* ```
* [batch index (first 2 bytes)][payload][total batches (last byte)]
* [chunk index (1 byte)][payload (up to chunkSize bytes)][total chunks (1 byte)]
* ```
*
* Each chunk carries a 2-byte index prefix and a 1-byte total count suffix,
* so the payload per chunk is `chunkSize - 3` bytes of compressed data.
* Each chunk carries a 1-byte index prefix and a 1-byte total count suffix
* (2 bytes overhead), matching the reference implementation in KoalaSat/samiz.
*/
object BleMessageChunker {
/** Per-chunk overhead: 1 byte index + 1 byte total count. */
const val CHUNK_OVERHEAD = 2
/**
* Compresses and splits a NIP-01 JSON message into BLE-sized chunks.
*
* @param message The JSON message string (e.g., `["EVENT", {...}]`).
* @param chunkSize The maximum size of each chunk in bytes (default 500).
* @param chunkSize The maximum payload size per chunk in bytes (default 500).
* The actual transmitted chunk size is `payload + 2` (index + total count).
* @return Array of byte arrays, each representing one chunk.
* @throws IllegalArgumentException if compressed message exceeds 64KB.
*/
@@ -52,26 +56,23 @@ object BleMessageChunker {
"Compressed message size ${compressed.size} exceeds maximum ${BleConfig.MAX_MESSAGE_SIZE} bytes"
}
// Overhead per chunk: 2 bytes index prefix + 1 byte total count suffix
val payloadSize = chunkSize - 3
require(payloadSize > 0) { "chunkSize must be at least 4 bytes" }
require(chunkSize > 0) { "chunkSize must be positive" }
val numChunks = (compressed.size + payloadSize - 1) / payloadSize
val numChunks = (compressed.size + chunkSize - 1) / chunkSize
require(numChunks <= 255) {
"Message requires $numChunks chunks but maximum is 255"
}
return Array(numChunks) { i ->
val start = i * payloadSize
val end = minOf((i + 1) * payloadSize, compressed.size)
val start = i * chunkSize
val end = minOf((i + 1) * chunkSize, compressed.size)
val payload = compressed.copyOfRange(start, end)
// [2-byte index][payload][1-byte total]
val chunk = ByteArray(payload.size + 3)
chunk[0] = (i shr 8).toByte()
chunk[1] = (i and 0xFF).toByte()
payload.copyInto(chunk, 2)
// [1-byte index][payload][1-byte total]
val chunk = ByteArray(payload.size + CHUNK_OVERHEAD)
chunk[0] = i.toByte()
payload.copyInto(chunk, 1)
chunk[chunk.size - 1] = numChunks.toByte()
chunk
@@ -81,7 +82,7 @@ object BleMessageChunker {
/**
* Reassembles and decompresses chunks back into a NIP-01 JSON message.
*
* Chunks can arrive in any order; they are sorted by their index prefix.
* Chunks can arrive in any order; they are sorted by their index byte.
*
* @param chunks The received chunks.
* @return The original JSON message string.
@@ -91,7 +92,7 @@ object BleMessageChunker {
var reassembled = ByteArray(0)
for (chunk in sorted) {
val payload = chunk.copyOfRange(2, chunk.size - 1)
val payload = chunk.copyOfRange(1, chunk.size - 1)
val newArray = ByteArray(reassembled.size + payload.size)
reassembled.copyInto(newArray)
payload.copyInto(newArray, reassembled.size)
@@ -102,9 +103,9 @@ object BleMessageChunker {
}
/**
* Extracts the 2-byte chunk index from a chunk.
* Extracts the 1-byte chunk index from a chunk.
*/
fun chunkIndex(chunk: ByteArray): Int = ((chunk[0].toInt() and 0xFF) shl 8) or (chunk[1].toInt() and 0xFF)
fun chunkIndex(chunk: ByteArray): Int = chunk[0].toInt() and 0xFF
/**
* Extracts the total number of expected chunks from a chunk's last byte.
@@ -48,15 +48,19 @@ class BleMessageChunkerTest {
@Test
fun chunksHaveCorrectFormat() {
val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world"}]"""
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 20)
val chunkSize = 20
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = chunkSize)
for ((i, chunk) in chunks.withIndex()) {
// First 2 bytes are the index
// First byte is the index
assertEquals(i, BleMessageChunker.chunkIndex(chunk))
// Last byte is the total count
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
// Each chunk should be at most chunkSize
assertTrue(chunk.size <= 20, "Chunk size ${chunk.size} exceeds max 20")
// Each chunk should be at most chunkSize + 2 (overhead)
assertTrue(
chunk.size <= chunkSize + BleMessageChunker.CHUNK_OVERHEAD,
"Chunk size ${chunk.size} exceeds max ${chunkSize + BleMessageChunker.CHUNK_OVERHEAD}",
)
}
}