diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt index e2cd11bf6b..982d74d912 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt @@ -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() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt index 16101e9c96..5d904c1fb5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt index 2973ad6f20..d7a68989bb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt index 70536c421c..2a2c5b0f3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt @@ -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. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt index 5b01909105..17f7a6468c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt @@ -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}", + ) } }