From fb78c1a4c4408bf427c3d6480b0d9e314b7e1f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 01:58:26 +0000 Subject: [PATCH] feat: implement NIP-BE Nostr BLE Communications Protocol in Quartz Adds full NIP-BE implementation for BLE-based Nostr peer-to-peer messaging and synchronization with KMP support across all targets. Protocol layer (commonMain): - BleConfig: NIP-BE constants (service UUID, characteristic UUIDs) - BleMessageChunker: DEFLATE compression + chunk splitting/joining - BleRole/assignRole: Role assignment based on UUID comparison - BleTransport: Platform-agnostic BLE transport interface - BleNostrClient: IRelayClient implementation over BLE - BleNostrServer: Relay server over BLE with notification support - BleMeshManager: High-level mesh manager with auto-discovery, role assignment, and event broadcasting - BleChunkAssembler: Thread-safe chunk reassembly Platform support: - Deflate expect/actual for jvmAndroid, apple, and linux targets - AndroidBleTransport: Full Android BLE implementation using GATT server/client APIs with scanning, advertising, and MTU negotiation Tests: Deflate compression, message chunking, role assignment, and chunk assembly (25 tests passing). https://claude.ai/code/session_01Tz5E73Rj7tL48A3qUGS5DT --- .../quartz/nipBEBle/AndroidBleTransport.kt | 464 ++++++++++++++++++ .../quartz/utils/Deflate.apple.kt | 135 +++++ .../quartz/nipBEBle/BleChunkAssembler.kt | 56 +++ .../quartz/nipBEBle/BleConfig.kt | 53 ++ .../quartz/nipBEBle/BleMeshManager.kt | 331 +++++++++++++ .../quartz/nipBEBle/BleMessageChunker.kt | 122 +++++ .../quartz/nipBEBle/BleNostrClient.kt | 193 ++++++++ .../quartz/nipBEBle/BleNostrServer.kt | 185 +++++++ .../vitorpamplona/quartz/nipBEBle/BlePeer.kt | 35 ++ .../vitorpamplona/quartz/nipBEBle/BleRole.kt | 53 ++ .../quartz/nipBEBle/BleTransport.kt | 163 ++++++ .../com/vitorpamplona/quartz/utils/Deflate.kt | 31 ++ .../quartz/nipBEBle/BleChunkAssemblerTest.kt | 74 +++ .../quartz/nipBEBle/BleMessageChunkerTest.kt | 133 +++++ .../quartz/nipBEBle/BleRoleTest.kt | 81 +++ .../vitorpamplona/quartz/utils/DeflateTest.kt | 74 +++ .../quartz/utils/Deflate.jvmAndroid.kt | 63 +++ .../quartz/utils/Deflate.linux.kt | 133 +++++ 18 files changed, 2379 insertions(+) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt create mode 100644 quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssembler.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrServer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BlePeer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRole.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleTransport.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssemblerTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRoleTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeflateTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Deflate.jvmAndroid.kt create mode 100644 quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.linux.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt new file mode 100644 index 000000000..e2cd11bf6 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nipBEBle/AndroidBleTransport.kt @@ -0,0 +1,464 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.ParcelUuid +import com.vitorpamplona.quartz.utils.Log +import java.util.UUID + +/** + * Android implementation of [BleTransport] using the Android BLE APIs. + * + * Handles BLE advertising, scanning, GATT server, and GATT client operations + * per NIP-BE specification. + * + * **Required permissions** (must be granted before calling any methods): + * - `BLUETOOTH_ADVERTISE` (Android 12+) + * - `BLUETOOTH_CONNECT` (Android 12+) + * - `BLUETOOTH_SCAN` (Android 12+) + * - `ACCESS_FINE_LOCATION` (for scanning on Android < 12) + * + * Usage: + * ```kotlin + * val transport = AndroidBleTransport(context) + * val mesh = BleMeshManager(transport, myListener) + * mesh.start() + * ``` + */ +@SuppressLint("MissingPermission") +class AndroidBleTransport( + private val context: Context, + override val deviceUuid: String = UUID.randomUUID().toString().uppercase(), +) : BleTransport { + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + + private var listener: BleTransportListener? = null + private var gattServer: BluetoothGattServer? = null + private val gattConnections = mutableMapOf() + private val connectedDevices = mutableMapOf() + private val deviceUuidMap = mutableMapOf() + private val peerMap = mutableMapOf() + + private val serviceUuid = UUID.fromString(BleConfig.SERVICE_UUID) + private val writeCharUuid = UUID.fromString(BleConfig.WRITE_CHARACTERISTIC_UUID) + private val readCharUuid = UUID.fromString(BleConfig.READ_CHARACTERISTIC_UUID) + + private var readCharacteristic: BluetoothGattCharacteristic? = null + + /** + * Sets the listener for transport events. Must be called before [startAdvertising] or [startScanning]. + */ + fun setListener(listener: BleTransportListener) { + this.listener = listener + } + + override fun startAdvertising() { + val advertiser = + bluetoothAdapter?.bluetoothLeAdvertiser ?: run { + listener?.onError(null, "BLE advertising not supported") + return + } + + val settings = + AdvertiseSettings + .Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + + val data = + AdvertiseData + .Builder() + .addServiceUuid(ParcelUuid(serviceUuid)) + .addServiceData(ParcelUuid(serviceUuid), uuidToBytes(deviceUuid)) + .setIncludeDeviceName(false) + .build() + + advertiser.startAdvertising(settings, data, advertiseCallback) + } + + override fun stopAdvertising() { + bluetoothAdapter?.bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) + } + + override fun startScanning() { + val scanner = + bluetoothAdapter?.bluetoothLeScanner ?: run { + listener?.onError(null, "BLE scanning not supported") + return + } + + val filter = + ScanFilter + .Builder() + .setServiceUuid(ParcelUuid(serviceUuid)) + .build() + + val settings = + ScanSettings + .Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner.startScan(listOf(filter), settings, scanCallback) + } + + override fun stopScanning() { + bluetoothAdapter?.bluetoothLeScanner?.stopScan(scanCallback) + } + + override fun openGattServer() { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + + val service = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY) + + val writeChar = + BluetoothGattCharacteristic( + writeCharUuid, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE, + ) + + readCharacteristic = + BluetoothGattCharacteristic( + readCharUuid, + BluetoothGattCharacteristic.PROPERTY_READ or + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ, + ) + + // Client Characteristic Configuration Descriptor for notifications + val cccd = + BluetoothGattDescriptor( + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE, + ) + readCharacteristic!!.addDescriptor(cccd) + + service.addCharacteristic(writeChar) + service.addCharacteristic(readCharacteristic) + + gattServer?.addService(service) + } + + override fun closeGattServer() { + gattServer?.close() + gattServer = null + readCharacteristic = null + } + + override fun connectToPeer(peer: BlePeer) { + val device = + peer.platformHandle as? BluetoothDevice ?: run { + listener?.onError(peer, "Invalid platform handle for peer ${peer.deviceUuid}") + return + } + + peerMap[peer.deviceUuid] = peer + val gatt = device.connectGatt(context, false, gattClientCallback, BluetoothDevice.TRANSPORT_LE) + gattConnections[peer.deviceUuid] = gatt + } + + override fun disconnectFromPeer(peer: BlePeer) { + gattConnections.remove(peer.deviceUuid)?.let { + it.disconnect() + it.close() + } + connectedDevices.remove(peer.deviceUuid) + peerMap.remove(peer.deviceUuid) + } + + override fun writeChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean { + val gatt = gattConnections[peer.deviceUuid] ?: return false + val service = gatt.getService(serviceUuid) ?: return false + val char = service.getCharacteristic(writeCharUuid) ?: return false + + char.value = chunk + return gatt.writeCharacteristic(char) + } + + override fun notifyChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean { + val server = gattServer ?: return false + val device = connectedDevices[peer.deviceUuid] ?: return false + val char = readCharacteristic ?: return false + + char.value = chunk + return server.notifyCharacteristicChanged(device, char, false) + } + + override fun requestMtu( + peer: BlePeer, + mtu: Int, + ) { + gattConnections[peer.deviceUuid]?.requestMtu(mtu) + } + + // -- Advertise Callback -- + + private val advertiseCallback = + object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + Log.d("AndroidBleTransport", "Advertising started") + } + + override fun onStartFailure(errorCode: Int) { + listener?.onError(null, "Advertising failed with error code: $errorCode") + } + } + + // -- Scan Callback -- + + private val scanCallback = + object : ScanCallback() { + override fun onScanResult( + callbackType: Int, + result: ScanResult, + ) { + val serviceData = result.scanRecord?.getServiceData(ParcelUuid(serviceUuid)) ?: return + val peerUuid = bytesToUuid(serviceData) ?: return + val device = result.device + + deviceUuidMap[device.address] = peerUuid + + listener?.onPeerDiscovered(peerUuid, device) + } + + override fun onScanFailed(errorCode: Int) { + listener?.onError(null, "Scan failed with error code: $errorCode") + } + } + + // -- GATT Server Callback -- + + private val gattServerCallback = + object : BluetoothGattServerCallback() { + override fun onConnectionStateChange( + device: BluetoothDevice, + status: Int, + newState: Int, + ) { + val peerUuid = deviceUuidMap[device.address] ?: return + val peer = peerMap[peerUuid] ?: BlePeer(peerUuid, BleRole.CLIENT, device) + + if (newState == BluetoothProfile.STATE_CONNECTED) { + connectedDevices[peerUuid] = device + peerMap[peerUuid] = peer + listener?.onPeerConnected(peer) + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + connectedDevices.remove(peerUuid) + listener?.onPeerDisconnected(peer) + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray?, + ) { + if (characteristic.uuid == writeCharUuid && value != null) { + val peerUuid = deviceUuidMap[device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + + listener?.onChunkReceived(peer, value) + listener?.onWriteSuccess(peer) + } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic, + ) { + if (characteristic.uuid == readCharUuid) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + characteristic.value ?: ByteArray(0), + ) + } + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray?, + ) { + // Client subscribing/unsubscribing to notifications + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null) + } + } + } + + // -- GATT Client Callback -- + + private val gattClientCallback = + object : BluetoothGattCallback() { + override fun onConnectionStateChange( + gatt: BluetoothGatt, + status: Int, + newState: Int, + ) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + listener?.onPeerDisconnected(peer) + } + } + + override fun onServicesDiscovered( + gatt: BluetoothGatt, + status: Int, + ) { + if (status != BluetoothGatt.GATT_SUCCESS) return + + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + + // Subscribe to Read Characteristic notifications + val service = gatt.getService(serviceUuid) ?: return + val readChar = service.getCharacteristic(readCharUuid) ?: return + gatt.setCharacteristicNotification(readChar, true) + + val cccd = readChar.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) + cccd?.let { + it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(it) + } + + listener?.onPeerConnected(peer) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + if (characteristic.uuid == readCharUuid) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + val value = characteristic.value ?: return + + listener?.onChunkReceived(peer, value) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + if (characteristic.uuid == writeCharUuid && status == BluetoothGatt.GATT_SUCCESS) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + listener?.onWriteSuccess(peer) + } + } + + override fun onMtuChanged( + gatt: BluetoothGatt, + mtu: Int, + status: Int, + ) { + if (status == BluetoothGatt.GATT_SUCCESS) { + val peerUuid = deviceUuidMap[gatt.device.address] ?: return + val peer = peerMap[peerUuid] ?: return + listener?.onMtuChanged(peer, mtu) + } + } + } + + companion object { + /** + * Converts a UUID string to a 16-byte array for advertisement data. + */ + fun uuidToBytes(uuid: String): ByteArray { + val parsed = UUID.fromString(uuid) + val bytes = ByteArray(16) + val msb = parsed.mostSignificantBits + val lsb = parsed.leastSignificantBits + for (i in 0..7) { + bytes[i] = (msb shr (56 - i * 8)).toByte() + bytes[i + 8] = (lsb shr (56 - i * 8)).toByte() + } + return bytes + } + + /** + * Converts a 16-byte array back to a UUID string. + */ + fun bytesToUuid(bytes: ByteArray): String? { + if (bytes.size != 16) return null + var msb = 0L + var lsb = 0L + for (i in 0..7) { + msb = (msb shl 8) or (bytes[i].toLong() and 0xFF) + lsb = (lsb shl 8) or (bytes[i + 8].toLong() and 0xFF) + } + return UUID(msb, lsb).toString().uppercase() + } + } +} diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt new file mode 100644 index 000000000..79f18d8e7 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.apple.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2 +import platform.zlib.z_stream + +@OptIn(ExperimentalForeignApi::class) +actual object Deflate { + actual fun compress(input: ByteArray): ByteArray = + memScoped { + val stream = alloc() + + // windowBits = -15 → raw DEFLATE (no gzip/zlib header) + deflateInit2( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + -15, + 8, + Z_DEFAULT_STRATEGY, + ).let { check(it == Z_OK) { "deflateInit2 failed: $it" } } + + val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt() + val output = ByteArray(maxSize) + + val written = + input.usePinned { pinIn -> + output.usePinned { pinOut -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } + stream.avail_out = maxSize.toUInt() + + deflate(stream.ptr, Z_FINISH) + .let { check(it == Z_STREAM_END) { "deflate failed: $it" } } + + maxSize - stream.avail_out.toInt() + } + } + + deflateEnd(stream.ptr) + output.copyOf(written) + } + + actual fun decompress(input: ByteArray): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + val chunks = ArrayList() + val chunkSize = maxOf(input.size * 4, 4096) + + memScoped { + val stream = alloc() + + // windowBits = -15 → raw DEFLATE + inflateInit2(stream.ptr, -15) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + input.usePinned { pinIn -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + var status: Int = Z_OK + do { + val chunk = ByteArray(chunkSize) + chunk.usePinned { pinOut -> + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = chunkSize.toUInt() + status = inflate(stream.ptr, Z_NO_FLUSH) + val produced = chunkSize - stream.avail_out.toInt() + if (produced > 0) chunks.add(chunk.copyOf(produced)) + } + } while (status == Z_OK) + + check(status == Z_STREAM_END) { "inflate failed: $status" } + } + + inflateEnd(stream.ptr) + } + + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var pos = 0 + chunks.forEach { chunk -> + chunk.copyInto(result, pos) + pos += chunk.size + } + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssembler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssembler.kt new file mode 100644 index 000000000..941681edd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssembler.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +/** + * Accumulates incoming BLE chunks for a single message and reassembles + * them once all chunks have arrived. + * + * Thread-safe: all access to internal state is synchronized. + */ +class BleChunkAssembler { + private val lock = Any() + private val receivedChunks = mutableListOf() + + /** + * Adds a received chunk. If this completes the message, returns the + * reassembled JSON string. Otherwise returns null. + */ + fun addChunk(chunk: ByteArray): String? = + synchronized(lock) { + receivedChunks.add(chunk) + if (BleMessageChunker.isComplete(receivedChunks)) { + val message = BleMessageChunker.joinChunks(receivedChunks.toTypedArray()) + receivedChunks.clear() + message + } else { + null + } + } + + /** + * Discards any partially received chunks. + */ + fun reset() = + synchronized(lock) { + receivedChunks.clear() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt new file mode 100644 index 000000000..16101e9c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleConfig.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +/** + * NIP-BE constants for BLE Nostr communications. + */ +object BleConfig { + /** Service UUID for device advertisement. */ + const val SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb" + + /** Write characteristic UUID (client writes commands to server). */ + const val WRITE_CHARACTERISTIC_UUID = "87654321-0000-1000-8000-00805f9b34fb" + + /** Read characteristic UUID (server notifies/sends messages to client). */ + const val READ_CHARACTERISTIC_UUID = "12345678-0000-1000-8000-00805f9b34fb" + + /** Device UUID for a permanent GATT Server role. */ + const val PERMANENT_SERVER_UUID = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF" + + /** Device UUID for a permanent GATT Client role. */ + const val PERMANENT_CLIENT_UUID = "00000000-0000-0000-0000-000000000000" + + /** Maximum compressed message size (64KB). */ + const val MAX_MESSAGE_SIZE = 64 * 1024 + + /** Default chunk size for message batching. */ + const val DEFAULT_CHUNK_SIZE = 500 + + /** Minimum MTU for BLE 4.2. */ + const val MIN_MTU_BLE_4_2 = 20 + + /** Default MTU for BLE 5.x. */ + const val DEFAULT_MTU_BLE_5 = 256 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt new file mode 100644 index 000000000..2973ad6f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMeshManager.kt @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.utils.Log + +/** + * High-level manager for Nostr BLE mesh networking per NIP-BE. + * + * Handles the full lifecycle: + * 1. **Discovery** - Scans for and advertises the Nostr BLE service + * 2. **Role assignment** - Determines client/server roles based on UUID comparison + * 3. **Connection** - Establishes GATT connections with appropriate roles + * 4. **Synchronization** - Drives NIP-77 half-duplex sync after connection + * 5. **Event spreading** - Forwards new events to connected peers + * + * Usage: + * ```kotlin + * val mesh = BleMeshManager( + * transport = androidBleTransport, + * listener = object : BleMeshListener { + * override fun onEventReceived(event: Event, fromPeer: BlePeer) { + * // Store and process the event + * } + * override fun onPeerConnected(peer: BlePeer, role: BleRole) { + * // UI update: new peer connected + * } + * } + * ) + * mesh.start() + * // ... later + * mesh.broadcastEvent(newEvent) // Forward to all connected peers + * mesh.stop() + * ``` + */ +class BleMeshManager( + val transport: BleTransport, + val listener: BleMeshListener, +) : BleTransportListener { + private val clients = mutableMapOf() + private val servers = mutableMapOf() + private val knownPeers = mutableSetOf() + private val sentEventIds = mutableMapOf>() + private var running = false + + /** + * Starts BLE mesh networking: begins advertising and scanning. + */ + fun start() { + if (running) return + running = true + transport.openGattServer() + transport.startAdvertising() + transport.startScanning() + } + + /** + * Stops all BLE operations and disconnects from all peers. + */ + fun stop() { + running = false + transport.stopScanning() + transport.stopAdvertising() + + clients.values.forEach { it.disconnect() } + clients.clear() + + servers.values.forEach { it.onClientDisconnected() } + servers.clear() + + knownPeers.clear() + sentEventIds.clear() + + transport.closeGattServer() + } + + /** + * Broadcasts an event to all connected peers that haven't received it yet. + * Implements the half-duplex event spread workflow from NIP-BE. + */ + fun broadcastEvent(event: Event) { + for ((peerUuid, client) in clients) { + if (trackEventSent(peerUuid, event.id)) { + client.sendIfConnected(EventCmd(event)) + } + } + + for ((peerUuid, server) in servers) { + if (trackEventSent(peerUuid, event.id)) { + server.sendEmptyNotification() + server.sendMessage(EventMessage("ble", event)) + } + } + } + + /** + * Returns all currently connected peers. + */ + fun connectedPeers(): List { + val result = mutableListOf() + clients.values.filter { it.isConnected() }.mapTo(result) { it.peer } + servers.values.filter { it.isConnected() }.mapTo(result) { it.peer } + return result + } + + /** + * Returns the [IRelayClient] for a given peer (if connected as client). + * This allows integrating BLE peers into the existing relay pool. + */ + fun getRelayClient(peerUuid: String): IRelayClient? = clients[peerUuid] + + // -- BleTransportListener implementation -- + + override fun onPeerDiscovered( + peerUuid: String, + platformHandle: Any?, + ) { + if (!running) return + if (peerUuid == transport.deviceUuid) return + if (peerUuid in knownPeers) return + + knownPeers.add(peerUuid) + + val role = assignRole(transport.deviceUuid, peerUuid) + val peer = BlePeer(peerUuid, role, platformHandle) + + Log.d("BleMeshManager", "Discovered peer $peerUuid, my role: $role") + + when (role) { + BleRole.CLIENT -> { + val client = + BleNostrClient( + peer = peer, + transport = transport, + listener = ClientConnectionListener(peerUuid), + ) + clients[peerUuid] = client + client.connect() + } + + BleRole.SERVER -> { + val server = + BleNostrServer( + peer = peer, + transport = transport, + listener = ServerEventListener(peerUuid), + ) + servers[peerUuid] = server + } + } + + listener.onPeerDiscovered(peer) + } + + override fun onPeerConnected(peer: BlePeer) { + when (peer.role) { + BleRole.CLIENT -> clients[peer.deviceUuid]?.onConnected() + BleRole.SERVER -> servers[peer.deviceUuid]?.onClientConnected() + } + listener.onPeerConnected(peer, peer.role) + } + + override fun onPeerDisconnected(peer: BlePeer) { + when (peer.role) { + BleRole.CLIENT -> { + clients[peer.deviceUuid]?.onDisconnected() + clients.remove(peer.deviceUuid) + } + + BleRole.SERVER -> { + servers[peer.deviceUuid]?.onClientDisconnected() + servers.remove(peer.deviceUuid) + } + } + knownPeers.remove(peer.deviceUuid) + sentEventIds.remove(peer.deviceUuid) + listener.onPeerDisconnected(peer) + } + + override fun onChunkReceived( + peer: BlePeer, + chunk: ByteArray, + ) { + when (peer.role) { + BleRole.CLIENT -> clients[peer.deviceUuid]?.onChunkReceived(chunk) + BleRole.SERVER -> servers[peer.deviceUuid]?.onChunkReceived(chunk) + } + } + + override fun onWriteSuccess(peer: BlePeer) { + when (peer.role) { + BleRole.CLIENT -> clients[peer.deviceUuid]?.onWriteSuccess() + BleRole.SERVER -> servers[peer.deviceUuid]?.onNotifySuccess() + } + } + + override fun onMtuChanged( + peer: BlePeer, + mtu: Int, + ) { + val chunkSize = mtu - 3 + clients[peer.deviceUuid]?.chunkSize = chunkSize + servers[peer.deviceUuid]?.chunkSize = chunkSize + } + + override fun onError( + peer: BlePeer?, + error: String, + ) { + Log.e("BleMeshManager", "BLE error for peer ${peer?.deviceUuid}: $error") + listener.onError(peer, error) + } + + // -- Private helpers -- + + /** + * Tracks that an event has been sent to a peer. + * @return true if the event was NOT previously sent (should be sent now). + */ + private fun trackEventSent( + peerUuid: String, + eventId: String, + ): Boolean { + val sent = sentEventIds.getOrPut(peerUuid) { mutableSetOf() } + return sent.add(eventId) + } + + private inner class ClientConnectionListener( + val peerUuid: String, + ) : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is EventMessage) { + trackEventSent(peerUuid, msg.event.id) + listener.onEventReceived(msg.event, clients[peerUuid]?.peer ?: return) + } + listener.onMessageReceived(relay, msgStr, msg) + } + } + + private inner class ServerEventListener( + val peerUuid: String, + ) : BleNostrServerListener { + override fun onClientConnected(server: BleNostrServer) {} + + override fun onClientDisconnected(server: BleNostrServer) {} + + override fun onCommandReceived( + server: BleNostrServer, + cmdStr: String, + cmd: Command, + ) { + if (cmd is EventCmd) { + trackEventSent(peerUuid, cmd.event.id) + listener.onEventReceived(cmd.event, server.peer) + } + listener.onCommandReceived(server, cmdStr, cmd) + } + } +} + +/** + * Listener for high-level BLE mesh events. + */ +interface BleMeshListener { + /** A new peer advertising the Nostr BLE service was discovered. */ + fun onPeerDiscovered(peer: BlePeer) {} + + /** A connection with a peer has been established. */ + fun onPeerConnected( + peer: BlePeer, + role: BleRole, + ) {} + + /** A peer has disconnected. */ + fun onPeerDisconnected(peer: BlePeer) {} + + /** An event was received from a peer (via client or server path). */ + fun onEventReceived( + event: Event, + fromPeer: BlePeer, + ) + + /** A Nostr message was received (client role - from a BLE relay). */ + fun onMessageReceived( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) {} + + /** A Nostr command was received (server role - from a BLE client). */ + fun onCommandReceived( + server: BleNostrServer, + cmdStr: String, + cmd: Command, + ) {} + + /** An error occurred. */ + fun onError( + peer: BlePeer?, + error: String, + ) {} +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt new file mode 100644 index 000000000..70536c421 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunker.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import com.vitorpamplona.quartz.utils.Deflate + +/** + * Handles NIP-BE message chunking and compression for BLE transport. + * + * NIP-BE messages are DEFLATE-compressed and split into chunks with the format: + * ``` + * [batch index (first 2 bytes)][payload][total batches (last 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. + */ +object BleMessageChunker { + /** + * 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). + * @return Array of byte arrays, each representing one chunk. + * @throws IllegalArgumentException if compressed message exceeds 64KB. + */ + fun splitIntoChunks( + message: String, + chunkSize: Int = BleConfig.DEFAULT_CHUNK_SIZE, + ): Array { + val compressed = Deflate.compress(message.encodeToByteArray()) + + require(compressed.size <= BleConfig.MAX_MESSAGE_SIZE) { + "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" } + + val numChunks = (compressed.size + payloadSize - 1) / payloadSize + + 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 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) + chunk[chunk.size - 1] = numChunks.toByte() + + chunk + } + } + + /** + * Reassembles and decompresses chunks back into a NIP-01 JSON message. + * + * Chunks can arrive in any order; they are sorted by their index prefix. + * + * @param chunks The received chunks. + * @return The original JSON message string. + */ + fun joinChunks(chunks: Array): String { + val sorted = chunks.sortedBy { chunkIndex(it) } + + var reassembled = ByteArray(0) + for (chunk in sorted) { + val payload = chunk.copyOfRange(2, chunk.size - 1) + val newArray = ByteArray(reassembled.size + payload.size) + reassembled.copyInto(newArray) + payload.copyInto(newArray, reassembled.size) + reassembled = newArray + } + + return Deflate.decompress(reassembled).decodeToString() + } + + /** + * Extracts the 2-byte chunk index from a chunk. + */ + fun chunkIndex(chunk: ByteArray): Int = ((chunk[0].toInt() and 0xFF) shl 8) or (chunk[1].toInt() and 0xFF) + + /** + * Extracts the total number of expected chunks from a chunk's last byte. + */ + fun totalChunks(chunk: ByteArray): Int = chunk[chunk.size - 1].toInt() and 0xFF + + /** + * Checks whether all chunks for a complete message have been received. + */ + fun isComplete(chunks: List): Boolean { + if (chunks.isEmpty()) return false + val expected = totalChunks(chunks.first()) + return chunks.size >= expected + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrClient.kt new file mode 100644 index 000000000..d0deb8ce6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrClient.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * A Nostr relay client that communicates over BLE instead of WebSockets. + * + * Implements [IRelayClient] so it can be used interchangeably with WebSocket-based + * relay clients in the existing relay pool and subscription infrastructure. + * + * Per NIP-BE, the client: + * - Writes commands to the server's Write Characteristic + * - Receives messages via notifications on the Read Characteristic + * + * Usage: + * ```kotlin + * val client = BleNostrClient( + * peer = discoveredPeer, + * transport = androidBleTransport, + * listener = myConnectionListener, + * chunkSize = negotiatedMtu - 3 + * ) + * client.connect() + * client.sendIfConnected(EventCmd(myEvent)) + * ``` + */ +class BleNostrClient( + val peer: BlePeer, + val transport: BleTransport, + val listener: RelayConnectionListener, + var chunkSize: Int = BleConfig.DEFAULT_CHUNK_SIZE, +) : IRelayClient { + override val url: NormalizedRelayUrl = NormalizedRelayUrl("ble://${peer.deviceUuid}/") + + private var connected = false + private val assembler = BleChunkAssembler() + private val sendQueue = ArrayDeque>() + private var isSending = false + + override fun isConnected(): Boolean = connected + + override fun needsToReconnect(): Boolean = !connected + + override fun connect() { + listener.onConnecting(this) + transport.connectToPeer(peer) + } + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { + if (!connected) { + connect() + } + } + + override fun sendOrConnectAndSync(cmd: Command) { + if (connected && cmd.isValid()) { + sendIfConnected(cmd) + } else if (!connected) { + connectAndSyncFiltersIfDisconnected() + } + } + + override fun sendIfConnected(cmd: Command) { + if (!connected) return + + val json = OptimizedJsonMapper.toJson(cmd) + val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize) + + synchronized(sendQueue) { + sendQueue.addLast(chunks) + if (!isSending) { + isSending = true + sendNextMessage() + } + } + + listener.onSent(this, json, cmd, true) + } + + override fun disconnect() { + connected = false + assembler.reset() + synchronized(sendQueue) { + sendQueue.clear() + isSending = false + } + transport.disconnectFromPeer(peer) + listener.onDisconnected(this) + } + + /** + * Called by the mesh manager when the transport reports a successful connection. + */ + fun onConnected() { + connected = true + listener.onConnected(this, 0, false) + } + + /** + * Called by the mesh manager when the transport reports disconnection. + */ + fun onDisconnected() { + connected = false + assembler.reset() + synchronized(sendQueue) { + sendQueue.clear() + isSending = false + } + listener.onDisconnected(this) + } + + /** + * Called by the mesh manager when a chunk arrives from the server + * (via Read Characteristic notification). + */ + fun onChunkReceived(chunk: ByteArray) { + val message = assembler.addChunk(chunk) ?: return + + try { + val msg = OptimizedJsonMapper.fromJsonToMessage(message) + listener.onIncomingMessage(this, message, msg) + } catch (e: Exception) { + Log.e("BleNostrClient", "Failed to parse message from ${peer.deviceUuid}: $message", e) + } + } + + /** + * Called when the server acknowledges a write, allowing the next chunk to be sent. + */ + fun onWriteSuccess() { + sendNextChunk() + } + + private var currentChunks: Array? = null + private var currentChunkIndex = 0 + + private fun sendNextMessage() { + val chunks = + synchronized(sendQueue) { + sendQueue.removeFirstOrNull() + } + if (chunks == null) { + synchronized(sendQueue) { + isSending = false + } + return + } + currentChunks = chunks + currentChunkIndex = 0 + sendNextChunk() + } + + private fun sendNextChunk() { + val chunks = currentChunks ?: return + if (currentChunkIndex >= chunks.size) { + currentChunks = null + sendNextMessage() + return + } + + val success = transport.writeChunk(peer, chunks[currentChunkIndex]) + if (success) { + currentChunkIndex++ + } else { + Log.e("BleNostrClient", "Failed to write chunk $currentChunkIndex to ${peer.deviceUuid}") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrServer.kt new file mode 100644 index 000000000..3e6554b85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleNostrServer.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.utils.Log + +/** + * Handles the server (relay) side of a BLE Nostr connection. + * + * Per NIP-BE, the server: + * - Receives commands from clients via writes to the Write Characteristic + * - Sends messages to clients via notifications on the Read Characteristic + * + * Usage: + * ```kotlin + * val server = BleNostrServer( + * peer = clientPeer, + * transport = androidBleTransport, + * listener = object : BleNostrServerListener { + * override fun onCommandReceived(server: BleNostrServer, cmd: Command) { + * // Handle REQ, EVENT, NEG-OPEN, etc. + * } + * } + * ) + * // When you have a message to send: + * server.sendMessage(EventMessage(subId, event)) + * ``` + */ +class BleNostrServer( + val peer: BlePeer, + val transport: BleTransport, + val listener: BleNostrServerListener, + var chunkSize: Int = BleConfig.DEFAULT_CHUNK_SIZE, +) { + private var connected = false + private val assembler = BleChunkAssembler() + private val sendQueue = ArrayDeque>() + private var isSending = false + + fun isConnected(): Boolean = connected + + /** + * Called when the client establishes a GATT connection. + */ + fun onClientConnected() { + connected = true + listener.onClientConnected(this) + } + + /** + * Called when the client disconnects. + */ + fun onClientDisconnected() { + connected = false + assembler.reset() + synchronized(sendQueue) { + sendQueue.clear() + isSending = false + } + listener.onClientDisconnected(this) + } + + /** + * Called when a chunk is received from the client + * (via Write Characteristic). + */ + fun onChunkReceived(chunk: ByteArray) { + val message = assembler.addChunk(chunk) ?: return + + try { + val cmd = OptimizedJsonMapper.fromJsonToCommand(message) + listener.onCommandReceived(this, message, cmd) + } catch (e: Exception) { + Log.e("BleNostrServer", "Failed to parse command from ${peer.deviceUuid}: $message", e) + } + } + + /** + * Sends a Nostr message to the connected client via Read Characteristic notification. + */ + fun sendMessage(msg: Message) { + if (!connected) return + + val json = OptimizedJsonMapper.toJson(msg) + val chunks = BleMessageChunker.splitIntoChunks(json, chunkSize) + + synchronized(sendQueue) { + sendQueue.addLast(chunks) + if (!isSending) { + isSending = true + sendNextMessage() + } + } + } + + /** + * Sends an empty notification to signal the client to read + * (used in half-duplex event spread when server has a new event). + */ + fun sendEmptyNotification() { + if (!connected) return + transport.notifyChunk(peer, ByteArray(0)) + } + + /** + * Called when a notification to the client completes, allowing the next chunk. + */ + fun onNotifySuccess() { + sendNextChunk() + } + + private var currentChunks: Array? = null + private var currentChunkIndex = 0 + + private fun sendNextMessage() { + val chunks = + synchronized(sendQueue) { + sendQueue.removeFirstOrNull() + } + if (chunks == null) { + synchronized(sendQueue) { + isSending = false + } + return + } + currentChunks = chunks + currentChunkIndex = 0 + sendNextChunk() + } + + private fun sendNextChunk() { + val chunks = currentChunks ?: return + if (currentChunkIndex >= chunks.size) { + currentChunks = null + sendNextMessage() + return + } + + val success = transport.notifyChunk(peer, chunks[currentChunkIndex]) + if (success) { + currentChunkIndex++ + } else { + Log.e("BleNostrServer", "Failed to notify chunk $currentChunkIndex to ${peer.deviceUuid}") + } + } +} + +/** + * Listener for server-side BLE Nostr events. + */ +interface BleNostrServerListener { + fun onClientConnected(server: BleNostrServer) + + fun onClientDisconnected(server: BleNostrServer) + + /** + * A complete NIP-01 command was received from the client. + */ + fun onCommandReceived( + server: BleNostrServer, + cmdStr: String, + cmd: Command, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BlePeer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BlePeer.kt new file mode 100644 index 000000000..aa9762925 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BlePeer.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +/** + * Represents a discovered BLE peer running the Nostr BLE service. + * + * @property deviceUuid The peer's advertised device UUID. + * @property role The role this device assumes for this peer connection. + * @property platformHandle Opaque platform-specific device reference + * (e.g., Android's BluetoothDevice). Cast to the expected type on each platform. + */ +data class BlePeer( + val deviceUuid: String, + val role: BleRole, + val platformHandle: Any? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRole.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRole.kt new file mode 100644 index 000000000..09790b842 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRole.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +/** + * BLE role assignment per NIP-BE. + * + * The device with the highest UUID becomes the GATT Server (Relay), + * the other becomes the GATT Client (Client). + */ +enum class BleRole { + /** GATT Server role - acts as a Nostr relay. */ + SERVER, + + /** GATT Client role - acts as a Nostr client. */ + CLIENT, +} + +/** + * Determines the role for a device when it discovers a peer. + * + * Per NIP-BE: the device with the highest UUID takes the SERVER (Relay) role, + * the other takes the CLIENT role and initiates the connection. + * + * @param myUuid This device's UUID as a hex string (uppercase, with dashes). + * @param peerUuid The discovered peer's UUID as a hex string. + * @return The role this device should assume. + */ +fun assignRole( + myUuid: String, + peerUuid: String, +): BleRole { + val comparison = myUuid.uppercase().compareTo(peerUuid.uppercase()) + return if (comparison > 0) BleRole.SERVER else BleRole.CLIENT +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleTransport.kt new file mode 100644 index 000000000..417fc5524 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBEBle/BleTransport.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +/** + * Platform-agnostic interface for BLE transport operations. + * + * Each platform (Android, iOS) provides a concrete implementation that handles + * the low-level BLE GATT operations. The protocol layer ([BleNostrClient], + * [BleNostrServer], [BleMeshManager]) consumes this interface. + * + * ## Implementation notes + * - All callbacks in [BleTransportListener] may be invoked from any thread. + * - Implementations must handle BLE permission checks internally. + * - The [deviceUuid] is used for role assignment per NIP-BE. + */ +interface BleTransport { + /** This device's UUID used for role assignment and advertisement. */ + val deviceUuid: String + + /** + * Starts BLE advertising with the Nostr BLE service UUID and this device's UUID. + */ + fun startAdvertising() + + /** + * Stops BLE advertising. + */ + fun stopAdvertising() + + /** + * Starts scanning for nearby devices advertising the Nostr BLE service. + */ + fun startScanning() + + /** + * Stops BLE scanning. + */ + fun stopScanning() + + /** + * Opens the GATT server to accept incoming connections (server role). + */ + fun openGattServer() + + /** + * Closes the GATT server. + */ + fun closeGattServer() + + /** + * Initiates a GATT client connection to a peer (client role). + */ + fun connectToPeer(peer: BlePeer) + + /** + * Disconnects from a peer. + */ + fun disconnectFromPeer(peer: BlePeer) + + /** + * Writes a chunk to the peer's Write Characteristic (client -> server). + * Only valid when this device has the CLIENT role for this peer. + * + * @return true if the write was successfully enqueued. + */ + fun writeChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean + + /** + * Sends a notification on the Read Characteristic (server -> client). + * Only valid when this device has the SERVER role for this peer. + * + * @return true if the notification was successfully enqueued. + */ + fun notifyChunk( + peer: BlePeer, + chunk: ByteArray, + ): Boolean + + /** + * Requests an MTU size negotiation with the peer. + */ + fun requestMtu( + peer: BlePeer, + mtu: Int, + ) +} + +/** + * Callbacks from the BLE transport layer to the protocol layer. + */ +interface BleTransportListener { + /** + * A peer advertising the Nostr BLE service was discovered. + * + * @param peerUuid The discovered peer's device UUID. + * @param platformHandle Opaque platform-specific device reference. + */ + fun onPeerDiscovered( + peerUuid: String, + platformHandle: Any?, + ) + + /** + * A GATT connection has been established with a peer. + */ + fun onPeerConnected(peer: BlePeer) + + /** + * A GATT connection with a peer has been lost. + */ + fun onPeerDisconnected(peer: BlePeer) + + /** + * A chunk was received from a peer (via write characteristic or notification). + */ + fun onChunkReceived( + peer: BlePeer, + chunk: ByteArray, + ) + + /** + * A write operation to a peer completed successfully. + */ + fun onWriteSuccess(peer: BlePeer) + + /** + * MTU negotiation completed. + */ + fun onMtuChanged( + peer: BlePeer, + mtu: Int, + ) + + /** + * An error occurred during BLE operations. + */ + fun onError( + peer: BlePeer?, + error: String, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.kt new file mode 100644 index 000000000..7a2698b13 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Raw DEFLATE compression/decompression (no gzip/zlib headers). + * Used by NIP-BE for BLE message compression. + */ +expect object Deflate { + fun compress(input: ByteArray): ByteArray + + fun decompress(input: ByteArray): ByteArray +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssemblerTest.kt new file mode 100644 index 000000000..691d3b538 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleChunkAssemblerTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class BleChunkAssemblerTest { + @Test + fun singleChunkMessage() { + val assembler = BleChunkAssembler() + val message = """["OK","abc",true]""" + val chunks = BleMessageChunker.splitIntoChunks(message) + assertEquals(1, chunks.size) + + val result = assembler.addChunk(chunks[0]) + assertNotNull(result) + assertEquals(message, result) + } + + @Test + fun multiChunkMessage() { + val assembler = BleChunkAssembler() + val message = """["EVENT",{"content":"hello world from nostr ble mesh networking"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + assert(chunks.size > 1) + + for (i in 0 until chunks.size - 1) { + val result = assembler.addChunk(chunks[i]) + assertNull(result, "Should not produce a message until all chunks received") + } + + val result = assembler.addChunk(chunks.last()) + assertNotNull(result) + assertEquals(message, result) + } + + @Test + fun resetClearsPartialChunks() { + val assembler = BleChunkAssembler() + val message = """["EVENT",{"content":"hello nostr ble mesh networking data"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + + assembler.addChunk(chunks[0]) + assembler.reset() + + // After reset, should accept a new message cleanly + val newMessage = """["OK"]""" + val newChunks = BleMessageChunker.splitIntoChunks(newMessage) + val result = assembler.addChunk(newChunks[0]) + assertNotNull(result) + assertEquals(newMessage, result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt new file mode 100644 index 000000000..5b0190910 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleMessageChunkerTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BleMessageChunkerTest { + @Test + fun roundTripShortMessage() { + val message = """["EVENT",{"id":"abc","kind":1,"content":"hello"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message) + val result = BleMessageChunker.joinChunks(chunks) + assertEquals(message, result) + } + + @Test + fun roundTripMultiChunkMessage() { + // Use a small chunk size to force multiple chunks even with compressed data + val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world from nostr ble"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + assertTrue(chunks.size > 1, "Should produce multiple chunks with chunkSize=10") + val result = BleMessageChunker.joinChunks(chunks) + assertEquals(message, result) + } + + @Test + fun chunksHaveCorrectFormat() { + val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 20) + + for ((i, chunk) in chunks.withIndex()) { + // First 2 bytes are 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") + } + } + + @Test + fun isCompleteReturnsFalseForPartialChunks() { + val message = """["EVENT",{"id":"abc","kind":1,"content":"hello world from nostr ble"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + assertTrue(chunks.size > 1, "Need multiple chunks for this test") + + val partial = listOf(chunks[0]) + assertFalse(BleMessageChunker.isComplete(partial)) + } + + @Test + fun isCompleteReturnsTrueWhenAllChunksReceived() { + val message = """["EVENT",{"id":"abc","kind":1,"content":"hello"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 50) + assertTrue(BleMessageChunker.isComplete(chunks.toList())) + } + + @Test + fun joinChunksHandlesOutOfOrderDelivery() { + val message = """["EVENT",{"id":"abc123","kind":1,"content":"nostr ble mesh networking"}]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + assertTrue(chunks.size > 2, "Need 3+ chunks for out-of-order test") + + // Reverse the order + val reversed = chunks.reversed().toTypedArray() + val result = BleMessageChunker.joinChunks(reversed) + assertEquals(message, result) + } + + @Test + fun singleChunkForSmallMessage() { + val message = """["OK"]""" + val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 500) + assertEquals(1, chunks.size) + assertEquals(0, BleMessageChunker.chunkIndex(chunks[0])) + assertEquals(1, BleMessageChunker.totalChunks(chunks[0])) + } + + @Test + fun rejectsOversizedMessage() { + // Generate data that compresses poorly by using all 256 byte values in a + // pseudo-random pattern. A 512KB input with this pattern stays above 64KB + // after DEFLATE compression. + val size = 512 * 1024 + val sb = StringBuilder(size) + var state = 7919L // prime seed + for (i in 0 until size) { + // Simple LCG to produce varied characters + state = (state * 1103515245L + 12345L) and 0x7FFFFFFF + sb.append(('!' + (state % 94).toInt())) // printable ASCII range + } + val message = sb.toString() + assertFails { + BleMessageChunker.splitIntoChunks(message) + } + } + + @Test + fun customChunkSize() { + val message = """["EVENT",{"content":"${"a".repeat(100)}"}]""" + val smallChunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) + val largeChunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 500) + assertTrue(smallChunks.size > largeChunks.size) + + // Both should produce the same result + assertEquals( + BleMessageChunker.joinChunks(smallChunks), + BleMessageChunker.joinChunks(largeChunks), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRoleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRoleTest.kt new file mode 100644 index 000000000..3bbd7a705 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/BleRoleTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBEBle + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BleRoleTest { + @Test + fun higherUuidBecomesServer() { + val high = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF" + val low = "00000000-0000-0000-0000-000000000000" + assertEquals(BleRole.SERVER, assignRole(high, low)) + } + + @Test + fun lowerUuidBecomesClient() { + val high = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF" + val low = "00000000-0000-0000-0000-000000000000" + assertEquals(BleRole.CLIENT, assignRole(low, high)) + } + + @Test + fun rolesAreComplementary() { + val a = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + val b = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" + + val roleA = assignRole(a, b) + val roleB = assignRole(b, a) + + assertEquals(BleRole.CLIENT, roleA) + assertEquals(BleRole.SERVER, roleB) + } + + @Test + fun permanentServerUuid() { + val serverUuid = BleConfig.PERMANENT_SERVER_UUID + val anyOther = "12345678-1234-1234-1234-123456789012" + assertEquals(BleRole.SERVER, assignRole(serverUuid, anyOther)) + } + + @Test + fun permanentClientUuid() { + val clientUuid = BleConfig.PERMANENT_CLIENT_UUID + val anyOther = "12345678-1234-1234-1234-123456789012" + assertEquals(BleRole.CLIENT, assignRole(clientUuid, anyOther)) + } + + @Test + fun caseInsensitiveComparison() { + val lower = "abcdef12-3456-7890-abcd-ef1234567890" + val upper = "ABCDEF12-3456-7890-ABCD-EF1234567890" + val other = "00000000-0000-0000-0000-000000000001" + + assertEquals(assignRole(lower, other), assignRole(upper, other)) + } + + @Test + fun equalUuidsDefaultToClient() { + val uuid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" + assertEquals(BleRole.CLIENT, assignRole(uuid, uuid)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeflateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeflateTest.kt new file mode 100644 index 000000000..a1c1784c1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeflateTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertTrue + +class DeflateTest { + @Test + fun roundTripSimpleBytes() { + val original = "Hello, NIP-BE!".encodeToByteArray() + val result = Deflate.decompress(Deflate.compress(original)) + assertContentEquals(original, result) + } + + @Test + fun roundTripEmptyBytes() { + val original = ByteArray(0) + val result = Deflate.decompress(Deflate.compress(original)) + assertContentEquals(original, result) + } + + @Test + fun roundTripLargeRepetitiveData() { + val original = "abcdefghij".repeat(1000).encodeToByteArray() + val compressed = Deflate.compress(original) + val result = Deflate.decompress(compressed) + assertContentEquals(original, result) + assertTrue(compressed.size < original.size) + } + + @Test + fun roundTripNostrJsonMessage() { + val original = """["EVENT",{"id":"abc","pubkey":"def","created_at":1700000000,"kind":1,"tags":[],"content":"hello","sig":"ghi"}]""".encodeToByteArray() + val result = Deflate.decompress(Deflate.compress(original)) + assertContentEquals(original, result) + } + + @Test + fun compressedOutputIsRawDeflate() { + val compressed = Deflate.compress("test".encodeToByteArray()) + // Raw DEFLATE does NOT start with gzip magic bytes (0x1F 0x8B) + if (compressed.size >= 2) { + val isGzip = compressed[0] == 0x1F.toByte() && compressed[1] == 0x8B.toByte() + assertTrue(!isGzip, "Expected raw DEFLATE, not gzip format") + } + } + + @Test + fun roundTripUnicodeContent() { + val original = "こんにちは世界 🌍 Nostr BLE".encodeToByteArray() + val result = Deflate.decompress(Deflate.compress(original)) + assertContentEquals(original, result) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Deflate.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Deflate.jvmAndroid.kt new file mode 100644 index 000000000..c47f2ce0a --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Deflate.jvmAndroid.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import java.io.ByteArrayOutputStream +import java.util.zip.Deflater +import java.util.zip.Inflater + +actual object Deflate { + actual fun compress(input: ByteArray): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) + try { + deflater.setInput(input) + deflater.finish() + + val bos = ByteArrayOutputStream(input.size) + val buffer = ByteArray(4096) + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + bos.write(buffer, 0, count) + } + return bos.toByteArray() + } finally { + deflater.end() + } + } + + actual fun decompress(input: ByteArray): ByteArray { + val inflater = Inflater(true) + try { + inflater.setInput(input) + + val bos = ByteArrayOutputStream(input.size * 2) + val buffer = ByteArray(4096) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count == 0 && inflater.needsInput()) break + bos.write(buffer, 0, count) + } + return bos.toByteArray() + } finally { + inflater.end() + } + } +} diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.linux.kt new file mode 100644 index 000000000..8480eef05 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Deflate.linux.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2 +import platform.zlib.z_stream + +@OptIn(ExperimentalForeignApi::class) +actual object Deflate { + actual fun compress(input: ByteArray): ByteArray = + memScoped { + val stream = alloc() + + deflateInit2( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + -15, + 8, + Z_DEFAULT_STRATEGY, + ).let { check(it == Z_OK) { "deflateInit2 failed: $it" } } + + val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt() + val output = ByteArray(maxSize) + + val written = + input.usePinned { pinIn -> + output.usePinned { pinOut -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } + stream.avail_out = maxSize.toUInt() + + deflate(stream.ptr, Z_FINISH) + .let { check(it == Z_STREAM_END) { "deflate failed: $it" } } + + maxSize - stream.avail_out.toInt() + } + } + + deflateEnd(stream.ptr) + output.copyOf(written) + } + + actual fun decompress(input: ByteArray): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + val chunks = ArrayList() + val chunkSize = maxOf(input.size * 4, 4096) + + memScoped { + val stream = alloc() + + inflateInit2(stream.ptr, -15) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + input.usePinned { pinIn -> + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } + stream.avail_in = input.size.toUInt() + + var status: Int = Z_OK + do { + val chunk = ByteArray(chunkSize) + chunk.usePinned { pinOut -> + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = chunkSize.toUInt() + status = inflate(stream.ptr, Z_NO_FLUSH) + val produced = chunkSize - stream.avail_out.toInt() + if (produced > 0) chunks.add(chunk.copyOf(produced)) + } + } while (status == Z_OK) + + check(status == Z_STREAM_END) { "inflate failed: $status" } + } + + inflateEnd(stream.ptr) + } + + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var pos = 0 + chunks.forEach { chunk -> + chunk.copyInto(result, pos) + pos += chunk.size + } + return result + } +}