mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:25:22 +00:00
Nostr geohash (#276)
* first nostr build * add test file * internet access * fix relay manager * fix serialization * demo service - remove later * fix nostr * event dedupe * dedupe * ui wip * can send messages * subscription works * works * favs * works * delete chat on change * fix mentions * remove autojoin channels * styling * adjust colors * ui changes * live updates working * use local timestamp * message history in background * robust * fixes * nicknames refresh optimization * nostr service * refactor nostr * style * geohash works * centralize colors * refactoring * disable DMs for now: click on peer nickname doesnt open chat list in geohash mode * use local time * less logging * robustness * scroll nickname * adjust some text
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
/**
|
||||
* Bech32 encoding/decoding implementation for Nostr
|
||||
* Used for npub/nsec encoding
|
||||
*/
|
||||
object Bech32 {
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
||||
|
||||
/**
|
||||
* Encode data with HRP (Human Readable Part)
|
||||
*/
|
||||
fun encode(hrp: String, data: ByteArray): String {
|
||||
val values = convertBits(data, 8, 5, true).toList()
|
||||
val checksum = createChecksum(hrp, values)
|
||||
val combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map { CHARSET[it] }.joinToString("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bech32 string
|
||||
* Returns (hrp, data) pair
|
||||
*/
|
||||
fun decode(bech32String: String): Pair<String, ByteArray> {
|
||||
val separatorIndex = bech32String.lastIndexOf('1')
|
||||
require(separatorIndex >= 0) { "No separator found" }
|
||||
|
||||
val hrp = bech32String.substring(0, separatorIndex)
|
||||
val dataString = bech32String.substring(separatorIndex + 1)
|
||||
|
||||
// Validate HRP contains only ASCII
|
||||
require(hrp.all { it.code < 128 }) { "Invalid HRP characters" }
|
||||
|
||||
// Convert characters to values
|
||||
val values = dataString.map { char ->
|
||||
val index = CHARSET.indexOf(char)
|
||||
require(index >= 0) { "Invalid character: $char" }
|
||||
index
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
require(values.size >= 6) { "Data too short" }
|
||||
val payloadValues = values.dropLast(6)
|
||||
val checksum = values.takeLast(6)
|
||||
val expectedChecksum = createChecksum(hrp, payloadValues)
|
||||
|
||||
require(checksum == expectedChecksum) { "Invalid checksum" }
|
||||
|
||||
// Convert back to bytes
|
||||
val bytesInt = convertBits(payloadValues.toIntArray(), 5, 8, false)
|
||||
val bytes = bytesInt.map { it.toByte() }.toByteArray()
|
||||
return Pair(hrp, bytes)
|
||||
}
|
||||
|
||||
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
return convertBits(data.map { it.toInt() and 0xFF }.toIntArray(), fromBits, toBits, pad)
|
||||
}
|
||||
|
||||
private fun convertBits(data: IntArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Int>()
|
||||
val maxv = (1 shl toBits) - 1
|
||||
|
||||
for (value in data) {
|
||||
acc = (acc shl fromBits) or value
|
||||
bits += fromBits
|
||||
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits
|
||||
result.add((acc shr bits) and maxv)
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add((acc shl (toBits - bits)) and maxv)
|
||||
}
|
||||
|
||||
return result.toIntArray()
|
||||
}
|
||||
|
||||
private fun convertBits(data: List<Int>, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
return convertBits(data.toIntArray(), fromBits, toBits, pad)
|
||||
}
|
||||
|
||||
private fun createChecksum(hrp: String, values: List<Int>): List<Int> {
|
||||
val checksumValues = hrpExpand(hrp) + values + intArrayOf(0, 0, 0, 0, 0, 0)
|
||||
val polymod = polymod(checksumValues) xor 1
|
||||
|
||||
return (0 until 6).map { i ->
|
||||
(polymod shr (5 * (5 - i))) and 31
|
||||
}
|
||||
}
|
||||
|
||||
private fun hrpExpand(hrp: String): IntArray {
|
||||
val result = mutableListOf<Int>()
|
||||
|
||||
// High bits
|
||||
hrp.forEach { c ->
|
||||
result.add(c.code shr 5)
|
||||
}
|
||||
|
||||
// Separator
|
||||
result.add(0)
|
||||
|
||||
// Low bits
|
||||
hrp.forEach { c ->
|
||||
result.add(c.code and 31)
|
||||
}
|
||||
|
||||
return result.toIntArray()
|
||||
}
|
||||
|
||||
private fun polymod(values: IntArray): Int {
|
||||
var chk = 1
|
||||
|
||||
for (value in values) {
|
||||
val b = chk shr 25
|
||||
chk = (chk and 0x1ffffff) shl 5 xor value
|
||||
|
||||
for (i in 0 until 5) {
|
||||
if ((b shr i) and 1 == 1) {
|
||||
chk = chk xor GENERATOR[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* High-level Nostr client that manages identity, connections, and messaging
|
||||
* Provides a simple API for the rest of the application
|
||||
*/
|
||||
class NostrClient private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrClient"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrClient? = null
|
||||
|
||||
fun getInstance(context: Context): NostrClient {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrClient(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Core components
|
||||
private val relayManager = NostrRelayManager.shared
|
||||
private var currentIdentity: NostrIdentity? = null
|
||||
|
||||
// Client state
|
||||
private val _isInitialized = MutableLiveData<Boolean>()
|
||||
val isInitialized: LiveData<Boolean> = _isInitialized
|
||||
|
||||
private val _currentNpub = MutableLiveData<String>()
|
||||
val currentNpub: LiveData<String> = _currentNpub
|
||||
|
||||
// Message processing
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
init {
|
||||
Log.d(TAG, "Initializing Nostr client")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Nostr client with identity and relay connections
|
||||
*/
|
||||
fun initialize() {
|
||||
scope.launch {
|
||||
try {
|
||||
// Load or create identity
|
||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
|
||||
if (currentIdentity != null) {
|
||||
_currentNpub.postValue(currentIdentity!!.npub)
|
||||
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
|
||||
|
||||
// Connect to relays
|
||||
relayManager.connect()
|
||||
|
||||
_isInitialized.postValue(true)
|
||||
Log.i(TAG, "✅ Nostr client initialized successfully")
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to load/create Nostr identity")
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the client and disconnect from relays
|
||||
*/
|
||||
fun shutdown() {
|
||||
Log.d(TAG, "Shutting down Nostr client")
|
||||
relayManager.disconnect()
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a private message using NIP-17
|
||||
*/
|
||||
fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientNpub: String,
|
||||
onSuccess: (() -> Unit)? = null,
|
||||
onError: ((String) -> Unit)? = null
|
||||
) {
|
||||
val identity = currentIdentity
|
||||
if (identity == null) {
|
||||
onError?.invoke("Nostr client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Decode recipient npub to hex pubkey
|
||||
val (hrp, pubkeyBytes) = Bech32.decode(recipientNpub)
|
||||
if (hrp != "npub") {
|
||||
onError?.invoke("Invalid npub format")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val recipientPubkeyHex = pubkeyBytes.toHexString()
|
||||
|
||||
// Create and send gift wrap
|
||||
val giftWrap = NostrProtocol.createPrivateMessage(
|
||||
content = content,
|
||||
recipientPubkey = recipientPubkeyHex,
|
||||
senderIdentity = identity
|
||||
)
|
||||
|
||||
// Track this as a pending gift wrap for logging
|
||||
NostrRelayManager.registerPendingGiftWrap(giftWrap.id)
|
||||
|
||||
relayManager.sendEvent(giftWrap)
|
||||
|
||||
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
|
||||
onSuccess?.invoke()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send private message: ${e.message}")
|
||||
onError?.invoke("Failed to send message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to private messages for current identity
|
||||
*/
|
||||
fun subscribeToPrivateMessages(handler: (content: String, senderNpub: String, timestamp: Int) -> Unit) {
|
||||
val identity = currentIdentity
|
||||
if (identity == null) {
|
||||
Log.e(TAG, "Cannot subscribe to private messages: client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
val filter = NostrFilter.giftWrapsFor(
|
||||
pubkey = identity.publicKeyHex,
|
||||
since = System.currentTimeMillis() - 86400000L // Last 24 hours
|
||||
)
|
||||
|
||||
relayManager.subscribe(filter, "private-messages", { giftWrap ->
|
||||
scope.launch {
|
||||
handlePrivateMessage(giftWrap, handler)
|
||||
}
|
||||
})
|
||||
|
||||
Log.i(TAG, "🔑 Subscribed to private messages for: ${identity.getShortNpub()}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a public message to a geohash channel
|
||||
*/
|
||||
fun sendGeohashMessage(
|
||||
content: String,
|
||||
geohash: String,
|
||||
nickname: String? = null,
|
||||
onSuccess: (() -> Unit)? = null,
|
||||
onError: ((String) -> Unit)? = null
|
||||
) {
|
||||
scope.launch {
|
||||
try {
|
||||
// Derive geohash-specific identity
|
||||
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||
|
||||
// Create ephemeral event
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(
|
||||
content = content,
|
||||
geohash = geohash,
|
||||
senderIdentity = geohashIdentity,
|
||||
nickname = nickname
|
||||
)
|
||||
|
||||
relayManager.sendEvent(event)
|
||||
|
||||
Log.i(TAG, "📤 Sent geohash message to #$geohash")
|
||||
onSuccess?.invoke()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send geohash message: ${e.message}")
|
||||
onError?.invoke("Failed to send message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to public messages in a geohash channel
|
||||
*/
|
||||
fun subscribeToGeohash(
|
||||
geohash: String,
|
||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||
) {
|
||||
val filter = NostrFilter.geohashEphemeral(
|
||||
geohash = geohash,
|
||||
since = System.currentTimeMillis() - 3600000L, // Last hour
|
||||
limit = 200
|
||||
)
|
||||
|
||||
relayManager.subscribe(filter, "geohash-$geohash", { event ->
|
||||
scope.launch {
|
||||
handleGeohashMessage(event, handler)
|
||||
}
|
||||
})
|
||||
|
||||
Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a geohash channel
|
||||
*/
|
||||
fun unsubscribeFromGeohash(geohash: String) {
|
||||
relayManager.unsubscribe("geohash-$geohash")
|
||||
Log.i(TAG, "Unsubscribed from geohash channel: #$geohash")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current identity information
|
||||
*/
|
||||
fun getCurrentIdentity(): NostrIdentity? = currentIdentity
|
||||
|
||||
/**
|
||||
* Get relay connection status
|
||||
*/
|
||||
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
|
||||
|
||||
/**
|
||||
* Get relay information
|
||||
*/
|
||||
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private suspend fun handlePrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
handler: (content: String, senderNpub: String, timestamp: Int) -> Unit
|
||||
) {
|
||||
// Age filtering (24h + 15min buffer for randomized timestamps)
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 87300) { // 24 hours + 15 minutes
|
||||
Log.v(TAG, "Ignoring old private message")
|
||||
return
|
||||
}
|
||||
|
||||
val identity = currentIdentity ?: return
|
||||
|
||||
try {
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||
if (decryptResult != null) {
|
||||
val (content, senderPubkey, timestamp) = decryptResult
|
||||
|
||||
// Convert sender pubkey to npub
|
||||
val senderNpub = try {
|
||||
Bech32.encode("npub", senderPubkey.hexToByteArray())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to encode sender npub: ${e.message}")
|
||||
"npub_decode_error"
|
||||
}
|
||||
|
||||
Log.d(TAG, "📥 Received private message from ${senderNpub.take(16)}...")
|
||||
|
||||
// Dispatch to main thread for handler
|
||||
withContext(Dispatchers.Main) {
|
||||
handler(content, senderNpub, timestamp)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Failed to decrypt private message")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling private message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleGeohashMessage(
|
||||
event: NostrEvent,
|
||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||
) {
|
||||
try {
|
||||
// Extract nickname from tags
|
||||
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
|
||||
|
||||
Log.v(TAG, "📥 Received geohash message from ${event.pubkey.take(16)}...")
|
||||
|
||||
// Dispatch to main thread for handler
|
||||
withContext(Dispatchers.Main) {
|
||||
handler(event.content, event.pubkey, nickname, event.createdAt)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling geohash message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.bouncycastle.crypto.ec.CustomNamedCurves
|
||||
import org.bouncycastle.crypto.params.ECDomainParameters
|
||||
import org.bouncycastle.crypto.params.ECPrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.ECPublicKeyParameters
|
||||
import org.bouncycastle.math.ec.ECPoint
|
||||
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
|
||||
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
|
||||
import org.bouncycastle.crypto.digests.SHA256Digest
|
||||
import org.bouncycastle.crypto.macs.HMac
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import java.security.SecureRandom
|
||||
import java.security.MessageDigest
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Cryptographic utilities for Nostr protocol
|
||||
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
|
||||
*/
|
||||
object NostrCrypto {
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
// secp256k1 curve parameters
|
||||
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
|
||||
val secp256k1Params = ECDomainParameters(
|
||||
secp256k1Curve.curve,
|
||||
secp256k1Curve.g,
|
||||
secp256k1Curve.n,
|
||||
secp256k1Curve.h
|
||||
)
|
||||
|
||||
/**
|
||||
* Generate secp256k1 key pair
|
||||
* Returns (privateKeyHex, publicKeyHex)
|
||||
*/
|
||||
fun generateKeyPair(): Pair<String, String> {
|
||||
val generator = ECKeyPairGenerator()
|
||||
val keyGenParams = ECKeyGenerationParameters(secp256k1Params, secureRandom)
|
||||
generator.init(keyGenParams)
|
||||
|
||||
val keyPair = generator.generateKeyPair()
|
||||
val privateKey = keyPair.private as ECPrivateKeyParameters
|
||||
val publicKey = keyPair.public as ECPublicKeyParameters
|
||||
|
||||
// Get private key as 32-byte hex - ensure proper padding
|
||||
val privateKeyBigInt = privateKey.d
|
||||
val privateKeyBytes = privateKeyBigInt.toByteArray()
|
||||
|
||||
val privateKeyPadded = ByteArray(32)
|
||||
if (privateKeyBytes.size <= 32) {
|
||||
val srcStart = maxOf(0, privateKeyBytes.size - 32)
|
||||
val destStart = maxOf(0, 32 - privateKeyBytes.size)
|
||||
val length = minOf(privateKeyBytes.size, 32)
|
||||
System.arraycopy(privateKeyBytes, srcStart, privateKeyPadded, destStart, length)
|
||||
} else {
|
||||
// If BigInteger added a sign byte, skip it
|
||||
System.arraycopy(privateKeyBytes, privateKeyBytes.size - 32, privateKeyPadded, 0, 32)
|
||||
}
|
||||
|
||||
// Get x-only public key (32 bytes)
|
||||
val publicKeyPoint = publicKey.q.normalize()
|
||||
val xCoord = publicKeyPoint.xCoord.encoded
|
||||
|
||||
return Pair(
|
||||
privateKeyPadded.toHexString(),
|
||||
xCoord.toHexString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive public key from private key
|
||||
* Returns x-only public key (32 bytes hex)
|
||||
*/
|
||||
fun derivePublicKey(privateKeyHex: String): String {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
|
||||
val publicKeyPoint = secp256k1Params.g.multiply(privateKeyBigInt).normalize()
|
||||
val xCoord = publicKeyPoint.xCoord.encoded
|
||||
|
||||
return xCoord.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform ECDH key agreement
|
||||
* Returns shared secret
|
||||
*/
|
||||
fun performECDH(privateKeyHex: String, publicKeyHex: String): ByteArray {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||
|
||||
// Try to recover full public key point from x-only coordinate
|
||||
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
|
||||
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
|
||||
|
||||
val agreement = ECDHBasicAgreement()
|
||||
agreement.init(privateKeyParams)
|
||||
|
||||
val sharedSecret = agreement.calculateAgreement(publicKeyParams)
|
||||
val sharedSecretBytes = sharedSecret.toByteArray()
|
||||
|
||||
// Ensure 32 bytes
|
||||
val result = ByteArray(32)
|
||||
if (sharedSecretBytes.size <= 32) {
|
||||
System.arraycopy(
|
||||
sharedSecretBytes,
|
||||
maxOf(0, sharedSecretBytes.size - 32),
|
||||
result,
|
||||
maxOf(0, 32 - sharedSecretBytes.size),
|
||||
minOf(sharedSecretBytes.size, 32)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover full EC point from x-only coordinate
|
||||
* Tries both possible y coordinates
|
||||
*/
|
||||
private fun recoverPublicKeyPoint(xOnlyBytes: ByteArray): ECPoint {
|
||||
require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" }
|
||||
|
||||
val x = BigInteger(1, xOnlyBytes)
|
||||
|
||||
// Try even y first (0x02 prefix)
|
||||
try {
|
||||
val compressedBytes = ByteArray(33)
|
||||
compressedBytes[0] = 0x02
|
||||
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
} catch (e: Exception) {
|
||||
// Try odd y (0x03 prefix)
|
||||
val compressedBytes = ByteArray(33)
|
||||
compressedBytes[0] = 0x03
|
||||
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 key derivation using HKDF
|
||||
*/
|
||||
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
|
||||
val salt = "nip44-v2".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// HKDF-Extract
|
||||
val hmac = HMac(SHA256Digest())
|
||||
hmac.init(KeyParameter(salt))
|
||||
hmac.update(sharedSecret, 0, sharedSecret.size)
|
||||
val prk = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(prk, 0)
|
||||
|
||||
// HKDF-Expand (we need 32 bytes for AES-256)
|
||||
hmac.init(KeyParameter(prk))
|
||||
hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1
|
||||
val okm = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(okm, 0)
|
||||
|
||||
// Return first 32 bytes
|
||||
return okm.copyOf(32)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 encryption using AES-256-GCM
|
||||
*/
|
||||
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String {
|
||||
try {
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex)
|
||||
|
||||
// Derive encryption key
|
||||
val encryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Generate random nonce (12 bytes for GCM)
|
||||
val nonce = ByteArray(12)
|
||||
secureRandom.nextBytes(nonce)
|
||||
|
||||
// Encrypt using AES-256-GCM
|
||||
val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(encryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
|
||||
val ciphertext = cipher.doFinal(plaintextBytes)
|
||||
|
||||
// Combine nonce + ciphertext (includes auth tag)
|
||||
val result = ByteArray(nonce.size + ciphertext.size)
|
||||
System.arraycopy(nonce, 0, result, 0, nonce.size)
|
||||
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
|
||||
|
||||
// Base64 encode
|
||||
return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 encryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 decryption using AES-256-GCM
|
||||
*/
|
||||
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
|
||||
try {
|
||||
// Decode base64
|
||||
val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP)
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
require(encryptedData.size >= 12 + 16) { "Ciphertext too short" }
|
||||
val nonce = encryptedData.copyOfRange(0, 12)
|
||||
val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size)
|
||||
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex)
|
||||
|
||||
// Derive decryption key
|
||||
val decryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Decrypt using AES-256-GCM
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(decryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
||||
val plaintextBytes = cipher.doFinal(ciphertextBytes)
|
||||
|
||||
return String(plaintextBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 decryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random timestamp offset for privacy (±15 minutes)
|
||||
*/
|
||||
fun randomizeTimestamp(baseTimestamp: Long = System.currentTimeMillis() / 1000): Int {
|
||||
val offset = secureRandom.nextInt(1800) - 900 // ±15 minutes in seconds
|
||||
return (baseTimestamp + offset).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate secp256k1 private key
|
||||
*/
|
||||
fun isValidPrivateKey(privateKeyHex: String): Boolean {
|
||||
return try {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
if (privateKeyBytes.size != 32) return false
|
||||
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
|
||||
// Must be less than curve order and greater than 0
|
||||
privateKeyBigInt > BigInteger.ZERO && privateKeyBigInt < secp256k1Params.n
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate x-only public key
|
||||
*/
|
||||
fun isValidPublicKey(publicKeyHex: String): Boolean {
|
||||
return try {
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
if (publicKeyBytes.size != 32) return false
|
||||
|
||||
// Try to recover point
|
||||
recoverPublicKeyPoint(publicKeyBytes)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// BIP-340 Schnorr Signatures Implementation
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Tagged hash function for BIP-340
|
||||
*/
|
||||
private fun taggedHash(tag: String, data: ByteArray): ByteArray {
|
||||
val tagBytes = tag.toByteArray(Charsets.UTF_8)
|
||||
val tagHash = MessageDigest.getInstance("SHA-256").digest(tagBytes)
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(tagHash)
|
||||
digest.update(tagHash)
|
||||
digest.update(data)
|
||||
return digest.digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if y coordinate is even
|
||||
*/
|
||||
private fun hasEvenY(point: ECPoint): Boolean {
|
||||
val yCoord = point.normalize().yCoord.encoded
|
||||
return (yCoord[yCoord.size - 1].toInt() and 1) == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift x coordinate to point with even y
|
||||
*/
|
||||
private fun liftX(xBytes: ByteArray): ECPoint? {
|
||||
return try {
|
||||
val point = recoverPublicKeyPoint(xBytes)
|
||||
val normalizedPoint = point.normalize()
|
||||
|
||||
if (hasEvenY(normalizedPoint)) {
|
||||
normalizedPoint
|
||||
} else {
|
||||
normalizedPoint.negate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-340 Schnorr signature creation
|
||||
* Returns 64-byte signature (r || s) as hex string
|
||||
*/
|
||||
fun schnorrSign(messageHash: ByteArray, privateKeyHex: String): String {
|
||||
require(messageHash.size == 32) { "Message hash must be 32 bytes" }
|
||||
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
require(privateKeyBytes.size == 32) { "Private key must be 32 bytes" }
|
||||
|
||||
val d = BigInteger(1, privateKeyBytes)
|
||||
require(d > BigInteger.ZERO && d < secp256k1Params.n) { "Invalid private key" }
|
||||
|
||||
// Compute public key point P = d * G
|
||||
val P = secp256k1Params.g.multiply(d).normalize()
|
||||
|
||||
// Ensure P has even y coordinate, adjust d if necessary
|
||||
val (adjustedD, publicKeyBytes) = if (hasEvenY(P)) {
|
||||
Pair(d, P.xCoord.encoded)
|
||||
} else {
|
||||
Pair(secp256k1Params.n.subtract(d), P.xCoord.encoded)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
val k = generateNonce(adjustedD, messageHash, publicKeyBytes)
|
||||
|
||||
// Compute R = k * G
|
||||
val R = secp256k1Params.g.multiply(k).normalize()
|
||||
|
||||
// Ensure R has even y coordinate
|
||||
val adjustedK = if (hasEvenY(R)) k else secp256k1Params.n.subtract(k)
|
||||
val r = R.xCoord.encoded
|
||||
|
||||
// Compute challenge e = H(r || P || m)
|
||||
val challengeData = ByteArray(96) // 32 + 32 + 32
|
||||
System.arraycopy(r, 0, challengeData, 0, 32)
|
||||
System.arraycopy(publicKeyBytes, 0, challengeData, 32, 32)
|
||||
System.arraycopy(messageHash, 0, challengeData, 64, 32)
|
||||
|
||||
val eBytes = taggedHash("BIP0340/challenge", challengeData)
|
||||
val e = BigInteger(1, eBytes).mod(secp256k1Params.n)
|
||||
|
||||
// Compute s = (k + e * d) mod n
|
||||
val s = adjustedK.add(e.multiply(adjustedD)).mod(secp256k1Params.n)
|
||||
|
||||
// Return signature as r || s (64 bytes hex)
|
||||
val rPadded = ByteArray(32)
|
||||
val sPadded = ByteArray(32)
|
||||
|
||||
val rBytes = r
|
||||
val sBytes = s.toByteArray()
|
||||
|
||||
// Pad r to 32 bytes (should already be 32)
|
||||
System.arraycopy(rBytes, 0, rPadded, 0, minOf(32, rBytes.size))
|
||||
|
||||
// Pad s to 32 bytes - handle BigInteger padding correctly
|
||||
if (sBytes.size <= 32) {
|
||||
val srcStart = maxOf(0, sBytes.size - 32)
|
||||
val destStart = maxOf(0, 32 - sBytes.size)
|
||||
val length = minOf(sBytes.size, 32)
|
||||
System.arraycopy(sBytes, srcStart, sPadded, destStart, length)
|
||||
} else {
|
||||
// If BigInteger added a sign byte, skip it
|
||||
System.arraycopy(sBytes, sBytes.size - 32, sPadded, 0, 32)
|
||||
}
|
||||
|
||||
return (rPadded + sPadded).toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-340 Schnorr signature verification
|
||||
*/
|
||||
fun schnorrVerify(messageHash: ByteArray, signatureHex: String, publicKeyHex: String): Boolean {
|
||||
return try {
|
||||
require(messageHash.size == 32) { "Message hash must be 32 bytes" }
|
||||
|
||||
val signatureBytes = signatureHex.hexToByteArray()
|
||||
require(signatureBytes.size == 64) { "Signature must be 64 bytes" }
|
||||
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
require(publicKeyBytes.size == 32) { "Public key must be 32 bytes" }
|
||||
|
||||
// Parse signature
|
||||
val r = signatureBytes.copyOfRange(0, 32)
|
||||
val sBytes = signatureBytes.copyOfRange(32, 64)
|
||||
val s = BigInteger(1, sBytes)
|
||||
|
||||
// Validate r and s
|
||||
val rBigInt = BigInteger(1, r)
|
||||
if (rBigInt >= secp256k1Params.curve.field.characteristic) return false
|
||||
if (s >= secp256k1Params.n) return false
|
||||
|
||||
// Lift public key
|
||||
val P = liftX(publicKeyBytes) ?: return false
|
||||
|
||||
// Compute challenge e = H(r || P || m)
|
||||
val challengeData = ByteArray(96)
|
||||
System.arraycopy(r, 0, challengeData, 0, 32)
|
||||
System.arraycopy(publicKeyBytes, 0, challengeData, 32, 32)
|
||||
System.arraycopy(messageHash, 0, challengeData, 64, 32)
|
||||
|
||||
val eBytes = taggedHash("BIP0340/challenge", challengeData)
|
||||
val e = BigInteger(1, eBytes).mod(secp256k1Params.n)
|
||||
|
||||
// Compute R = s * G - e * P
|
||||
val sG = secp256k1Params.g.multiply(s)
|
||||
val eP = P.multiply(e)
|
||||
val R = sG.subtract(eP).normalize()
|
||||
|
||||
// Check if R has even y and x coordinate matches r
|
||||
if (!hasEvenY(R)) return false
|
||||
|
||||
val computedR = R.xCoord.encoded
|
||||
return r.contentEquals(computedR)
|
||||
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic nonce for Schnorr signature (RFC 6979 style)
|
||||
*/
|
||||
private fun generateNonce(privateKey: BigInteger, messageHash: ByteArray, publicKeyBytes: ByteArray): BigInteger {
|
||||
// Simple nonce generation - in production, use RFC 6979
|
||||
// For now, use SHA256(private_key || message || public_key || random)
|
||||
val random = ByteArray(32)
|
||||
secureRandom.nextBytes(random)
|
||||
|
||||
val privateKeyBytes = privateKey.toByteArray()
|
||||
val nonceInput = ByteArray(privateKeyBytes.size + messageHash.size + publicKeyBytes.size + random.size)
|
||||
var offset = 0
|
||||
|
||||
System.arraycopy(privateKeyBytes, 0, nonceInput, offset, privateKeyBytes.size)
|
||||
offset += privateKeyBytes.size
|
||||
|
||||
System.arraycopy(messageHash, 0, nonceInput, offset, messageHash.size)
|
||||
offset += messageHash.size
|
||||
|
||||
System.arraycopy(publicKeyBytes, 0, nonceInput, offset, publicKeyBytes.size)
|
||||
offset += publicKeyBytes.size
|
||||
|
||||
System.arraycopy(random, 0, nonceInput, offset, random.size)
|
||||
|
||||
val nonceHash = MessageDigest.getInstance("SHA-256").digest(nonceInput)
|
||||
val nonce = BigInteger(1, nonceHash)
|
||||
|
||||
// Ensure nonce is in valid range
|
||||
return if (nonce >= secp256k1Params.n) {
|
||||
nonce.mod(secp256k1Params.n)
|
||||
} else {
|
||||
nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BitChat-over-Nostr Adapter
|
||||
* Direct port from iOS implementation for 100% compatibility
|
||||
*/
|
||||
object NostrEmbeddedBitChat {
|
||||
|
||||
private const val TAG = "NostrEmbeddedBitChat"
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
*/
|
||||
fun encodePMForNostr(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientPeerID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
try {
|
||||
// TLV-encode the private message
|
||||
val pm = PrivateMessagePacket(messageID = messageID, content = content)
|
||||
val tlv = pm.encode() ?: return null
|
||||
|
||||
// Prefix with NoisePayloadType
|
||||
val payload = ByteArray(1 + tlv.size)
|
||||
payload[0] = NoisePayloadType.PRIVATE_MESSAGE.value.toByte()
|
||||
System.arraycopy(tlv, 0, payload, 1, tlv.size)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
val recipientIDHex = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = hexStringToByteArray(recipientIDHex),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode PM for Nostr: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
*/
|
||||
fun encodeAckForNostr(
|
||||
type: NoisePayloadType,
|
||||
messageID: String,
|
||||
recipientPeerID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val payload = ByteArray(1 + messageID.toByteArray(Charsets.UTF_8).size)
|
||||
payload[0] = type.value.toByte()
|
||||
val messageIDBytes = messageID.toByteArray(Charsets.UTF_8)
|
||||
System.arraycopy(messageIDBytes, 0, payload, 1, messageIDBytes.size)
|
||||
|
||||
val recipientIDHex = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = hexStringToByteArray(recipientIDHex),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode ACK for Nostr: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
*/
|
||||
fun encodeAckForNostrNoRecipient(
|
||||
type: NoisePayloadType,
|
||||
messageID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val payload = ByteArray(1 + messageID.toByteArray(Charsets.UTF_8).size)
|
||||
payload[0] = type.value.toByte()
|
||||
val messageIDBytes = messageID.toByteArray(Charsets.UTF_8)
|
||||
System.arraycopy(messageIDBytes, 0, payload, 1, messageIDBytes.size)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = null, // No recipient for geohash DMs
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode ACK for Nostr (no recipient): ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
*/
|
||||
fun encodePMForNostrNoRecipient(
|
||||
content: String,
|
||||
messageID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
try {
|
||||
val pm = PrivateMessagePacket(messageID = messageID, content = content)
|
||||
val tlv = pm.encode() ?: return null
|
||||
|
||||
val payload = ByteArray(1 + tlv.size)
|
||||
payload[0] = NoisePayloadType.PRIVATE_MESSAGE.value.toByte()
|
||||
System.arraycopy(tlv, 0, payload, 1, tlv.size)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = null, // No recipient for geohash DMs
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode PM for Nostr (no recipient): ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize recipient peer ID (matches iOS implementation)
|
||||
*/
|
||||
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
|
||||
try {
|
||||
val maybeData = hexStringToByteArray(recipientPeerID)
|
||||
return when (maybeData.size) {
|
||||
32 -> {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
// For now, return first 8 bytes as hex (simplified)
|
||||
maybeData.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
8 -> {
|
||||
// Already an 8-byte peer ID
|
||||
recipientPeerID
|
||||
}
|
||||
else -> {
|
||||
// Fallback: return as-is (expecting 16 hex chars)
|
||||
recipientPeerID
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback: return as-is
|
||||
return recipientPeerID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode without padding (matches iOS implementation)
|
||||
*/
|
||||
private fun base64URLEncode(data: ByteArray): String {
|
||||
val b64 = Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
return b64
|
||||
.replace("+", "-")
|
||||
.replace("/", "_")
|
||||
.replace("=", "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Nostr Event structure following NIP-01
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrEvent(
|
||||
var id: String = "",
|
||||
val pubkey: String,
|
||||
@SerializedName("created_at") val createdAt: Int,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
var sig: String? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create from JSON dictionary
|
||||
*/
|
||||
fun fromJson(json: Map<String, Any>): NostrEvent? {
|
||||
return try {
|
||||
NostrEvent(
|
||||
id = json["id"] as? String ?: "",
|
||||
pubkey = json["pubkey"] as? String ?: return null,
|
||||
createdAt = (json["created_at"] as? Number)?.toInt() ?: return null,
|
||||
kind = (json["kind"] as? Number)?.toInt() ?: return null,
|
||||
tags = (json["tags"] as? List<List<String>>) ?: return null,
|
||||
content = json["content"] as? String ?: return null,
|
||||
sig = json["sig"] as? String?
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from JSON string
|
||||
*/
|
||||
fun fromJsonString(jsonString: String): NostrEvent? {
|
||||
return try {
|
||||
val gson = Gson()
|
||||
gson.fromJson(jsonString, NostrEvent::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new text note event
|
||||
*/
|
||||
fun createTextNote(
|
||||
content: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
tags: List<List<String>> = emptyList(),
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new metadata event (kind 0)
|
||||
*/
|
||||
fun createMetadata(
|
||||
metadata: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.METADATA,
|
||||
tags = emptyList(),
|
||||
content = metadata
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign event with secp256k1 private key
|
||||
* Returns signed event with id and signature set
|
||||
*/
|
||||
fun sign(privateKeyHex: String): NostrEvent {
|
||||
val (eventId, eventIdHash) = calculateEventId()
|
||||
|
||||
// Create signature using secp256k1
|
||||
val signature = signHash(eventIdHash, privateKeyHex)
|
||||
|
||||
return this.copy(
|
||||
id = eventId,
|
||||
sig = signature
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate event ID according to NIP-01
|
||||
* Returns (hex_id, hash_bytes)
|
||||
*/
|
||||
private fun calculateEventId(): Pair<String, ByteArray> {
|
||||
// Create serialized array for hashing according to NIP-01
|
||||
val serialized = listOf(
|
||||
0,
|
||||
pubkey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// Convert to JSON without escaping slashes (compact format)
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(serialized)
|
||||
|
||||
// SHA256 hash of the JSON string
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
|
||||
val hash = digest.digest(jsonBytes)
|
||||
|
||||
// Convert to hex
|
||||
val hexId = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return Pair(hexId, hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign hash using BIP-340 Schnorr signatures
|
||||
*/
|
||||
private fun signHash(hash: ByteArray, privateKeyHex: String): String {
|
||||
return try {
|
||||
// Use the real BIP-340 Schnorr signature from NostrCrypto
|
||||
NostrCrypto.schnorrSign(hash, privateKeyHex)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("Failed to sign event: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON string
|
||||
*/
|
||||
fun toJsonString(): String {
|
||||
val gson = Gson()
|
||||
return gson.toJson(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event signature using BIP-340 Schnorr verification
|
||||
*/
|
||||
fun isValidSignature(): Boolean {
|
||||
return try {
|
||||
val signatureHex = sig ?: return false
|
||||
if (id.isEmpty() || pubkey.isEmpty()) return false
|
||||
|
||||
// Recalculate the event ID hash for verification
|
||||
val (calculatedId, messageHash) = calculateEventId()
|
||||
|
||||
// Check if the calculated ID matches the stored ID
|
||||
if (calculatedId != id) return false
|
||||
|
||||
// Verify the Schnorr signature
|
||||
NostrCrypto.schnorrVerify(messageHash, signatureHex, pubkey)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event structure and signature
|
||||
*/
|
||||
fun isValid(): Boolean {
|
||||
return try {
|
||||
// Basic field validation
|
||||
if (pubkey.isEmpty() || content.isEmpty()) return false
|
||||
if (createdAt <= 0 || kind < 0) return false
|
||||
if (!NostrCrypto.isValidPublicKey(pubkey)) return false
|
||||
|
||||
// Signature validation
|
||||
isValidSignature()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event kinds
|
||||
*/
|
||||
object NostrKind {
|
||||
const val METADATA = 0
|
||||
const val TEXT_NOTE = 1
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension functions for hex encoding/decoding
|
||||
*/
|
||||
fun String.hexToByteArray(): ByteArray {
|
||||
check(length % 2 == 0) { "Must have an even length" }
|
||||
return chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Efficient LRU-based Nostr event deduplication system
|
||||
*
|
||||
* This class provides thread-safe deduplication of Nostr events based on their event IDs.
|
||||
* It maintains an LRU cache of up to 10,000 event IDs to prevent memory bloat while ensuring
|
||||
* duplicate events (which commonly arrive via different relays) are processed only once.
|
||||
*
|
||||
* Features:
|
||||
* - Thread-safe concurrent access
|
||||
* - LRU eviction when capacity is exceeded
|
||||
* - Configurable capacity (default 10,000)
|
||||
* - Efficient O(1) lookup and insertion
|
||||
* - Memory-bounded to prevent unbounded growth
|
||||
*/
|
||||
class NostrEventDeduplicator(
|
||||
private val maxCapacity: Int = DEFAULT_CAPACITY
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NostrDeduplicator"
|
||||
private const val DEFAULT_CAPACITY = 10000
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrEventDeduplicator? = null
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the deduplicator
|
||||
*/
|
||||
fun getInstance(): NostrEventDeduplicator {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrEventDeduplicator().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node for the doubly-linked list used in LRU implementation
|
||||
*/
|
||||
private data class LRUNode(
|
||||
val eventId: String,
|
||||
var prev: LRUNode? = null,
|
||||
var next: LRUNode? = null
|
||||
)
|
||||
|
||||
// Hash map for O(1) lookup - maps event ID to node
|
||||
private val nodeMap = ConcurrentHashMap<String, LRUNode>()
|
||||
|
||||
// Doubly-linked list for LRU ordering
|
||||
private val head = LRUNode("HEAD") // Dummy head node
|
||||
private val tail = LRUNode("TAIL") // Dummy tail node
|
||||
|
||||
// Lock for thread-safe LRU operations
|
||||
private val lruLock = Any()
|
||||
|
||||
// Statistics
|
||||
@Volatile
|
||||
private var totalChecks = 0L
|
||||
@Volatile
|
||||
private var duplicateCount = 0L
|
||||
@Volatile
|
||||
private var evictionCount = 0L
|
||||
|
||||
init {
|
||||
// Initialize the doubly-linked list
|
||||
head.next = tail
|
||||
tail.prev = head
|
||||
|
||||
Log.d(TAG, "Initialized NostrEventDeduplicator with capacity: $maxCapacity")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event has been seen before and mark it as seen
|
||||
*
|
||||
* @param eventId The Nostr event ID to check
|
||||
* @return true if the event is a duplicate (already seen), false if it's new
|
||||
*/
|
||||
fun isDuplicate(eventId: String): Boolean {
|
||||
totalChecks++
|
||||
|
||||
synchronized(lruLock) {
|
||||
val existingNode = nodeMap[eventId]
|
||||
|
||||
if (existingNode != null) {
|
||||
// Event is a duplicate - move to front (most recently used)
|
||||
moveToFront(existingNode)
|
||||
duplicateCount++
|
||||
|
||||
if (duplicateCount % 100 == 0L) {
|
||||
Log.v(TAG, "Duplicate event detected: $eventId (${duplicateCount} total duplicates)")
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
// New event - add to front
|
||||
addToFront(eventId)
|
||||
|
||||
// Check if we need to evict oldest entries
|
||||
if (nodeMap.size > maxCapacity) {
|
||||
evictOldest()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Nostr event with deduplication
|
||||
*
|
||||
* @param event The Nostr event to process
|
||||
* @param processor Function to call if the event is not a duplicate
|
||||
* @return true if the event was processed (not a duplicate), false if it was deduplicated
|
||||
*/
|
||||
fun processEvent(event: NostrEvent, processor: (NostrEvent) -> Unit): Boolean {
|
||||
return if (!isDuplicate(event.id)) {
|
||||
processor(event)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current statistics about the deduplicator
|
||||
*/
|
||||
fun getStats(): DeduplicationStats {
|
||||
synchronized(lruLock) {
|
||||
return DeduplicationStats(
|
||||
capacity = maxCapacity,
|
||||
currentSize = nodeMap.size,
|
||||
totalChecks = totalChecks,
|
||||
duplicateCount = duplicateCount,
|
||||
evictionCount = evictionCount,
|
||||
hitRate = if (totalChecks > 0) (duplicateCount.toDouble() / totalChecks.toDouble()) else 0.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached event IDs (useful for testing or resetting state)
|
||||
*/
|
||||
fun clear() {
|
||||
synchronized(lruLock) {
|
||||
nodeMap.clear()
|
||||
head.next = tail
|
||||
tail.prev = head
|
||||
|
||||
// Reset statistics
|
||||
totalChecks = 0L
|
||||
duplicateCount = 0L
|
||||
evictionCount = 0L
|
||||
|
||||
Log.d(TAG, "Cleared all cached event IDs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the deduplicator contains a specific event ID
|
||||
*/
|
||||
fun contains(eventId: String): Boolean {
|
||||
return nodeMap.containsKey(eventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the cache
|
||||
*/
|
||||
fun size(): Int = nodeMap.size
|
||||
|
||||
// MARK: - Private LRU Implementation Methods
|
||||
|
||||
/**
|
||||
* Add a new event ID to the front of the LRU list
|
||||
*/
|
||||
private fun addToFront(eventId: String) {
|
||||
val newNode = LRUNode(eventId)
|
||||
nodeMap[eventId] = newNode
|
||||
|
||||
// Insert after head
|
||||
newNode.next = head.next
|
||||
newNode.prev = head
|
||||
head.next?.prev = newNode
|
||||
head.next = newNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an existing node to the front (most recently used position)
|
||||
*/
|
||||
private fun moveToFront(node: LRUNode) {
|
||||
// Remove from current position
|
||||
node.prev?.next = node.next
|
||||
node.next?.prev = node.prev
|
||||
|
||||
// Insert at front
|
||||
node.next = head.next
|
||||
node.prev = head
|
||||
head.next?.prev = node
|
||||
head.next = node
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the least recently used node (at the tail)
|
||||
*/
|
||||
private fun removeTail(): LRUNode? {
|
||||
val lastNode = tail.prev
|
||||
if (lastNode == head) {
|
||||
return null // Empty list
|
||||
}
|
||||
|
||||
// Remove from linked list
|
||||
lastNode?.prev?.next = tail
|
||||
tail.prev = lastNode?.prev
|
||||
|
||||
// Remove from hash map
|
||||
if (lastNode != null) {
|
||||
nodeMap.remove(lastNode.eventId)
|
||||
}
|
||||
|
||||
return lastNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the oldest (least recently used) entries when capacity is exceeded
|
||||
*/
|
||||
private fun evictOldest() {
|
||||
while (nodeMap.size > maxCapacity) {
|
||||
val evictedNode = removeTail()
|
||||
if (evictedNode != null) {
|
||||
evictionCount++
|
||||
|
||||
if (evictionCount % 500 == 0L) {
|
||||
Log.v(TAG, "Evicted event ID: ${evictedNode.eventId} (${evictionCount} total evictions)")
|
||||
}
|
||||
} else {
|
||||
break // Should not happen, but safety check
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics about the deduplication system
|
||||
*/
|
||||
data class DeduplicationStats(
|
||||
val capacity: Int,
|
||||
val currentSize: Int,
|
||||
val totalChecks: Long,
|
||||
val duplicateCount: Long,
|
||||
val evictionCount: Long,
|
||||
val hitRate: Double
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "DeduplicationStats(capacity=$capacity, size=$currentSize, " +
|
||||
"checks=$totalChecks, duplicates=$duplicateCount, evictions=$evictionCount, " +
|
||||
"hitRate=${"%.2f".format(hitRate * 100)}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Nostr event filter for subscriptions
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrFilter(
|
||||
val ids: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val since: Int? = null,
|
||||
val until: Int? = null,
|
||||
val limit: Int? = null,
|
||||
private val tagFilters: Map<String, List<String>>? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create filter for NIP-17 gift wraps
|
||||
*/
|
||||
fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.GIFT_WRAP),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("p" to listOf(pubkey)),
|
||||
limit = 100
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000)
|
||||
*/
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("g" to listOf(geohash)),
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for text notes from specific authors
|
||||
*/
|
||||
fun textNotesFrom(authors: List<String>, since: Long? = null, limit: Int = 50): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.TEXT_NOTE),
|
||||
authors = authors,
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for specific event IDs
|
||||
*/
|
||||
fun forEvents(ids: List<String>): NostrFilter {
|
||||
return NostrFilter(ids = ids)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON serializer to handle tag filters properly
|
||||
*/
|
||||
class FilterSerializer : JsonSerializer<NostrFilter> {
|
||||
override fun serialize(src: NostrFilter, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
|
||||
val jsonObject = JsonObject()
|
||||
|
||||
// Standard fields
|
||||
src.ids?.let { jsonObject.add("ids", context.serialize(it)) }
|
||||
src.authors?.let { jsonObject.add("authors", context.serialize(it)) }
|
||||
src.kinds?.let { jsonObject.add("kinds", context.serialize(it)) }
|
||||
src.since?.let { jsonObject.addProperty("since", it) }
|
||||
src.until?.let { jsonObject.addProperty("until", it) }
|
||||
src.limit?.let { jsonObject.addProperty("limit", it) }
|
||||
|
||||
// Tag filters with # prefix
|
||||
src.tagFilters?.forEach { (tag, values) ->
|
||||
jsonObject.add("#$tag", context.serialize(values))
|
||||
}
|
||||
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create builder for complex filters
|
||||
*/
|
||||
class Builder {
|
||||
private var ids: List<String>? = null
|
||||
private var authors: List<String>? = null
|
||||
private var kinds: List<Int>? = null
|
||||
private var since: Int? = null
|
||||
private var until: Int? = null
|
||||
private var limit: Int? = null
|
||||
private val tagFilters = mutableMapOf<String, List<String>>()
|
||||
|
||||
fun ids(vararg ids: String) = apply { this.ids = ids.toList() }
|
||||
fun authors(vararg authors: String) = apply { this.authors = authors.toList() }
|
||||
fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() }
|
||||
fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() }
|
||||
fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() }
|
||||
fun limit(count: Int) = apply { this.limit = count }
|
||||
|
||||
fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() }
|
||||
fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() }
|
||||
fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() }
|
||||
fun tag(name: String, vararg values: String) = apply { tagFilters[name] = values.toList() }
|
||||
|
||||
fun build(): NostrFilter {
|
||||
return NostrFilter(
|
||||
ids = ids,
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
since = since,
|
||||
until = until,
|
||||
limit = limit,
|
||||
tagFilters = tagFilters.toMap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this filter matches an event
|
||||
*/
|
||||
fun matches(event: NostrEvent): Boolean {
|
||||
// Check IDs
|
||||
if (ids != null && !ids.contains(event.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check authors
|
||||
if (authors != null && !authors.contains(event.pubkey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check kinds
|
||||
if (kinds != null && !kinds.contains(event.kind)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time bounds
|
||||
if (since != null && event.createdAt < since) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (until != null && event.createdAt > until) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check tag filters
|
||||
if (tagFilters != null) {
|
||||
for ((tagName, requiredValues) in tagFilters) {
|
||||
val eventTags = event.tags.filter { it.isNotEmpty() && it[0] == tagName }
|
||||
val eventValues = eventTags.mapNotNull { tag ->
|
||||
if (tag.size > 1) tag[1] else null
|
||||
}
|
||||
|
||||
val hasMatch = requiredValues.any { requiredValue ->
|
||||
eventValues.contains(requiredValue)
|
||||
}
|
||||
|
||||
if (!hasMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug description
|
||||
*/
|
||||
fun getDebugDescription(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
ids?.let { parts.add("ids=${it.size}") }
|
||||
authors?.let { parts.add("authors=${it.size}") }
|
||||
kinds?.let { parts.add("kinds=$it") }
|
||||
since?.let { parts.add("since=$it") }
|
||||
until?.let { parts.add("until=$it") }
|
||||
limit?.let { parts.add("limit=$it") }
|
||||
tagFilters?.let { filters ->
|
||||
filters.forEach { (tag, values) ->
|
||||
parts.add("#$tag=${values.size}")
|
||||
}
|
||||
}
|
||||
|
||||
return "NostrFilter(${parts.joinToString(", ")})"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrIdentity(
|
||||
val privateKeyHex: String,
|
||||
val publicKeyHex: String,
|
||||
val npub: String,
|
||||
val createdAt: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrIdentity"
|
||||
|
||||
/**
|
||||
* Generate a new Nostr identity
|
||||
*/
|
||||
fun generate(): NostrIdentity {
|
||||
val (privateKeyHex, publicKeyHex) = NostrCrypto.generateKeyPair()
|
||||
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
|
||||
|
||||
Log.d(TAG, "Generated new Nostr identity: npub=$npub")
|
||||
|
||||
return NostrIdentity(
|
||||
privateKeyHex = privateKeyHex,
|
||||
publicKeyHex = publicKeyHex,
|
||||
npub = npub,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from existing private key
|
||||
*/
|
||||
fun fromPrivateKey(privateKeyHex: String): NostrIdentity {
|
||||
require(NostrCrypto.isValidPrivateKey(privateKeyHex)) {
|
||||
"Invalid private key"
|
||||
}
|
||||
|
||||
val publicKeyHex = NostrCrypto.derivePublicKey(privateKeyHex)
|
||||
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
|
||||
|
||||
return NostrIdentity(
|
||||
privateKeyHex = privateKeyHex,
|
||||
publicKeyHex = publicKeyHex,
|
||||
npub = npub,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from a deterministic seed (for demo purposes)
|
||||
*/
|
||||
fun fromSeed(seed: String): NostrIdentity {
|
||||
// Hash the seed to create a private key
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val seedBytes = seed.toByteArray(Charsets.UTF_8)
|
||||
val privateKeyBytes = digest.digest(seedBytes)
|
||||
val privateKeyHex = privateKeyBytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return fromPrivateKey(privateKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a Nostr event
|
||||
*/
|
||||
fun signEvent(event: NostrEvent): NostrEvent {
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display format
|
||||
*/
|
||||
fun getShortNpub(): String {
|
||||
return if (npub.length > 16) {
|
||||
"${npub.take(8)}...${npub.takeLast(8)}"
|
||||
} else {
|
||||
npub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between Noise and Nostr identities
|
||||
* Manages persistent storage and per-geohash identity derivation
|
||||
*/
|
||||
object NostrIdentityBridge {
|
||||
private const val TAG = "NostrIdentityBridge"
|
||||
private const val NOSTR_PRIVATE_KEY = "nostr_private_key"
|
||||
private const val DEVICE_SEED_KEY = "nostr_device_seed"
|
||||
|
||||
// Cache for derived geohash identities to avoid repeated crypto operations
|
||||
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
|
||||
|
||||
/**
|
||||
* Get or create the current Nostr identity
|
||||
*/
|
||||
fun getCurrentNostrIdentity(context: Context): NostrIdentity? {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Try to load existing Nostr private key
|
||||
val existingKey = loadNostrPrivateKey(stateManager)
|
||||
if (existingKey != null) {
|
||||
return try {
|
||||
NostrIdentity.fromPrivateKey(existingKey)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create identity from stored key: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new identity
|
||||
val newIdentity = NostrIdentity.generate()
|
||||
saveNostrPrivateKey(stateManager, newIdentity.privateKeyHex)
|
||||
|
||||
Log.i(TAG, "Created new Nostr identity: ${newIdentity.getShortNpub()}")
|
||||
return newIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic, unlinkable Nostr identity for a given geohash
|
||||
* Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing
|
||||
* if the candidate is not a valid secp256k1 private key.
|
||||
*
|
||||
* Direct port from iOS implementation for 100% compatibility
|
||||
* OPTIMIZED: Cached for UI responsiveness
|
||||
*/
|
||||
fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity {
|
||||
// Check cache first for immediate response
|
||||
geohashIdentityCache[forGeohash]?.let { cachedIdentity ->
|
||||
//Log.v(TAG, "Using cached geohash identity for $forGeohash")
|
||||
return cachedIdentity
|
||||
}
|
||||
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
val seed = getOrCreateDeviceSeed(stateManager)
|
||||
|
||||
val geohashBytes = forGeohash.toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed (exactly like iOS)
|
||||
for (i in 0 until 10) {
|
||||
val candidateKey = candidateKey(seed, geohashBytes, i.toUInt())
|
||||
val candidateKeyHex = candidateKey.toHexStringLocal()
|
||||
|
||||
if (NostrCrypto.isValidPrivateKey(candidateKeyHex)) {
|
||||
val identity = NostrIdentity.fromPrivateKey(candidateKeyHex)
|
||||
|
||||
// Cache the result for future UI responsiveness
|
||||
geohashIdentityCache[forGeohash] = identity
|
||||
|
||||
Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)")
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
// As a final fallback, hash the seed+msg and try again (exactly like iOS)
|
||||
val combined = seed + geohashBytes
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val fallbackKey = digest.digest(combined)
|
||||
|
||||
val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexStringLocal())
|
||||
|
||||
// Cache the fallback result too
|
||||
geohashIdentityCache[forGeohash] = fallbackIdentity
|
||||
|
||||
Log.d(TAG, "Used fallback identity derivation for $forGeohash")
|
||||
return fallbackIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate candidate key for a specific iteration (matches iOS implementation)
|
||||
*/
|
||||
private fun candidateKey(seed: ByteArray, message: ByteArray, iteration: UInt): ByteArray {
|
||||
val input = message + iteration.toLittleEndianBytes()
|
||||
return hmacSha256(seed, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate a Nostr identity with a Noise public key (for favorites)
|
||||
*/
|
||||
fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// We'll use the existing signing key storage mechanism for associations
|
||||
// For now, we'll store this as a preference since it's just for favorites mapping
|
||||
// In a full implementation, you'd want a proper association storage system
|
||||
|
||||
Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Nostr public key associated with a Noise public key
|
||||
*/
|
||||
fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? {
|
||||
// This would need proper implementation based on your favorites storage system
|
||||
// For now, return null as we don't have the full association system
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all Nostr identity data
|
||||
*/
|
||||
fun clearAllAssociations(context: Context) {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Clear cache first
|
||||
geohashIdentityCache.clear()
|
||||
|
||||
// Clear Nostr private key
|
||||
try {
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.edit()
|
||||
.remove(NOSTR_PRIVATE_KEY)
|
||||
.remove(DEVICE_SEED_KEY)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Cleared all Nostr identity data and cache")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private fun loadNostrPrivateKey(stateManager: SecureIdentityStateManager): String? {
|
||||
return try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.getString(NOSTR_PRIVATE_KEY, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load Nostr private key: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNostrPrivateKey(stateManager: SecureIdentityStateManager, privateKeyHex: String) {
|
||||
try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.edit()
|
||||
.putString(NOSTR_PRIVATE_KEY, privateKeyHex)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Saved Nostr private key to secure storage")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save Nostr private key: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateDeviceSeed(stateManager: SecureIdentityStateManager): ByteArray {
|
||||
try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
val existingSeed = prefs.getString(DEVICE_SEED_KEY, null)
|
||||
if (existingSeed != null) {
|
||||
return android.util.Base64.decode(existingSeed, android.util.Base64.DEFAULT)
|
||||
}
|
||||
|
||||
// Generate new seed
|
||||
val seed = ByteArray(32)
|
||||
SecureRandom().nextBytes(seed)
|
||||
|
||||
val seedBase64 = android.util.Base64.encodeToString(seed, android.util.Base64.DEFAULT)
|
||||
prefs.edit()
|
||||
.putString(DEVICE_SEED_KEY, seedBase64)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Generated new device seed for geohash identity derivation")
|
||||
return seed
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get/create device seed: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun hmacSha256(key: ByteArray, message: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = javax.crypto.spec.SecretKeySpec(key, "HmacSHA256")
|
||||
mac.init(secretKeySpec)
|
||||
return mac.doFinal(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Extension functions for data conversion
|
||||
private fun String.hexToByteArrayLocal(): ByteArray {
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexStringLocal(): String {
|
||||
return joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun UInt.toLittleEndianBytes(): ByteArray {
|
||||
val bytes = ByteArray(4)
|
||||
bytes[0] = (this and 0xFFu).toByte()
|
||||
bytes[1] = ((this shr 8) and 0xFFu).toByte()
|
||||
bytes[2] = ((this shr 16) and 0xFFu).toByte()
|
||||
bytes[3] = ((this shr 24) and 0xFFu).toByte()
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
|
||||
/**
|
||||
* NIP-17 Protocol Implementation for Private Direct Messages
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
object NostrProtocol {
|
||||
|
||||
private const val TAG = "NostrProtocol"
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* Create a NIP-17 private message
|
||||
* Returns gift-wrapped event ready for relay broadcast
|
||||
*/
|
||||
fun createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent {
|
||||
Log.v(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
val rumor = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = emptyList(),
|
||||
content = content
|
||||
)
|
||||
|
||||
// 2. Create ephemeral key for this message
|
||||
val (ephemeralPrivateKey, ephemeralPublicKey) = NostrCrypto.generateKeyPair()
|
||||
Log.v(TAG, "Created ephemeral key for seal")
|
||||
|
||||
// 3. Seal the rumor (encrypt to recipient)
|
||||
val sealedEvent = createSeal(
|
||||
rumor = rumor,
|
||||
recipientPubkey = recipientPubkey,
|
||||
senderPrivateKey = ephemeralPrivateKey,
|
||||
senderPublicKey = ephemeralPublicKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
val giftWrap = createGiftWrap(
|
||||
seal = sealedEvent,
|
||||
recipientPubkey = recipientPubkey
|
||||
)
|
||||
|
||||
Log.v(TAG, "Created gift wrap with id: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a received NIP-17 message
|
||||
* Returns (content, senderPubkey, timestamp) or null if decryption fails
|
||||
*/
|
||||
fun decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
): Triple<String, String, Int>? {
|
||||
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return try {
|
||||
// 1. Unwrap the gift wrap
|
||||
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
Log.w(TAG, "❌ Failed to unwrap gift wrap")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap")
|
||||
|
||||
// 2. Open the seal
|
||||
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
Log.w(TAG, "❌ Failed to open seal")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully opened seal")
|
||||
|
||||
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to decrypt private message: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
*/
|
||||
fun createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null
|
||||
): NostrEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
|
||||
if (!nickname.isNullOrEmpty()) {
|
||||
tags.add(listOf("n", nickname))
|
||||
}
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.EPHEMERAL_EVENT,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
|
||||
return senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private fun createSeal(
|
||||
rumor: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderPrivateKey: String,
|
||||
senderPublicKey: String
|
||||
): NostrEvent {
|
||||
val rumorJSON = gson.toJson(rumor)
|
||||
|
||||
val encrypted = NostrCrypto.encryptNIP44(
|
||||
plaintext = rumorJSON,
|
||||
recipientPublicKeyHex = recipientPubkey,
|
||||
senderPrivateKeyHex = senderPrivateKey
|
||||
)
|
||||
|
||||
val seal = NostrEvent(
|
||||
pubkey = senderPublicKey,
|
||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
||||
kind = NostrKind.SEAL,
|
||||
tags = emptyList(),
|
||||
content = encrypted
|
||||
)
|
||||
|
||||
// Sign with the ephemeral key
|
||||
return seal.sign(senderPrivateKey)
|
||||
}
|
||||
|
||||
private fun createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String
|
||||
): NostrEvent {
|
||||
val sealJSON = gson.toJson(seal)
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair()
|
||||
Log.v(TAG, "Creating gift wrap with ephemeral key")
|
||||
|
||||
// Encrypt the seal with the new ephemeral key
|
||||
val encrypted = NostrCrypto.encryptNIP44(
|
||||
plaintext = sealJSON,
|
||||
recipientPublicKeyHex = recipientPubkey,
|
||||
senderPrivateKeyHex = wrapPrivateKey
|
||||
)
|
||||
|
||||
val giftWrap = NostrEvent(
|
||||
pubkey = wrapPublicKey,
|
||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
||||
kind = NostrKind.GIFT_WRAP,
|
||||
tags = listOf(listOf("p", recipientPubkey)), // Tag recipient
|
||||
content = encrypted
|
||||
)
|
||||
|
||||
// Sign with the gift wrap ephemeral key
|
||||
return giftWrap.sign(wrapPrivateKey)
|
||||
}
|
||||
|
||||
private fun unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientPrivateKey: String
|
||||
): NostrEvent? {
|
||||
Log.v(TAG, "Unwrapping gift wrap")
|
||||
|
||||
return try {
|
||||
val decrypted = NostrCrypto.decryptNIP44(
|
||||
ciphertext = giftWrap.content,
|
||||
senderPublicKeyHex = giftWrap.pubkey,
|
||||
recipientPrivateKeyHex = recipientPrivateKey
|
||||
)
|
||||
|
||||
val jsonElement = JsonParser.parseString(decrypted)
|
||||
if (!jsonElement.isJsonObject) {
|
||||
Log.w(TAG, "Decrypted gift wrap is not a JSON object")
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
val seal = NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
|
||||
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
|
||||
seal
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unwrap gift wrap: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSeal(
|
||||
seal: NostrEvent,
|
||||
recipientPrivateKey: String
|
||||
): NostrEvent? {
|
||||
return try {
|
||||
val decrypted = NostrCrypto.decryptNIP44(
|
||||
ciphertext = seal.content,
|
||||
senderPublicKeyHex = seal.pubkey,
|
||||
recipientPrivateKeyHex = recipientPrivateKey
|
||||
)
|
||||
|
||||
val jsonElement = JsonParser.parseString(decrypted)
|
||||
if (!jsonElement.isJsonObject) {
|
||||
Log.w(TAG, "Decrypted seal is not a JSON object")
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open seal: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTagsFromJson(tagsArray: com.google.gson.JsonArray?): List<List<String>>? {
|
||||
if (tagsArray == null) return emptyList()
|
||||
|
||||
return try {
|
||||
tagsArray.map { tagElement ->
|
||||
if (tagElement.isJsonArray) {
|
||||
val tagArray = tagElement.asJsonArray
|
||||
tagArray.map { it.asString }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse tags: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections to Nostr relays
|
||||
* Compatible with iOS implementation with Android-specific optimizations
|
||||
*/
|
||||
class NostrRelayManager private constructor() {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val shared = NostrRelayManager()
|
||||
|
||||
private const val TAG = "NostrRelayManager"
|
||||
|
||||
/**
|
||||
* Get instance for Android compatibility (context-aware calls)
|
||||
*/
|
||||
fun getInstance(context: android.content.Context): NostrRelayManager {
|
||||
return shared
|
||||
}
|
||||
|
||||
// Default relay list (same as iOS)
|
||||
private val DEFAULT_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
|
||||
// Exponential backoff configuration (same as iOS)
|
||||
private const val INITIAL_BACKOFF_INTERVAL = 1000L // 1 second
|
||||
private const val MAX_BACKOFF_INTERVAL = 300000L // 5 minutes
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||
|
||||
// Track gift-wraps we initiated for logging
|
||||
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay status information
|
||||
*/
|
||||
data class Relay(
|
||||
val url: String,
|
||||
var isConnected: Boolean = false,
|
||||
var lastError: Throwable? = null,
|
||||
var lastConnectedAt: Long? = null,
|
||||
var messagesSent: Int = 0,
|
||||
var messagesReceived: Int = 0,
|
||||
var reconnectAttempts: Int = 0,
|
||||
var lastDisconnectedAt: Long? = null,
|
||||
var nextReconnectTime: Long? = null
|
||||
)
|
||||
|
||||
// Published state
|
||||
private val _relays = MutableLiveData<List<Relay>>()
|
||||
val relays: LiveData<List<Relay>> = _relays
|
||||
|
||||
private val _isConnected = MutableLiveData<Boolean>()
|
||||
val isConnected: LiveData<Boolean> = _isConnected
|
||||
|
||||
// Internal state
|
||||
private val relaysList = mutableListOf<Relay>()
|
||||
private val connections = ConcurrentHashMap<String, WebSocket>()
|
||||
private val subscriptions = ConcurrentHashMap<String, Set<String>>() // relay URL -> subscription IDs
|
||||
private val messageHandlers = ConcurrentHashMap<String, (NostrEvent) -> Unit>()
|
||||
|
||||
// Persistent subscription tracking for robust reconnection
|
||||
private val activeSubscriptions = ConcurrentHashMap<String, SubscriptionInfo>() // subscription ID -> info
|
||||
|
||||
/**
|
||||
* Information about an active subscription that needs to be maintained across reconnections
|
||||
*/
|
||||
data class SubscriptionInfo(
|
||||
val id: String,
|
||||
val filter: NostrFilter,
|
||||
val handler: (NostrEvent) -> Unit,
|
||||
val targetRelayUrls: Set<String>? = null, // null means all relays
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Event deduplication system
|
||||
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
|
||||
|
||||
// Message queue for reliability
|
||||
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
|
||||
private val messageQueueLock = Any()
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Subscription validation timer
|
||||
private var subscriptionValidationJob: Job? = null
|
||||
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
|
||||
|
||||
// OkHttp client for WebSocket connections
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val gson by lazy { NostrRequest.createGson() }
|
||||
|
||||
init {
|
||||
// Initialize with default relays - avoid static initialization order issues
|
||||
try {
|
||||
val defaultRelayUrls = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
_relays.postValue(relaysList.toList())
|
||||
updateConnectionStatus()
|
||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||
// Initialize with empty list as fallback
|
||||
_relays.postValue(emptyList())
|
||||
_isConnected.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
fun connect() {
|
||||
Log.d(TAG, "🌐 Connecting to ${relaysList.size} Nostr relays")
|
||||
|
||||
scope.launch {
|
||||
relaysList.forEach { relay ->
|
||||
launch {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic subscription validation
|
||||
startSubscriptionValidation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting from all relays")
|
||||
|
||||
// Stop subscription validation
|
||||
stopSubscriptionValidation()
|
||||
|
||||
connections.values.forEach { webSocket ->
|
||||
webSocket.close(1000, "Manual disconnect")
|
||||
}
|
||||
connections.clear()
|
||||
|
||||
// Clear subscriptions
|
||||
subscriptions.clear()
|
||||
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to specified relays (or all if none specified)
|
||||
*/
|
||||
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
|
||||
val targetRelays = relayUrls ?: relaysList.map { it.url }
|
||||
|
||||
// Add to queue for reliability
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(Pair(event, targetRelays))
|
||||
}
|
||||
|
||||
// Attempt immediate send
|
||||
scope.launch {
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a filter
|
||||
* The subscription will be automatically re-established on reconnection
|
||||
*/
|
||||
fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String = generateSubscriptionId(),
|
||||
handler: (NostrEvent) -> Unit,
|
||||
targetRelayUrls: List<String>? = null
|
||||
): String {
|
||||
// Store subscription info for persistent tracking
|
||||
val subscriptionInfo = SubscriptionInfo(
|
||||
id = id,
|
||||
filter = filter,
|
||||
handler = handler,
|
||||
targetRelayUrls = targetRelayUrls?.toSet()
|
||||
)
|
||||
|
||||
activeSubscriptions[id] = subscriptionInfo
|
||||
messageHandlers[id] = handler
|
||||
|
||||
Log.d(TAG, "📡 Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}")
|
||||
|
||||
// Send subscription to appropriate relays
|
||||
sendSubscriptionToRelays(subscriptionInfo)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a subscription to the appropriate relays
|
||||
*/
|
||||
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
// DEBUG: Log the actual serialized message format
|
||||
Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message")
|
||||
|
||||
scope.launch {
|
||||
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
|
||||
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
try {
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Subscription '${subscriptionInfo.id}' sent to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to send subscription to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send subscription to $relayUrl: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.v(TAG, "⏳ Relay $relayUrl not connected, subscription will be sent on reconnection")
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a subscription
|
||||
*/
|
||||
fun unsubscribe(id: String) {
|
||||
// Remove from persistent tracking
|
||||
val subscriptionInfo = activeSubscriptions.remove(id)
|
||||
messageHandlers.remove(id)
|
||||
|
||||
if (subscriptionInfo == null) {
|
||||
Log.w(TAG, "⚠️ Attempted to unsubscribe from unknown subscription: $id")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🚫 Unsubscribing from subscription: $id")
|
||||
|
||||
val request = NostrRequest.Close(id)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
scope.launch {
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl]
|
||||
if (currentSubs?.contains(id) == true) {
|
||||
try {
|
||||
webSocket.send(message)
|
||||
subscriptions[relayUrl] = currentSubs - id
|
||||
Log.v(TAG, "Unsubscribed '$id' from relay: $relayUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually retry connection to a specific relay
|
||||
*/
|
||||
fun retryConnection(relayUrl: String) {
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
|
||||
// Reset reconnection attempts
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
|
||||
// Disconnect if connected
|
||||
connections[relayUrl]?.close(1000, "Manual retry")
|
||||
connections.remove(relayUrl)
|
||||
|
||||
// Attempt immediate reconnection
|
||||
scope.launch {
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all relay connections
|
||||
* This will automatically restore all subscriptions when reconnected
|
||||
*/
|
||||
fun resetAllConnections() {
|
||||
disconnect()
|
||||
|
||||
// Reset all relay states
|
||||
relaysList.forEach { relay ->
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
relay.lastError = null
|
||||
}
|
||||
|
||||
// Reconnect - subscriptions will be automatically restored in onOpen
|
||||
connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-establishment of all subscriptions on currently connected relays
|
||||
* Useful for ensuring subscription consistency after network issues
|
||||
*/
|
||||
fun reestablishAllSubscriptions() {
|
||||
Log.d(TAG, "🔄 Force re-establishing all ${activeSubscriptions.size} active subscriptions")
|
||||
|
||||
scope.launch {
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed status for all relays
|
||||
*/
|
||||
fun getRelayStatuses(): List<Relay> {
|
||||
return relaysList.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event deduplication statistics
|
||||
*/
|
||||
fun getDeduplicationStats(): DeduplicationStats {
|
||||
return eventDeduplicator.getStats()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the event deduplication cache (useful for testing or debugging)
|
||||
*/
|
||||
fun clearDeduplicationCache() {
|
||||
eventDeduplicator.clear()
|
||||
Log.i(TAG, "🧹 Cleared event deduplication cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of active subscriptions
|
||||
*/
|
||||
fun getActiveSubscriptionCount(): Int {
|
||||
return activeSubscriptions.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about all active subscriptions (for debugging)
|
||||
*/
|
||||
fun getActiveSubscriptions(): Map<String, SubscriptionInfo> {
|
||||
return activeSubscriptions.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription consistency across all relays
|
||||
* Returns a report of any inconsistencies found
|
||||
*/
|
||||
fun validateSubscriptionConsistency(): SubscriptionConsistencyReport {
|
||||
val expectedSubs = activeSubscriptions.keys
|
||||
val actualSubsByRelay = subscriptions.toMap()
|
||||
val inconsistencies = mutableListOf<String>()
|
||||
|
||||
connections.keys.forEach { relayUrl ->
|
||||
val actualSubs = actualSubsByRelay[relayUrl] ?: emptySet()
|
||||
val expectedForRelay = expectedSubs.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
val missing = expectedForRelay - actualSubs
|
||||
val extra = actualSubs - expectedForRelay
|
||||
|
||||
if (missing.isNotEmpty()) {
|
||||
inconsistencies.add("Relay $relayUrl missing subscriptions: $missing")
|
||||
}
|
||||
if (extra.isNotEmpty()) {
|
||||
inconsistencies.add("Relay $relayUrl has extra subscriptions: $extra")
|
||||
}
|
||||
}
|
||||
|
||||
return SubscriptionConsistencyReport(
|
||||
isConsistent = inconsistencies.isEmpty(),
|
||||
inconsistencies = inconsistencies,
|
||||
totalActiveSubscriptions = activeSubscriptions.size,
|
||||
connectedRelayCount = connections.size
|
||||
)
|
||||
}
|
||||
|
||||
data class SubscriptionConsistencyReport(
|
||||
val isConsistent: Boolean,
|
||||
val inconsistencies: List<String>,
|
||||
val totalActiveSubscriptions: Int,
|
||||
val connectedRelayCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Start periodic subscription validation to ensure robustness
|
||||
*/
|
||||
private fun startSubscriptionValidation() {
|
||||
stopSubscriptionValidation() // Stop any existing validation
|
||||
|
||||
subscriptionValidationJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(SUBSCRIPTION_VALIDATION_INTERVAL)
|
||||
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (!report.isConsistent && report.connectedRelayCount > 0) {
|
||||
Log.w(TAG, "⚠️ Subscription inconsistencies detected: ${report.inconsistencies}")
|
||||
|
||||
// Auto-repair: re-establish subscriptions for relays with missing ones
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
val expectedSubs = activeSubscriptions.keys.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
val missingSubs = expectedSubs - currentSubs
|
||||
if (missingSubs.isNotEmpty()) {
|
||||
Log.i(TAG, "🔧 Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during subscription validation: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Started periodic subscription validation (${SUBSCRIPTION_VALIDATION_INTERVAL / 1000}s interval)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic subscription validation
|
||||
*/
|
||||
private fun stopSubscriptionValidation() {
|
||||
subscriptionValidationJob?.cancel()
|
||||
subscriptionValidationJob = null
|
||||
Log.v(TAG, "⏹️ Stopped subscription validation")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private suspend fun connectToRelay(urlString: String) {
|
||||
// Skip if we already have a connection
|
||||
if (connections.containsKey(urlString)) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.v(TAG, "Attempting to connect to Nostr relay: $urlString")
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
.build()
|
||||
|
||||
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
|
||||
connections[urlString] = webSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to create WebSocket connection to $urlString: ${e.message}")
|
||||
handleDisconnection(urlString, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
|
||||
try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl")
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
|
||||
updateRelaysList()
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(message: String, relayUrl: String) {
|
||||
try {
|
||||
val jsonElement = JsonParser.parseString(message)
|
||||
if (!jsonElement.isJsonArray) {
|
||||
Log.w(TAG, "Received non-array message from $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
val response = NostrResponse.fromJsonArray(jsonElement.asJsonArray)
|
||||
|
||||
when (response) {
|
||||
is NostrResponse.Event -> {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1
|
||||
updateRelaysList()
|
||||
|
||||
// DEDUPLICATION: Check if we've already processed this event
|
||||
val wasProcessed = eventDeduplicator.processEvent(response.event) { event ->
|
||||
// Only log non-gift-wrap events to reduce noise
|
||||
if (event.kind != NostrKind.GIFT_WRAP) {
|
||||
Log.v(TAG, "📥 Processing new Nostr event (kind: ${event.kind}) from relay: $relayUrl")
|
||||
}
|
||||
|
||||
// Call handler for new events only
|
||||
val handler = messageHandlers[response.subscriptionId]
|
||||
if (handler != null) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
handler(event)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ No handler for subscription ${response.subscriptionId}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasProcessed) {
|
||||
//Log.v(TAG, "🔄 Duplicate event ${response.event.id.take(16)}... from relay: $relayUrl")
|
||||
}
|
||||
}
|
||||
|
||||
is NostrResponse.EndOfStoredEvents -> {
|
||||
Log.v(TAG, "End of stored events for subscription: ${response.subscriptionId}")
|
||||
}
|
||||
|
||||
is NostrResponse.Ok -> {
|
||||
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
|
||||
if (response.accepted) {
|
||||
Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl")
|
||||
} else {
|
||||
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
|
||||
Log.println(level, TAG, "📮 Event ${response.eventId.take(16)}... rejected by relay: ${response.message ?: "no reason"}")
|
||||
}
|
||||
}
|
||||
|
||||
is NostrResponse.Notice -> {
|
||||
Log.i(TAG, "📢 Notice from $relayUrl: ${response.message}")
|
||||
}
|
||||
|
||||
is NostrResponse.Unknown -> {
|
||||
Log.v(TAG, "Unknown message type from $relayUrl: ${response.raw}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDisconnection(relayUrl: String, error: Throwable) {
|
||||
connections.remove(relayUrl)
|
||||
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
|
||||
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
|
||||
|
||||
updateRelayStatus(relayUrl, false, error)
|
||||
|
||||
// Check if this is a DNS error
|
||||
val errorMessage = error.message?.lowercase() ?: ""
|
||||
if (errorMessage.contains("hostname could not be found") ||
|
||||
errorMessage.contains("dns") ||
|
||||
errorMessage.contains("unable to resolve host")) {
|
||||
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
if (relay?.lastError == null) {
|
||||
Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Implement exponential backoff for non-DNS errors
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
relay.reconnectAttempts++
|
||||
|
||||
// Stop attempting after max attempts
|
||||
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate backoff interval
|
||||
val backoffInterval = min(
|
||||
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
|
||||
MAX_BACKOFF_INTERVAL.toDouble()
|
||||
).toLong()
|
||||
|
||||
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
||||
|
||||
Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})")
|
||||
|
||||
// Schedule reconnection
|
||||
scope.launch {
|
||||
delay(backoffInterval)
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
|
||||
val relay = relaysList.find { it.url == url } ?: return
|
||||
|
||||
relay.isConnected = isConnected
|
||||
relay.lastError = error
|
||||
|
||||
if (isConnected) {
|
||||
relay.lastConnectedAt = System.currentTimeMillis()
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
} else {
|
||||
relay.lastDisconnectedAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
updateRelaysList()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private fun updateRelaysList() {
|
||||
_relays.postValue(relaysList.toList())
|
||||
}
|
||||
|
||||
private fun updateConnectionStatus() {
|
||||
val connected = relaysList.any { it.isConnected }
|
||||
_isConnected.postValue(connected)
|
||||
}
|
||||
|
||||
private fun generateSubscriptionId(): String {
|
||||
return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all active subscriptions for a specific relay that just reconnected
|
||||
*/
|
||||
private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) {
|
||||
val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo ->
|
||||
// Include subscription if it targets all relays or specifically targets this relay
|
||||
subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)
|
||||
}
|
||||
|
||||
if (subscriptionsToRestore.isEmpty()) {
|
||||
Log.v(TAG, "🔄 No subscriptions to restore for relay: $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
|
||||
|
||||
subscriptionsToRestore.forEach { subscriptionInfo ->
|
||||
try {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Restored subscription '${subscriptionInfo.id}' to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket listener for relay connections
|
||||
*/
|
||||
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "✅ Connected to Nostr relay: $relayUrl")
|
||||
updateRelayStatus(relayUrl, true)
|
||||
|
||||
// Restore all active subscriptions for this relay
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
|
||||
// Process any queued messages for this relay
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (event, targetRelays) = iterator.next()
|
||||
if (relayUrl in targetRelays) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text, relayUrl)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closing for $relayUrl: $code $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed for $relayUrl: $code $reason")
|
||||
val error = Exception("WebSocket closed: $code $reason")
|
||||
handleDisconnection(relayUrl, error)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "❌ WebSocket failure for $relayUrl: ${t.message}")
|
||||
handleDisconnection(relayUrl, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.*
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Nostr protocol request messages
|
||||
* Supports EVENT, REQ, and CLOSE message types
|
||||
*/
|
||||
sealed class NostrRequest {
|
||||
|
||||
/**
|
||||
* EVENT message - publish an event
|
||||
*/
|
||||
data class Event(val event: NostrEvent) : NostrRequest()
|
||||
|
||||
/**
|
||||
* REQ message - subscribe to events
|
||||
*/
|
||||
data class Subscribe(
|
||||
val subscriptionId: String,
|
||||
val filters: List<NostrFilter>
|
||||
) : NostrRequest()
|
||||
|
||||
/**
|
||||
* CLOSE message - close a subscription
|
||||
*/
|
||||
data class Close(val subscriptionId: String) : NostrRequest()
|
||||
|
||||
/**
|
||||
* Custom JSON serializer for NostrRequest
|
||||
*/
|
||||
class RequestSerializer : JsonSerializer<NostrRequest> {
|
||||
override fun serialize(src: NostrRequest, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
|
||||
val array = JsonArray()
|
||||
|
||||
when (src) {
|
||||
is Event -> {
|
||||
array.add("EVENT")
|
||||
array.add(context.serialize(src.event))
|
||||
}
|
||||
|
||||
is Subscribe -> {
|
||||
array.add("REQ")
|
||||
array.add(src.subscriptionId)
|
||||
src.filters.forEach { filter ->
|
||||
array.add(context.serialize(filter, NostrFilter::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
is Close -> {
|
||||
array.add("CLOSE")
|
||||
array.add(src.subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
return array
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create Gson instance with proper serializers
|
||||
*/
|
||||
fun createGson(): Gson {
|
||||
return GsonBuilder()
|
||||
.registerTypeAdapter(NostrRequest::class.java, RequestSerializer())
|
||||
.registerTypeAdapter(NostrFilter::class.java, NostrFilter.FilterSerializer())
|
||||
.disableHtmlEscaping()
|
||||
.create()
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize request to JSON string
|
||||
*/
|
||||
fun toJson(request: NostrRequest): String {
|
||||
return createGson().toJson(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr protocol response messages
|
||||
* Handles EVENT, EOSE, OK, and NOTICE responses
|
||||
*/
|
||||
sealed class NostrResponse {
|
||||
|
||||
/**
|
||||
* EVENT response - received event from subscription
|
||||
*/
|
||||
data class Event(
|
||||
val subscriptionId: String,
|
||||
val event: NostrEvent
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* EOSE response - end of stored events
|
||||
*/
|
||||
data class EndOfStoredEvents(
|
||||
val subscriptionId: String
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* OK response - event publication result
|
||||
*/
|
||||
data class Ok(
|
||||
val eventId: String,
|
||||
val accepted: Boolean,
|
||||
val message: String?
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* NOTICE response - relay notice
|
||||
*/
|
||||
data class Notice(
|
||||
val message: String
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* Unknown response type
|
||||
*/
|
||||
data class Unknown(
|
||||
val raw: String
|
||||
) : NostrResponse()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse JSON array response
|
||||
*/
|
||||
fun fromJsonArray(jsonArray: JsonArray): NostrResponse {
|
||||
return try {
|
||||
when (val type = jsonArray[0].asString) {
|
||||
"EVENT" -> {
|
||||
if (jsonArray.size() >= 3) {
|
||||
val subscriptionId = jsonArray[1].asString
|
||||
val eventJson = jsonArray[2].asJsonObject
|
||||
val event = parseEventFromJson(eventJson)
|
||||
Event(subscriptionId, event)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"EOSE" -> {
|
||||
if (jsonArray.size() >= 2) {
|
||||
val subscriptionId = jsonArray[1].asString
|
||||
EndOfStoredEvents(subscriptionId)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"OK" -> {
|
||||
if (jsonArray.size() >= 3) {
|
||||
val eventId = jsonArray[1].asString
|
||||
val accepted = jsonArray[2].asBoolean
|
||||
val message = if (jsonArray.size() >= 4) {
|
||||
jsonArray[3].asString
|
||||
} else null
|
||||
Ok(eventId, accepted, message)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"NOTICE" -> {
|
||||
if (jsonArray.size() >= 2) {
|
||||
val message = jsonArray[1].asString
|
||||
Notice(message)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unknown(jsonArray.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventFromJson(jsonObject: JsonObject): NostrEvent {
|
||||
return NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTagsFromJson(tagsArray: JsonArray?): List<List<String>> {
|
||||
if (tagsArray == null) return emptyList()
|
||||
|
||||
return try {
|
||||
tagsArray.map { tagElement ->
|
||||
if (tagElement.isJsonArray) {
|
||||
val tagArray = tagElement.asJsonArray
|
||||
tagArray.map { it.asString }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* Test manager for Nostr functionality
|
||||
* Use this to verify the Nostr client works correctly
|
||||
*/
|
||||
class NostrTestManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrTestManager"
|
||||
}
|
||||
|
||||
private val testScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private lateinit var nostrClient: NostrClient
|
||||
|
||||
/**
|
||||
* Run comprehensive Nostr tests
|
||||
*/
|
||||
fun runTests() {
|
||||
Log.i(TAG, "🧪 Starting Nostr functionality tests...")
|
||||
|
||||
testScope.launch {
|
||||
try {
|
||||
// Test 1: Initialize client
|
||||
testClientInitialization()
|
||||
|
||||
// Test 2: Test identity generation and storage
|
||||
testIdentityManagement()
|
||||
|
||||
// Test 3: Test relay connections
|
||||
testRelayConnections()
|
||||
|
||||
// Test 4: Test cryptography
|
||||
testCryptography()
|
||||
|
||||
// Test 5: Test Bech32 encoding
|
||||
testBech32()
|
||||
|
||||
// Test 6: Test message subscription (without sending)
|
||||
testMessageSubscription()
|
||||
|
||||
Log.i(TAG, "✅ All Nostr tests completed successfully!")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Nostr tests failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun testClientInitialization() {
|
||||
Log.d(TAG, "Testing client initialization...")
|
||||
|
||||
nostrClient = NostrClient.getInstance(context)
|
||||
nostrClient.initialize()
|
||||
|
||||
// Wait for initialization
|
||||
delay(2000)
|
||||
|
||||
val isInitialized = nostrClient.isInitialized.value ?: false
|
||||
require(isInitialized) { "Client failed to initialize" }
|
||||
|
||||
Log.d(TAG, "✅ Client initialization successful")
|
||||
}
|
||||
|
||||
private suspend fun testIdentityManagement() {
|
||||
Log.d(TAG, "Testing identity management...")
|
||||
|
||||
// Test current identity
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
requireNotNull(identity) { "No current identity" }
|
||||
|
||||
Log.d(TAG, "Current identity npub: ${identity.getShortNpub()}")
|
||||
require(identity.npub.startsWith("npub1")) { "Invalid npub format" }
|
||||
require(identity.publicKeyHex.length == 64) { "Invalid public key length" }
|
||||
require(identity.privateKeyHex.length == 64) { "Invalid private key length" }
|
||||
|
||||
// Test geohash identity derivation
|
||||
val geohashIdentity = NostrIdentityBridge.deriveIdentity("u4pruydq", context)
|
||||
require(geohashIdentity.npub.startsWith("npub1")) { "Invalid geohash identity npub" }
|
||||
require(geohashIdentity.publicKeyHex != identity.publicKeyHex) { "Geohash identity should be different" }
|
||||
|
||||
Log.d(TAG, "Geohash identity npub: ${geohashIdentity.getShortNpub()}")
|
||||
Log.d(TAG, "✅ Identity management test successful")
|
||||
}
|
||||
|
||||
private suspend fun testRelayConnections() {
|
||||
Log.d(TAG, "Testing relay connections...")
|
||||
|
||||
// Wait for potential relay connections
|
||||
delay(3000)
|
||||
|
||||
val relayInfo = nostrClient.relayInfo.value ?: emptyList()
|
||||
require(relayInfo.isNotEmpty()) { "No relays configured" }
|
||||
|
||||
Log.d(TAG, "Configured relays: ${relayInfo.size}")
|
||||
relayInfo.forEach { relay ->
|
||||
Log.d(TAG, "Relay: ${relay.url} - Connected: ${relay.isConnected}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Relay configuration test successful")
|
||||
}
|
||||
|
||||
private suspend fun testCryptography() {
|
||||
Log.d(TAG, "Testing cryptography functions...")
|
||||
|
||||
// Test key generation
|
||||
val (privateKey, publicKey) = NostrCrypto.generateKeyPair()
|
||||
require(privateKey.length == 64) { "Invalid private key length" }
|
||||
require(publicKey.length == 64) { "Invalid public key length" }
|
||||
require(NostrCrypto.isValidPrivateKey(privateKey)) { "Generated private key is invalid" }
|
||||
require(NostrCrypto.isValidPublicKey(publicKey)) { "Generated public key is invalid" }
|
||||
|
||||
// Test key derivation
|
||||
val derivedPublic = NostrCrypto.derivePublicKey(privateKey)
|
||||
require(derivedPublic == publicKey) { "Key derivation mismatch" }
|
||||
|
||||
// Test encryption/decryption
|
||||
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
|
||||
val plaintext = "Hello, Nostr world! This is a test message."
|
||||
|
||||
val encrypted = NostrCrypto.encryptNIP44(plaintext, recipientPublic, privateKey)
|
||||
require(encrypted.isNotEmpty()) { "Encryption failed" }
|
||||
|
||||
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
|
||||
require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" }
|
||||
|
||||
Log.d(TAG, "✅ Cryptography test successful")
|
||||
}
|
||||
|
||||
private suspend fun testBech32() {
|
||||
Log.d(TAG, "Testing Bech32 encoding...")
|
||||
|
||||
val testData = "hello world test data for bech32".toByteArray()
|
||||
val encoded = Bech32.encode("test", testData)
|
||||
require(encoded.startsWith("test1")) { "Invalid bech32 encoding" }
|
||||
|
||||
val (hrp, decoded) = Bech32.decode(encoded)
|
||||
require(hrp == "test") { "HRP mismatch" }
|
||||
require(decoded.contentEquals(testData)) { "Data mismatch after decode" }
|
||||
|
||||
// Test with actual public key
|
||||
val (_, publicKey) = NostrCrypto.generateKeyPair()
|
||||
val npub = Bech32.encode("npub", publicKey.hexToByteArray())
|
||||
require(npub.startsWith("npub1")) { "Invalid npub encoding" }
|
||||
|
||||
val (npubHrp, npubData) = Bech32.decode(npub)
|
||||
require(npubHrp == "npub") { "npub HRP mismatch" }
|
||||
require(npubData.toHexString() == publicKey) { "npub data mismatch" }
|
||||
|
||||
Log.d(TAG, "✅ Bech32 test successful")
|
||||
}
|
||||
|
||||
private suspend fun testMessageSubscription() {
|
||||
Log.d(TAG, "Testing message subscription...")
|
||||
|
||||
var messageReceived = false
|
||||
|
||||
// Subscribe to private messages (won't receive any in test, but tests the subscription mechanism)
|
||||
nostrClient.subscribeToPrivateMessages { content, senderNpub, timestamp ->
|
||||
Log.d(TAG, "📥 Received test private message from $senderNpub: $content")
|
||||
messageReceived = true
|
||||
}
|
||||
|
||||
// Subscribe to a test geohash
|
||||
nostrClient.subscribeToGeohash("u4pru") { content, senderPubkey, nickname, timestamp ->
|
||||
Log.d(TAG, "📥 Received test geohash message from ${senderPubkey.take(16)}...: $content")
|
||||
messageReceived = true
|
||||
}
|
||||
|
||||
// Wait a bit to see if any messages come through
|
||||
delay(2000)
|
||||
|
||||
Log.d(TAG, "✅ Message subscription test successful (no messages expected in test)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending a message to yourself (loopback test)
|
||||
*/
|
||||
fun testLoopbackMessage() {
|
||||
testScope.launch {
|
||||
try {
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
requireNotNull(identity) { "No identity available for loopback test" }
|
||||
|
||||
Log.i(TAG, "🔄 Testing loopback private message...")
|
||||
|
||||
// Send message to ourselves
|
||||
nostrClient.sendPrivateMessage(
|
||||
content = "Test loopback message at ${System.currentTimeMillis()}",
|
||||
recipientNpub = identity.npub,
|
||||
onSuccess = {
|
||||
Log.i(TAG, "✅ Loopback message sent successfully")
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e(TAG, "❌ Loopback message failed: $error")
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Loopback test failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending a geohash message
|
||||
*/
|
||||
fun testGeohashMessage() {
|
||||
testScope.launch {
|
||||
try {
|
||||
Log.i(TAG, "🌍 Testing geohash message...")
|
||||
|
||||
nostrClient.sendGeohashMessage(
|
||||
content = "Test geohash message from Android at ${System.currentTimeMillis()}",
|
||||
geohash = "u4pru",
|
||||
nickname = "android-test",
|
||||
onSuccess = {
|
||||
Log.i(TAG, "✅ Geohash message sent successfully")
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e(TAG, "❌ Geohash message failed: $error")
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Geohash test failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information about the Nostr client
|
||||
*/
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("=== Nostr Client Debug Info ===")
|
||||
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
if (identity != null) {
|
||||
appendLine("Identity: ${identity.getShortNpub()}")
|
||||
appendLine("Public Key: ${identity.publicKeyHex.take(16)}...")
|
||||
appendLine("Created: ${java.util.Date(identity.createdAt)}")
|
||||
} else {
|
||||
appendLine("No identity loaded")
|
||||
}
|
||||
|
||||
val isInitialized = nostrClient.isInitialized.value ?: false
|
||||
appendLine("Initialized: $isInitialized")
|
||||
|
||||
val isConnected = nostrClient.relayConnectionStatus.value ?: false
|
||||
appendLine("Relay Connected: $isConnected")
|
||||
|
||||
val relays = nostrClient.relayInfo.value ?: emptyList()
|
||||
appendLine("Relays (${relays.size}):")
|
||||
relays.forEach { relay ->
|
||||
appendLine(" ${relay.url}: ${if (relay.isConnected) "✅" else "❌"} (sent: ${relay.messagesSent}, received: ${relay.messagesReceived})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown test manager
|
||||
*/
|
||||
fun shutdown() {
|
||||
testScope.cancel()
|
||||
nostrClient.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* Minimal Nostr transport for offline sending
|
||||
* Direct port from iOS NostrTransport for 100% compatibility
|
||||
*/
|
||||
class NostrTransport(
|
||||
private val context: Context,
|
||||
var senderPeerID: String = ""
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrTransport"
|
||||
private const val READ_ACK_INTERVAL = 350L // ~3 per second (0.35s interval like iOS)
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrTransport? = null
|
||||
|
||||
fun getInstance(context: Context): NostrTransport {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrTransport(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits (like iOS)
|
||||
private data class QueuedRead(
|
||||
val receipt: ReadReceipt,
|
||||
val peerID: String
|
||||
)
|
||||
|
||||
private val readQueue = ConcurrentLinkedQueue<QueuedRead>()
|
||||
private var isSendingReadAcks = false
|
||||
private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// MARK: - Transport Interface Methods
|
||||
|
||||
val myPeerID: String get() = senderPeerID
|
||||
|
||||
fun sendPrivateMessage(
|
||||
content: String,
|
||||
to: String,
|
||||
recipientNickname: String,
|
||||
messageID: String
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
// Resolve favorite by full noise key or by short peerID fallback
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
// This would need integration with the existing favorites system
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for peerID: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
|
||||
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(receipt: ReadReceipt, to: String) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.offer(QueuedRead(receipt, to))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
private fun processReadQueueIfNeeded() {
|
||||
if (isSendingReadAcks) return
|
||||
if (readQueue.isEmpty()) return
|
||||
|
||||
isSendingReadAcks = true
|
||||
sendNextReadAck()
|
||||
}
|
||||
|
||||
private fun sendNextReadAck() {
|
||||
val item = readQueue.poll()
|
||||
if (item == null) {
|
||||
isSendingReadAcks = false
|
||||
return
|
||||
}
|
||||
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for read receipt")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
messageID = item.receipt.originalMessageID,
|
||||
recipientPeerID = item.peerID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (ack == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed READ ack")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
scheduleNextReadAck()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}")
|
||||
scheduleNextReadAck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleNextReadAck() {
|
||||
transportScope.launch {
|
||||
delay(READ_ACK_INTERVAL)
|
||||
isSendingReadAcks = false
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for favorite notification")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val content = if (isFavorite) {
|
||||
"[FAVORITED]:${senderIdentity.npub}"
|
||||
} else {
|
||||
"[UNFAVORITED]:${senderIdentity.npub}"
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
messageID = UUID.randomUUID().toString(),
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed favorite notification")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDeliveryAck(messageID: String, to: String) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for delivery ack")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (ack == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers (for per-geohash identity DMs)
|
||||
|
||||
fun sendDeliveryAckGeohash(
|
||||
messageID: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send DELIVERED -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) return@launch
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceiptGeohash(
|
||||
messageID: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send READ -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) return@launch
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
|
||||
fun sendPrivateMessageGeohash(
|
||||
content: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity,
|
||||
messageID: String
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
if (toRecipientHex.isEmpty()) return@launch
|
||||
|
||||
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/**
|
||||
* Resolve Nostr public key for a peer ID
|
||||
*/
|
||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||
try {
|
||||
// Try to resolve from favorites persistence service
|
||||
val noiseKey = hexStringToByteArray(peerID)
|
||||
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
||||
return favoriteStatus.peerNostrPublicKey
|
||||
}
|
||||
|
||||
// Fallback: try with 16-hex peerID lookup
|
||||
if (peerID.length == 16) {
|
||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
return fallbackStatus?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resolve Nostr public key for $peerID: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array (8 bytes)
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
transportScope.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user