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()
}
}
}