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
This commit is contained in:
Claude
2026-03-30 01:58:26 +00:00
parent 207fc6beba
commit fb78c1a4c4
18 changed files with 2379 additions and 0 deletions
@@ -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<String, BluetoothGatt>()
private val connectedDevices = mutableMapOf<String, BluetoothDevice>()
private val deviceUuidMap = mutableMapOf<String, String>()
private val peerMap = mutableMapOf<String, BlePeer>()
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()
}
}
}
@@ -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<z_stream>()
// 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<ByteArray>()
val chunkSize = maxOf(input.size * 4, 4096)
memScoped {
val stream = alloc<z_stream>()
// 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
}
}
@@ -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<ByteArray>()
/**
* 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()
}
}
@@ -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
}
@@ -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<String, BleNostrClient>()
private val servers = mutableMapOf<String, BleNostrServer>()
private val knownPeers = mutableSetOf<String>()
private val sentEventIds = mutableMapOf<String, MutableSet<String>>()
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<BlePeer> {
val result = mutableListOf<BlePeer>()
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,
) {}
}
@@ -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<ByteArray> {
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<ByteArray>): 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<ByteArray>): Boolean {
if (chunks.isEmpty()) return false
val expected = totalChunks(chunks.first())
return chunks.size >= expected
}
}
@@ -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<Array<ByteArray>>()
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<ByteArray>? = 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}")
}
}
}
@@ -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<Array<ByteArray>>()
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<ByteArray>? = 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,
)
}
@@ -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,
)
@@ -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
}
@@ -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,
)
}
@@ -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
}
@@ -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)
}
}
@@ -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),
)
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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<z_stream>()
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<ByteArray>()
val chunkSize = maxOf(input.size * 4, 4096)
memScoped {
val stream = alloc<z_stream>()
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
}
}