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() .Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true) .setConnectable(true)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setTimeout(0)
.build() .build()
val data = val data =
@@ -158,6 +159,7 @@ class AndroidBleTransport(
BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE,
) )
writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
readCharacteristic = readCharacteristic =
BluetoothGattCharacteristic( BluetoothGattCharacteristic(
@@ -364,7 +366,8 @@ class AndroidBleTransport(
val peer = peerMap[peerUuid] ?: return val peer = peerMap[peerUuid] ?: return
if (newState == BluetoothProfile.STATE_CONNECTED) { if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.discoverServices() gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
gatt.requestMtu(BleConfig.DEFAULT_MTU)
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
listener?.onPeerDisconnected(peer) listener?.onPeerDisconnected(peer)
} }
@@ -428,6 +431,8 @@ class AndroidBleTransport(
val peer = peerMap[peerUuid] ?: return val peer = peerMap[peerUuid] ?: return
listener?.onMtuChanged(peer, mtu) 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). */ /** Maximum compressed message size (64KB). */
const val MAX_MESSAGE_SIZE = 64 * 1024 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 const val DEFAULT_CHUNK_SIZE = 500
/** Default MTU to request during BLE connection. */
const val DEFAULT_MTU = 512
/** Minimum MTU for BLE 4.2. */ /** Minimum MTU for BLE 4.2. */
const val MIN_MTU_BLE_4_2 = 20 const val MIN_MTU_BLE_4_2 = 20
@@ -223,7 +223,8 @@ class BleMeshManager(
peer: BlePeer, peer: BlePeer,
mtu: Int, 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 clients[peer.deviceUuid]?.chunkSize = chunkSize
servers[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: * 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, * Each chunk carries a 1-byte index prefix and a 1-byte total count suffix
* so the payload per chunk is `chunkSize - 3` bytes of compressed data. * (2 bytes overhead), matching the reference implementation in KoalaSat/samiz.
*/ */
object BleMessageChunker { 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. * Compresses and splits a NIP-01 JSON message into BLE-sized chunks.
* *
* @param message The JSON message string (e.g., `["EVENT", {...}]`). * @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. * @return Array of byte arrays, each representing one chunk.
* @throws IllegalArgumentException if compressed message exceeds 64KB. * @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" "Compressed message size ${compressed.size} exceeds maximum ${BleConfig.MAX_MESSAGE_SIZE} bytes"
} }
// Overhead per chunk: 2 bytes index prefix + 1 byte total count suffix require(chunkSize > 0) { "chunkSize must be positive" }
val payloadSize = chunkSize - 3
require(payloadSize > 0) { "chunkSize must be at least 4 bytes" }
val numChunks = (compressed.size + payloadSize - 1) / payloadSize val numChunks = (compressed.size + chunkSize - 1) / chunkSize
require(numChunks <= 255) { require(numChunks <= 255) {
"Message requires $numChunks chunks but maximum is 255" "Message requires $numChunks chunks but maximum is 255"
} }
return Array(numChunks) { i -> return Array(numChunks) { i ->
val start = i * payloadSize val start = i * chunkSize
val end = minOf((i + 1) * payloadSize, compressed.size) val end = minOf((i + 1) * chunkSize, compressed.size)
val payload = compressed.copyOfRange(start, end) val payload = compressed.copyOfRange(start, end)
// [2-byte index][payload][1-byte total] // [1-byte index][payload][1-byte total]
val chunk = ByteArray(payload.size + 3) val chunk = ByteArray(payload.size + CHUNK_OVERHEAD)
chunk[0] = (i shr 8).toByte() chunk[0] = i.toByte()
chunk[1] = (i and 0xFF).toByte() payload.copyInto(chunk, 1)
payload.copyInto(chunk, 2)
chunk[chunk.size - 1] = numChunks.toByte() chunk[chunk.size - 1] = numChunks.toByte()
chunk chunk
@@ -81,7 +82,7 @@ object BleMessageChunker {
/** /**
* Reassembles and decompresses chunks back into a NIP-01 JSON message. * 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. * @param chunks The received chunks.
* @return The original JSON message string. * @return The original JSON message string.
@@ -91,7 +92,7 @@ object BleMessageChunker {
var reassembled = ByteArray(0) var reassembled = ByteArray(0)
for (chunk in sorted) { 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) val newArray = ByteArray(reassembled.size + payload.size)
reassembled.copyInto(newArray) reassembled.copyInto(newArray)
payload.copyInto(newArray, reassembled.size) 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. * Extracts the total number of expected chunks from a chunk's last byte.
@@ -48,15 +48,19 @@ class BleMessageChunkerTest {
@Test @Test
fun chunksHaveCorrectFormat() { fun chunksHaveCorrectFormat() {
val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world"}]""" 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()) { for ((i, chunk) in chunks.withIndex()) {
// First 2 bytes are the index // First byte is the index
assertEquals(i, BleMessageChunker.chunkIndex(chunk)) assertEquals(i, BleMessageChunker.chunkIndex(chunk))
// Last byte is the total count // Last byte is the total count
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk)) assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
// Each chunk should be at most chunkSize // Each chunk should be at most chunkSize + 2 (overhead)
assertTrue(chunk.size <= 20, "Chunk size ${chunk.size} exceeds max 20") assertTrue(
chunk.size <= chunkSize + BleMessageChunker.CHUNK_OVERHEAD,
"Chunk size ${chunk.size} exceeds max ${chunkSize + BleMessageChunker.CHUNK_OVERHEAD}",
)
} }
} }