Merge pull request #2022 from vitorpamplona/claude/nostr-ble-messaging-i2rrJ
Add NIP-BE Bluetooth Low Energy mesh networking support
This commit is contained in:
+478
@@ -0,0 +1,478 @@
|
|||||||
|
/*
|
||||||
|
* 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.transport
|
||||||
|
|
||||||
|
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.nipBEBle.AndroidBleTransportContract
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BleConfig
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BlePeer
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BleRole
|
||||||
|
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 mesh = BleNostrMesh(AndroidBleTransport(context))
|
||||||
|
* mesh.onEvent { event, peer -> saveEvent(event) }
|
||||||
|
* mesh.start()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class AndroidBleTransport(
|
||||||
|
private val context: Context,
|
||||||
|
override val deviceUuid: String = UUID.randomUUID().toString().uppercase(),
|
||||||
|
) : BleTransport,
|
||||||
|
AndroidBleTransportContract {
|
||||||
|
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 setTransportListener(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_HIGH)
|
||||||
|
.setTimeout(0)
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||||
|
|
||||||
|
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.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
|
||||||
|
gatt.requestMtu(BleConfig.DEFAULT_MTU)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
// Discover services after MTU negotiation (matching samiz flow)
|
||||||
|
gatt.discoverServices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,59 @@
|
|||||||
|
/*
|
||||||
|
* 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 payload size per chunk in bytes (excluding 2-byte overhead).
|
||||||
|
* Each transmitted chunk is `DEFAULT_CHUNK_SIZE + 2` bytes on the wire.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_CHUNK_SIZE = 500
|
||||||
|
|
||||||
|
/** Default MTU to request during BLE connection. */
|
||||||
|
const val DEFAULT_MTU = 512
|
||||||
|
|
||||||
|
/** Minimum MTU for BLE 4.2. */
|
||||||
|
const val MIN_MTU_BLE_4_2 = 20
|
||||||
|
|
||||||
|
/** Default MTU for BLE 5.x. */
|
||||||
|
const val DEFAULT_MTU_BLE_5 = 256
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 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.single.IRelayClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.relay.BleMeshListener
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.relay.BleMeshManager
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.relay.BleNostrServer
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransportListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easy-to-use facade for NIP-BE Nostr BLE mesh networking.
|
||||||
|
*
|
||||||
|
* Wraps all the BLE protocol complexity behind a simple lambda-based API.
|
||||||
|
* Handles transport wiring, role assignment, message chunking, and
|
||||||
|
* event routing automatically.
|
||||||
|
*
|
||||||
|
* ## Quick start (Android)
|
||||||
|
* ```kotlin
|
||||||
|
* val mesh = BleNostrMesh(AndroidBleTransport(context))
|
||||||
|
*
|
||||||
|
* mesh.onEvent { event, peer ->
|
||||||
|
* Log.d("BLE", "Got ${event.kind} from ${peer.deviceUuid}")
|
||||||
|
* saveToDatabase(event)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* mesh.onPeerConnected { peer ->
|
||||||
|
* Log.d("BLE", "Peer connected: ${peer.deviceUuid}")
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* mesh.start()
|
||||||
|
*
|
||||||
|
* // Broadcast a signed event to all connected peers
|
||||||
|
* mesh.broadcast(myEvent)
|
||||||
|
*
|
||||||
|
* // Clean up
|
||||||
|
* mesh.stop()
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Advanced usage
|
||||||
|
* For relay-level protocol access (REQ, filters, subscriptions),
|
||||||
|
* use [getRelayClient] to get an [IRelayClient] for a specific peer,
|
||||||
|
* or set [onMessage] / [onCommand] for raw protocol callbacks.
|
||||||
|
*/
|
||||||
|
class BleNostrMesh(
|
||||||
|
transport: BleTransport,
|
||||||
|
) {
|
||||||
|
/** Called when a Nostr event is received from any peer. */
|
||||||
|
var onEvent: ((event: Event, fromPeer: BlePeer) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called when a new peer connects. */
|
||||||
|
var onPeerConnected: ((peer: BlePeer) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called when a peer disconnects. */
|
||||||
|
var onPeerDisconnected: ((peer: BlePeer) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called when a new peer is discovered (before connection). */
|
||||||
|
var onPeerDiscovered: ((peer: BlePeer) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called on BLE errors. */
|
||||||
|
var onError: ((error: String) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called for raw Nostr messages (when acting as client). */
|
||||||
|
var onMessage: ((relay: IRelayClient, msg: Message) -> Unit)? = null
|
||||||
|
|
||||||
|
/** Called for raw Nostr commands (when acting as server). */
|
||||||
|
var onCommand: ((server: BleNostrServer, cmd: Command) -> Unit)? = null
|
||||||
|
|
||||||
|
private val manager =
|
||||||
|
BleMeshManager(
|
||||||
|
transport = transport,
|
||||||
|
listener =
|
||||||
|
object : BleMeshListener {
|
||||||
|
override fun onEventReceived(
|
||||||
|
event: Event,
|
||||||
|
fromPeer: BlePeer,
|
||||||
|
) {
|
||||||
|
onEvent?.invoke(event, fromPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPeerConnected(
|
||||||
|
peer: BlePeer,
|
||||||
|
role: BleRole,
|
||||||
|
) {
|
||||||
|
this@BleNostrMesh.onPeerConnected?.invoke(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPeerDisconnected(peer: BlePeer) {
|
||||||
|
this@BleNostrMesh.onPeerDisconnected?.invoke(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPeerDiscovered(peer: BlePeer) {
|
||||||
|
this@BleNostrMesh.onPeerDiscovered?.invoke(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(
|
||||||
|
relay: IRelayClient,
|
||||||
|
msgStr: String,
|
||||||
|
msg: Message,
|
||||||
|
) {
|
||||||
|
onMessage?.invoke(relay, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCommandReceived(
|
||||||
|
server: BleNostrServer,
|
||||||
|
cmdStr: String,
|
||||||
|
cmd: Command,
|
||||||
|
) {
|
||||||
|
onCommand?.invoke(server, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(
|
||||||
|
peer: BlePeer?,
|
||||||
|
error: String,
|
||||||
|
) {
|
||||||
|
this@BleNostrMesh.onError?.invoke(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (transport is AndroidBleTransportContract) {
|
||||||
|
transport.setTransportListener(manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts BLE advertising and scanning for peers. */
|
||||||
|
fun start() = manager.start()
|
||||||
|
|
||||||
|
/** Stops all BLE operations and disconnects from all peers. */
|
||||||
|
fun stop() = manager.stop()
|
||||||
|
|
||||||
|
/** Broadcasts an event to all connected peers. */
|
||||||
|
fun broadcast(event: Event) = manager.broadcastEvent(event)
|
||||||
|
|
||||||
|
/** Returns all currently connected peers. */
|
||||||
|
fun connectedPeers(): List<BlePeer> = manager.connectedPeers()
|
||||||
|
|
||||||
|
/** Returns the [IRelayClient] for a specific peer (for advanced protocol use). */
|
||||||
|
fun getRelayClient(peerUuid: String): IRelayClient? = manager.getRelayClient(peerUuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for Android BLE transports that support automatic listener wiring.
|
||||||
|
* Implemented by [AndroidBleTransport] so that [BleNostrMesh] can self-configure.
|
||||||
|
*/
|
||||||
|
interface AndroidBleTransportContract {
|
||||||
|
fun setTransportListener(listener: BleTransportListener)
|
||||||
|
}
|
||||||
@@ -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,145 @@
|
|||||||
|
# NIP-BE: Nostr BLE Communications Protocol
|
||||||
|
|
||||||
|
Kotlin Multiplatform implementation of [NIP-BE](https://github.com/nostr-protocol/nips/blob/master/BE.md)
|
||||||
|
for peer-to-peer Nostr event synchronization over Bluetooth Low Energy.
|
||||||
|
|
||||||
|
Compatible with [KoalaSat/samiz](https://github.com/KoalaSat/samiz).
|
||||||
|
|
||||||
|
## Quick Start (Android)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 1. Create the mesh (auto-wires transport)
|
||||||
|
val mesh = BleNostrMesh(AndroidBleTransport(context))
|
||||||
|
|
||||||
|
// 2. Listen for events from nearby peers
|
||||||
|
mesh.onEvent { event, peer ->
|
||||||
|
Log.d("BLE", "Received kind ${event.kind} from ${peer.deviceUuid}")
|
||||||
|
myDatabase.save(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Optional: track peer connectivity
|
||||||
|
mesh.onPeerConnected { peer -> updatePeerCount(mesh.connectedPeers().size) }
|
||||||
|
mesh.onPeerDisconnected { peer -> updatePeerCount(mesh.connectedPeers().size) }
|
||||||
|
mesh.onError { error -> Log.e("BLE", error) }
|
||||||
|
|
||||||
|
// 4. Start (begins advertising + scanning)
|
||||||
|
mesh.start()
|
||||||
|
|
||||||
|
// 5. Broadcast events to all connected peers
|
||||||
|
mesh.broadcast(mySignedEvent)
|
||||||
|
|
||||||
|
// 6. Stop when done
|
||||||
|
mesh.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Android Permissions
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Request these at runtime before calling `mesh.start()`.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When two devices running NIP-BE discover each other:
|
||||||
|
|
||||||
|
1. **Discovery** - Both devices advertise and scan for the Nostr BLE service UUID
|
||||||
|
2. **Role assignment** - The device with the higher UUID becomes the GATT Server (relay),
|
||||||
|
the other becomes the GATT Client
|
||||||
|
3. **Connection** - The client connects to the server's GATT service
|
||||||
|
4. **Messaging** - NIP-01 JSON messages are DEFLATE-compressed, split into chunks,
|
||||||
|
and exchanged via BLE characteristics
|
||||||
|
5. **Event spreading** - New events are automatically forwarded to all connected peers
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nipBEBle/
|
||||||
|
BleNostrMesh.kt # Easy-to-use facade (start here)
|
||||||
|
BleConfig.kt # NIP-BE constants (UUIDs, sizes)
|
||||||
|
BlePeer.kt # Peer data model
|
||||||
|
BleRole.kt # Role assignment (SERVER/CLIENT)
|
||||||
|
|
||||||
|
protocol/ # Wire format
|
||||||
|
BleMessageChunker.kt # DEFLATE compression + chunk splitting/joining
|
||||||
|
BleChunkAssembler.kt # Reassembles incoming chunks into messages
|
||||||
|
|
||||||
|
transport/ # Platform BLE abstraction
|
||||||
|
BleTransport.kt # Interface for BLE operations
|
||||||
|
AndroidBleTransport.kt # Android implementation (androidMain)
|
||||||
|
|
||||||
|
relay/ # Nostr relay protocol over BLE
|
||||||
|
BleNostrClient.kt # Client side (implements IRelayClient)
|
||||||
|
BleNostrServer.kt # Server side (receives commands, sends messages)
|
||||||
|
BleMeshManager.kt # Orchestrates discovery, connections, broadcasting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Use a BLE peer as a standard relay client
|
||||||
|
|
||||||
|
`BleNostrClient` implements `IRelayClient`, so you can use BLE peers alongside
|
||||||
|
WebSocket relays in the existing subscription infrastructure:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val relayClient = mesh.getRelayClient(peer.deviceUuid)
|
||||||
|
relayClient?.sendIfConnected(ReqCmd("sub1", listOf(filter)))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access raw protocol messages
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
mesh.onMessage { relay, msg ->
|
||||||
|
// Nostr messages received when acting as client (EVENT, EOSE, OK, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.onCommand { server, cmd ->
|
||||||
|
// Nostr commands received when acting as server (REQ, EVENT, CLOSE, etc.)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implement a custom BLE transport
|
||||||
|
|
||||||
|
For platforms without a built-in transport, implement `BleTransport`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class MyPlatformBleTransport : BleTransport {
|
||||||
|
override val deviceUuid = UUID.randomUUID().toString()
|
||||||
|
override fun startAdvertising() { /* ... */ }
|
||||||
|
override fun startScanning() { /* ... */ }
|
||||||
|
// ... other methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use BleMeshManager directly
|
||||||
|
|
||||||
|
For full control over the relay protocol layer:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val transport = AndroidBleTransport(context)
|
||||||
|
val manager = BleMeshManager(transport, object : BleMeshListener {
|
||||||
|
override fun onEventReceived(event: Event, fromPeer: BlePeer) { /* ... */ }
|
||||||
|
})
|
||||||
|
transport.setListener(manager)
|
||||||
|
manager.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wire Protocol
|
||||||
|
|
||||||
|
Messages follow [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) JSON format,
|
||||||
|
compressed with DEFLATE and split into chunks:
|
||||||
|
|
||||||
|
```
|
||||||
|
[chunk index (1 byte)][payload (up to 500 bytes)][total chunks (1 byte)]
|
||||||
|
```
|
||||||
|
|
||||||
|
| GATT Characteristic | UUID | Direction |
|
||||||
|
|---------------------|------|-----------|
|
||||||
|
| Write | `87654321-0000-1000-8000-00805f9b34fb` | Client -> Server |
|
||||||
|
| Read (Notify) | `12345678-0000-1000-8000-00805f9b34fb` | Server -> Client |
|
||||||
|
|
||||||
|
Service UUID: `0000180f-0000-1000-8000-00805f9b34fb`
|
||||||
+56
@@ -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.protocol
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* 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.protocol
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BleConfig
|
||||||
|
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:
|
||||||
|
* ```
|
||||||
|
* [chunk index (1 byte)][payload (up to chunkSize bytes)][total chunks (1 byte)]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Each chunk carries a 1-byte index prefix and a 1-byte total count suffix
|
||||||
|
* (2 bytes overhead), matching the reference implementation in KoalaSat/samiz.
|
||||||
|
*/
|
||||||
|
object BleMessageChunker {
|
||||||
|
/** Per-chunk overhead: 1 byte index + 1 byte total count. */
|
||||||
|
const val CHUNK_OVERHEAD = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses and splits a NIP-01 JSON message into BLE-sized chunks.
|
||||||
|
*
|
||||||
|
* @param message The JSON message string (e.g., `["EVENT", {...}]`).
|
||||||
|
* @param chunkSize The maximum payload size per chunk in bytes (default 500).
|
||||||
|
* The actual transmitted chunk size is `payload + 2` (index + total count).
|
||||||
|
* @return Array of byte arrays, each representing one chunk.
|
||||||
|
* @throws IllegalArgumentException if compressed message exceeds 64KB.
|
||||||
|
*/
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
require(chunkSize > 0) { "chunkSize must be positive" }
|
||||||
|
|
||||||
|
val numChunks = (compressed.size + chunkSize - 1) / chunkSize
|
||||||
|
|
||||||
|
require(numChunks <= 255) {
|
||||||
|
"Message requires $numChunks chunks but maximum is 255"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(numChunks) { i ->
|
||||||
|
val start = i * chunkSize
|
||||||
|
val end = minOf((i + 1) * chunkSize, compressed.size)
|
||||||
|
val payload = compressed.copyOfRange(start, end)
|
||||||
|
|
||||||
|
// [1-byte index][payload][1-byte total]
|
||||||
|
val chunk = ByteArray(payload.size + CHUNK_OVERHEAD)
|
||||||
|
chunk[0] = i.toByte()
|
||||||
|
payload.copyInto(chunk, 1)
|
||||||
|
chunk[chunk.size - 1] = numChunks.toByte()
|
||||||
|
|
||||||
|
chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassembles and decompresses chunks back into a NIP-01 JSON message.
|
||||||
|
*
|
||||||
|
* Chunks can arrive in any order; they are sorted by their index byte.
|
||||||
|
*
|
||||||
|
* @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(1, 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 1-byte chunk index from a chunk.
|
||||||
|
*/
|
||||||
|
fun chunkIndex(chunk: ByteArray): Int = chunk[0].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
|
||||||
|
}
|
||||||
|
}
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
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.nipBEBle.BlePeer
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BleRole
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.assignRole
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransportListener
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
// ATT overhead is 3 bytes, chunk framing is 2 bytes (index + total count)
|
||||||
|
val chunkSize = mtu - 3 - BleMessageChunker.CHUNK_OVERHEAD
|
||||||
|
clients[peer.deviceUuid]?.chunkSize = chunkSize
|
||||||
|
servers[peer.deviceUuid]?.chunkSize = chunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
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.nipBEBle.BleConfig
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BlePeer
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
/*
|
||||||
|
* 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.relay
|
||||||
|
|
||||||
|
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.nipBEBle.BleConfig
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BlePeer
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.protocol.BleChunkAssembler
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.protocol.BleMessageChunker
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.transport.BleTransport
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* 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.transport
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nipBEBle.BlePeer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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.protocol
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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.protocol
|
||||||
|
|
||||||
|
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 chunkSize = 20
|
||||||
|
val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = chunkSize)
|
||||||
|
|
||||||
|
for ((i, chunk) in chunks.withIndex()) {
|
||||||
|
// First byte is the index
|
||||||
|
assertEquals(i, BleMessageChunker.chunkIndex(chunk))
|
||||||
|
// Last byte is the total count
|
||||||
|
assertEquals(chunks.size, BleMessageChunker.totalChunks(chunk))
|
||||||
|
// Each chunk should be at most chunkSize + 2 (overhead)
|
||||||
|
assertTrue(
|
||||||
|
chunk.size <= chunkSize + BleMessageChunker.CHUNK_OVERHEAD,
|
||||||
|
"Chunk size ${chunk.size} exceeds max ${chunkSize + BleMessageChunker.CHUNK_OVERHEAD}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user