feat(nostr): add mesh serializer + gateway listener helpers (initial)

This commit is contained in:
Andrés Villagrán 2026-01-14 22:07:43 -03:00
parent e769a03634
commit 260040fea2
2 changed files with 284 additions and 0 deletions

View File

@ -0,0 +1,148 @@
package com.bitchat.android.nostr
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Nostr Mesh Gateway helpers
* - publishToMeshOrRelay(event): decide ruta según conectividad
* - MeshListener snippet: detecta TYPE_NOSTR_RELAY_REQUEST paquetes y publica en relays
*/
object NostrMeshGateway {
private const val TAG = "NostrMeshGateway"
/**
* Decide publicar directamente a relays si hay conectividad, o enviar por Mesh si no
*/
fun publishToMeshOrRelay(context: Context, event: NostrEvent, meshSender: (ByteArray) -> Unit) {
if (hasInternetConnection(context)) {
Log.d(TAG, "Device online - publishing event to relays directly")
// Ensure event meets PoW requirement before sending
val minDifficulty = com.bitchat.android.nostr.NostrProofOfWork.estimateWork(0) // placeholder: use PoW settings
// Send immediately via NostrRelayManager
try {
NostrRelayManager.getInstance(context).sendEvent(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to relays: ${e.message}")
// Fallback: send over mesh
try {
val payload = NostrMeshSerializer.serializeEventForMesh(event)
meshSender(payload)
} catch (ex: Exception) {
Log.e(TAG, "Failed to serialize event for mesh fallback: ${ex.message}")
}
}
} else {
Log.d(TAG, "No internet - sending event over mesh")
// When offline: ensure PoW is present (NIP-13). Mining should be done before calling this ideally.
// If not mined, we could kick off mining synchronously (dangerous for battery) or reject.
CoroutineScope(Dispatchers.Default).launch {
try {
// If event lacks nonce, attempt a light PoW using user-preferred difficulty
val prefDifficulty = NostrProofOfWork.estimateWork(8).toIntOrNullSafe() ?: 8
} catch (ignored: Exception) {}
}
try {
val payload = NostrMeshSerializer.serializeEventForMesh(event)
meshSender(payload)
} catch (e: Exception) {
Log.e(TAG, "Failed to serialize event for mesh: ${e.message}")
}
}
}
/**
* Simple connectivity check helper
*/
fun hasInternetConnection(context: Context): Boolean {
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val nw = cm.activeNetwork ?: return false
val actNw = cm.getNetworkCapabilities(nw) ?: return false
return actNw.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} catch (e: Exception) {
return false
}
}
/**
* Mesh listener snippet to be integrated in the mesh receive path.
* When a packet with header TYPE_NOSTR_RELAY_REQUEST is received and device has internet,
* it will attempt to deserialize, validate signature & PoW, then publish to relays.
*/
fun meshPacketReceived(context: Context, packet: ByteArray, ackSender: ((ByteArray) -> Unit)? = null) {
CoroutineScope(Dispatchers.IO).launch {
try {
// Quick header check
if (packet.isEmpty()) return@launch
val header = packet[0]
if (header != NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST && header != NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT) {
return@launch
}
if (!hasInternetConnection(context)) {
Log.d(TAG, "Received Nostr mesh packet but device is offline - skipping relay publish")
return@launch
}
val jsonString = NostrMeshSerializer.deserializeEventFromMesh(packet) ?: run {
Log.w(TAG, "Failed to deserialize Nostr event from mesh packet")
return@launch
}
// Parse event
val event = NostrEvent.fromJsonString(jsonString)
if (event == null) {
Log.w(TAG, "Failed to parse NostrEvent JSON")
return@launch
}
// Validate signature (NIP-01)
if (!event.isValidSignature()) {
Log.w(TAG, "Invalid Nostr signature - discarding event id=${event.id.take(16)}...")
return@launch
}
// Validate PoW (NIP-13) - require at least low difficulty (8 bits)
val requiredDifficulty = 8
if (!NostrProofOfWork.validateDifficulty(event, requiredDifficulty)) {
Log.w(TAG, "Event failed PoW validation - discarding id=${event.id.take(16)}...")
return@launch
}
// Publish to relays via NostrRelayManager
try {
NostrRelayManager.getInstance(context).sendEvent(event)
Log.i(TAG, "Published mesh-origin event to relays id=${event.id.take(16)}...")
// Optionally send acknowledgement back over mesh
ackSender?.let { sender ->
try {
val ack = "DELIVERED:${event.id}".toByteArray(Charsets.UTF_8)
sender(ack)
} catch (e: Exception) {
Log.w(TAG, "Failed to send ack over mesh: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to publish event to relays: ${e.message}")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling mesh packet: ${e.message}")
}
}
}
// helper extension to attempt convert Long to Int safely
private fun Long.toIntOrNullSafe(): Int? {
return if (this in Int.MIN_VALUE..Int.MAX_VALUE) this.toInt() else null
}
}

View File

@ -0,0 +1,136 @@
package com.bitchat.android.nostr
import android.util.Log
import com.bitchat.android.protocol.CompressionUtil
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPOutputStream
/**
* NostrMeshSerializer
* - Serializa eventos Nostr a payloads optimizados para transporte sobre Bluetooth Mesh
* - Aplica compresión (preferencia: raw deflate via CompressionUtil, fallback GZIP)
* - Añade un header simple para identificar paquetes tipo TYPE_NOSTR_RELAY_REQUEST
*
* Formato de paquete (simple):
* [HEADER (1 byte)] [ORIG_SIZE (4 bytes, BE)] [BODY (bytes...)]
* HEADER: 0x7E = TYPE_NOSTR_RELAY_REQUEST, 0x00 = uncompressed body
*/
object NostrMeshSerializer {
private const val TAG = "NostrMeshSerializer"
// Header identifiers
const val TYPE_NOSTR_RELAY_REQUEST: Byte = 0x7E
const val TYPE_NOSTR_PLAINTEXT: Byte = 0x00
/**
* Serializa y comprime un evento Nostr listo para ser enviado sobre la Mesh
* - Devuelve el payload listo para enviar por BLE
*/
fun serializeEventForMesh(event: NostrEvent): ByteArray {
val json = event.toJsonString()
val bytes = json.toByteArray(Charsets.UTF_8)
// Preferir compresión compatible con el proyecto (CompressionUtil -> raw deflate)
try {
val compressed = CompressionUtil.compress(bytes)
if (compressed != null) {
Log.d(TAG, "Using raw deflate compression: ${compressed.size} < ${bytes.size}")
return buildPacket(TYPE_NOSTR_RELAY_REQUEST, bytes.size, compressed)
}
} catch (e: Exception) {
Log.w(TAG, "CompressionUtil.compress failed: ${e.message}")
}
// Fallback: try GZIP if CompressionUtil didn't compress
try {
val gzipped = gzip(bytes)
if (gzipped.size < bytes.size) {
Log.d(TAG, "Using GZIP compression as fallback: ${gzipped.size} < ${bytes.size}")
return buildPacket(TYPE_NOSTR_RELAY_REQUEST, bytes.size, gzipped)
}
} catch (e: Exception) {
Log.w(TAG, "GZIP fallback failed: ${e.message}")
}
// No compression beneficial -> send plaintext but still mark type
return buildPacket(TYPE_NOSTR_PLAINTEXT, bytes.size, bytes)
}
/**
* Reconstruye el evento Nostr desde el payload Mesh
* - El caller decide si debe intentar descompress o validar
*/
fun deserializeEventFromMesh(payload: ByteArray): String? {
if (payload.isEmpty()) return null
val header = payload[0]
if (header != TYPE_NOSTR_RELAY_REQUEST && header != TYPE_NOSTR_PLAINTEXT) {
Log.w(TAG, "Unknown packet header: $header")
return null
}
if (payload.size < 5) {
Log.w(TAG, "Payload too small to contain original size")
return null
}
val origSize = ((payload[1].toInt() and 0xFF) shl 24) or
((payload[2].toInt() and 0xFF) shl 16) or
((payload[3].toInt() and 0xFF) shl 8) or
(payload[4].toInt() and 0xFF)
val body = payload.copyOfRange(5, payload.size)
return when (header) {
TYPE_NOSTR_RELAY_REQUEST -> {
// Try raw deflate first (CompressionUtil expects raw deflate)
CompressionUtil.decompress(body, origSize)?.let { bytes ->
return String(bytes, Charsets.UTF_8)
}
// Fallback try GZIP
try {
val inflated = tryGzipDecompress(body)
if (inflated != null) return String(inflated, Charsets.UTF_8)
} catch (e: Exception) {
Log.w(TAG, "GZIP fallback failed during decompress: ${e.message}")
}
Log.w(TAG, "Failed to decompress body for Nostr payload")
null
}
TYPE_NOSTR_PLAINTEXT -> {
// Plaintext body
String(body, Charsets.UTF_8)
}
else -> null
}
}
private fun buildPacket(header: Byte, originalSize: Int, body: ByteArray): ByteArray {
val out = ByteArrayOutputStream(5 + body.size)
out.write(byteArrayOf(header))
// Original size big-endian 4 bytes
out.write((originalSize ushr 24) and 0xFF)
out.write((originalSize ushr 16) and 0xFF)
out.write((originalSize ushr 8) and 0xFF)
out.write((originalSize) and 0xFF)
out.write(body)
return out.toByteArray()
}
private fun gzip(input: ByteArray): ByteArray {
val baos = ByteArrayOutputStream()
GZIPOutputStream(baos).use { it.write(input) }
return baos.toByteArray()
}
private fun tryGzipDecompress(input: ByteArray): ByteArray? {
return try {
val inflater = java.util.zip.GZIPInputStream(input.inputStream())
inflater.readBytes()
} catch (e: Exception) {
null
}
}
}