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:
callebtc
2025-08-22 19:09:18 +02:00
committed by GitHub
parent 848cffee07
commit 7243d841a3
35 changed files with 7859 additions and 171 deletions
@@ -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()
}
}