From 260040fea205e0fb0c90c58dcb864707af45b86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Wed, 14 Jan 2026 22:07:43 -0300 Subject: [PATCH] feat(nostr): add mesh serializer + gateway listener helpers (initial) --- .../bitchat/android/nostr/NostrMeshGateway.kt | 148 ++++++++++++++++++ .../android/nostr/NostrMeshSerializer.kt | 136 ++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt new file mode 100644 index 00000000..7679c050 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt new file mode 100644 index 00000000..6ba18283 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt @@ -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 + } + } +}