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