mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
feat(nostr): add mesh serializer + gateway listener helpers (initial)
This commit is contained in:
parent
e769a03634
commit
260040fea2
148
app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt
Normal file
148
app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user