Files
bitchat-android/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
T
callebtcandGitHub 91f3f270d4 Fix geohash filtering (#376)
* fix(geohash): enforce client-side filter on subscription and validate 'g' tag in handler; move dedup after validation; avoid misrouting events across geohashes

* cleanup duplicate code block

* remove
2025-09-05 13:26:38 +02:00

893 lines
34 KiB
Kotlin

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)
}
fun defaultRelays(): List<String> = DEFAULT_RELAYS
}
/**
* 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(),
val originGeohash: String? = null // used for logging and grouping
)
// 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 (via provider to honor Tor)
private val httpClient: OkHttpClient
get() = com.bitchat.android.net.OkHttpProvider.webSocketClient()
private val gson by lazy { NostrRequest.createGson() }
// Per-geohash relay selection
private val geohashToRelays = ConcurrentHashMap<String, Set<String>>() // geohash -> relay URLs
// --- Public API for geohash-specific operation ---
/**
* Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping.
*/
fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) {
try {
val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays)
val selected = if (includeDefaults) {
(nearest + Companion.defaultRelays()).toSet()
} else nearest.toSet()
if (selected.isEmpty()) {
Log.w(TAG, "No relays selected for geohash=$geohash")
return
}
geohashToRelays[geohash] = selected
Log.i(TAG, "🌐 Geohash $geohash using ${selected.size} relays: ${selected.joinToString()}")
ensureConnectionsFor(selected)
} catch (e: Exception) {
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
}
}
/**
* Get relays mapped to a geohash (empty list if none configured).
*/
fun getRelaysForGeohash(geohash: String): List<String> {
return geohashToRelays[geohash]?.toList() ?: emptyList()
}
/**
* Subscribe with explicit geohash routing; ensures connections exist, then targets only those relays.
*/
fun subscribeForGeohash(
geohash: String,
filter: NostrFilter,
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
includeDefaults: Boolean = false,
nRelays: Int = 5
): String {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
val relayUrls = getRelaysForGeohash(geohash)
Log.d(TAG, "📡 Subscribing id=$id for geohash=$geohash on ${relayUrls.size} relays")
return subscribe(
filter = filter,
id = id,
handler = handler,
targetRelayUrls = relayUrls
).also {
// update origin geohash for this subscription
activeSubscriptions[it]?.let { sub ->
activeSubscriptions[it] = sub.copy(originGeohash = geohash)
}
}
}
/**
* Send an event specifically to a geohash's relays (+ optional defaults).
*/
fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
val relayUrls = getRelaysForGeohash(geohash)
if (relayUrls.isEmpty()) {
Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults")
sendEvent(event, Companion.defaultRelays())
return
}
Log.v(TAG, "📤 Sending event kind=${event.kind} to ${relayUrls.size} relays for geohash=$geohash")
sendEvent(event, relayUrls)
}
// --- Internal helpers ---
private fun ensureConnectionsFor(relayUrls: Set<String>) {
// Ensure relays are tracked for UI/status
relayUrls.forEach { url ->
if (relaysList.none { it.url == url }) {
relaysList.add(Relay(url))
}
}
updateRelaysList()
scope.launch {
relayUrls.forEach { relayUrl ->
launch {
if (!connections.containsKey(relayUrl)) {
connectToRelay(relayUrl)
}
}
}
}
}
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)
}
}
}
/**
* Clear all subscription tracking, message handlers, routing caches, and queued messages.
* Intended for panic/reset flows prior to reconnecting and re-subscribing from scratch.
*/
fun clearAllSubscriptions() {
try {
// Clear persistent subscription tracking
activeSubscriptions.clear()
messageHandlers.clear()
subscriptions.clear()
// Clear routing caches (per-geohash relay selections)
geohashToRelays.clear()
// Clear any queued messages waiting to be sent
synchronized(messageQueueLock) {
messageQueue.clear()
}
Log.i(TAG, "🧹 Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear subscriptions: ${e.message}")
}
}
/**
* 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()
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
activeSubscriptions[response.subscriptionId]?.let { subInfo ->
val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true }
if (!matches) {
Log.v(TAG, "🚫 Dropping event ${response.event.id.take(16)}... not matching filter for sub=${response.subscriptionId}")
// Do NOT call deduplicator here to allow the correct subscription to process it later
return
}
}
// 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) {
val originGeo = activeSubscriptions[response.subscriptionId]?.originGeohash
if (originGeo != null) {
Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl geo=$originGeo sub=${response.subscriptionId}")
} else {
Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl sub=${response.subscriptionId}")
}
}
// 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)
}
}
}