diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4fe95935..3600fee78 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,7 @@ android { jvmTarget = "1.8" } buildFeatures { + buildConfig = true compose = true } packaging { diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 5d1f4b0b7..03de42287 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.Lifecycle import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.onboarding.BluetoothCheckScreen import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.BluetoothStatusManager @@ -76,7 +77,7 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + NostrRelayManager.shared.setDiagnosticLogging(BuildConfig.DEBUG) // Register receiver for force finish signal from shutdown coordinator val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) if (android.os.Build.VERSION.SDK_INT >= 33) { diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt index bed29902e..141387a40 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -29,14 +29,15 @@ object LocationNotesInitializer { } Log.d(TAG, "๐Ÿ“ Location Notes subscribing to geohash: $geohashFromFilter") - + + val optimalRelays = NostrRelayManager.optimalRelayCount(geohashFromFilter) NostrRelayManager.getInstance(context).subscribeForGeohash( geohash = geohashFromFilter, filter = filter, id = id, handler = handler, includeDefaults = true, - nRelays = 5 + nRelays = optimalRelays ) }, unsubscribe = { id -> diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDiagnosticsReporter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDiagnosticsReporter.kt new file mode 100644 index 000000000..1f2fc32c2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDiagnosticsReporter.kt @@ -0,0 +1,290 @@ +package com.bitchat.android.nostr + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles diagnostics reporting and health checks for Nostr relays + * Separates diagnostics logic from relay management + */ +class NostrDiagnosticsReporter( + private val metricsCollector: NostrMetricsCollector, + private val scope: CoroutineScope, + private val getActiveSubscriptions: () -> Map, + private val getConnections: () -> Map, + private val getSubscriptions: () -> Map> +) { + + companion object { + private const val TAG = "NostrDiagnostics" + private const val HEALTH_CHECK_INTERVAL = 300000L // 5 minutes + private const val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds + } + + private var healthCheckJob: Job? = null + private var subscriptionValidationJob: Job? = null + var diagnosticLoggingEnabled = false + + /** + * Start periodic health check logging + */ + fun startHealthCheck() { + stopHealthCheck() + + healthCheckJob = scope.launch { + while (isActive) { + delay(HEALTH_CHECK_INTERVAL) + + try { + val metrics = metricsCollector.getMetrics() + val activeSubscriptions = getActiveSubscriptions() + val now = System.currentTimeMillis() + + // Find old subscriptions + val oldSubs = activeSubscriptions.filter { (_, info) -> + (now - info.createdAt) > 21600000 // > 6 hours + } + + val mbReceived = metrics.totalBytesReceived / 1048576.0 + val ratePerHour = if (metrics.uptimeHours > 0) mbReceived / metrics.uptimeHours else 0.0 + + Log.i(TAG, """ + โ•โ•โ• HEALTH CHECK โ•โ•โ• + ๐Ÿ“Š Subscriptions: ${activeSubscriptions.size} active, ${oldSubs.size} old (>6h) + ๐Ÿ“ก Relays: ${getConnections().size} connected, ${metrics.reconnectionCount} reconnections + ๐Ÿ“ˆ Data: ${formatBytes(metrics.totalBytesReceived)} received (${String.format("%.1f", ratePerHour)} MB/h) + ๐Ÿ“ฅ Events: ${metrics.totalEventsReceived} (avg ${formatBytes(metrics.averageEventSize)}) + ${if (oldSubs.size > 5) "โš ๏ธ WARNING: ${oldSubs.size} old subscriptions (possible leaks)" else ""} + ${if (ratePerHour > 100) "โš ๏ธ WARNING: High bandwidth (${String.format("%.1f", ratePerHour)} MB/h)" else ""} + """.trimIndent()) + + if (oldSubs.size > 10 || ratePerHour > 500) { + Log.w(TAG, "๐Ÿšจ CRITICAL ISSUE DETECTED - Full diagnostics:") + Log.w(TAG, generateDiagnosticsReport()) + } + } catch (e: Exception) { + Log.e(TAG, "Error during health check: ${e.message}") + } + } + } + + Log.d(TAG, "๐Ÿ”„ Started periodic health check (${HEALTH_CHECK_INTERVAL / 1000}s interval)") + } + + /** + * Stop health check logging + */ + fun stopHealthCheck() { + healthCheckJob?.cancel() + healthCheckJob = null + } + + /** + * Generate comprehensive diagnostics report + */ + fun generateDiagnosticsReport(): String { + val metrics = metricsCollector.getMetrics() + val activeSubscriptions = getActiveSubscriptions() + val connections = getConnections() + val now = System.currentTimeMillis() + + // Group subscriptions by age + val subsByAge = activeSubscriptions.entries.groupBy { (_, info) -> + val ageHours = (now - info.createdAt) / 3600000 + when { + ageHours < 1 -> "0-1h" + ageHours < 6 -> "1-6h" + ageHours < 24 -> "6-24h" + else -> "24h+" + } + } + + // Find old subscriptions (likely leaks) + val oldSubs = activeSubscriptions.filter { (_, info) -> + (now - info.createdAt) > 21600000 // > 6 hours + } + + // Find top event-receiving subscriptions + val topSubs = metrics.eventsReceivedPerSubscription.entries + .sortedByDescending { it.value } + .take(10) + + // Calculate per-relay bandwidth + val relayBandwidth = metrics.bytesReceivedPerRelay.entries + .sortedByDescending { it.value } + .take(10) + + return """ +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + NOSTR DATA USAGE DIAGNOSTICS REPORT +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +๐Ÿ“Š OVERVIEW +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Active Subscriptions: ${activeSubscriptions.size} +Connected Relays: ${connections.size} +Reconnections: ${metrics.reconnectionCount} +Uptime: ${String.format("%.1f", metrics.uptimeHours)} hours + +๐Ÿ” DATA TRANSFER +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Total Sent: ${formatBytes(metrics.totalBytesSent)} +Total Received: ${formatBytes(metrics.totalBytesReceived)} +Total: ${formatBytes(metrics.totalBytesSent + metrics.totalBytesReceived)} + +Events Received: ${metrics.totalEventsReceived} +Average Event Size: ${formatBytes(metrics.averageEventSize)} +${if (metrics.averageEventSize > 5000) "โš ๏ธ WARNING: Large average event size!" else ""} + +Rate: ${String.format("%.1f", metrics.bandwidthPerHour)} MB/hour +Projected Weekly: ${String.format("%.1f", metrics.bandwidthPerHour * 168)} MB + +โš ๏ธ LEAK DETECTION +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +Subscriptions by Age: + 0-1 hour: ${subsByAge["0-1h"]?.size ?: 0} subs + 1-6 hours: ${subsByAge["1-6h"]?.size ?: 0} subs + 6-24 hours: ${subsByAge["6-24h"]?.size ?: 0} subs + 24+ hours: ${subsByAge["24h+"]?.size ?: 0} subs + +Old Subscriptions (>6h): ${oldSubs.size} +${if (oldSubs.size > 5) "โš ๏ธ WARNING: Likely subscription leaks detected!" else ""} +${if (oldSubs.size > 20) "๐Ÿšจ CRITICAL: Severe subscription leak! ${oldSubs.size} old subscriptions!" else ""} + +๐Ÿ“ˆ TOP EVENT-RECEIVING SUBSCRIPTIONS +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +${topSubs.joinToString("\n") { (subId, count) -> + val info = activeSubscriptions[subId] + val ageMin = if (info != null) (now - info.createdAt) / 60000 else 0 + val geo = info?.originGeohash ?: "unknown" + " $subId: $count events (age: ${ageMin}min, geo: $geo)" + }} +${if (topSubs.isEmpty()) " No events received yet" else ""} + +๐Ÿ“ก TOP BANDWIDTH-CONSUMING RELAYS +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +${relayBandwidth.joinToString("\n") { (relay, bytes) -> + " ${relay.substringAfter("wss://")}: ${formatBytes(bytes)}" + }} + +๐Ÿ”ง SUBSCRIPTION DETAILS (first 20) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +${activeSubscriptions.entries.take(20).joinToString("\n") { (id, info) -> + val ageMin = (now - info.createdAt) / 60000 + val eventCount = metrics.eventsReceivedPerSubscription[id] ?: 0 + val targets = info.targetRelayUrls?.size ?: connections.size + val geo = info.originGeohash?.take(6) ?: "global" + " $id: age=${ageMin}min, events=$eventCount, relays=$targets, geo=$geo" + }} +${if (activeSubscriptions.size > 20) " ... and ${activeSubscriptions.size - 20} more subscriptions" else ""} + +๐Ÿฉบ HEALTH STATUS +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +${when { + activeSubscriptions.size > 100 -> "๐Ÿšจ CRITICAL: Too many active subscriptions (${activeSubscriptions.size})" + activeSubscriptions.size > 50 -> "โš ๏ธ WARNING: High subscription count (${activeSubscriptions.size})" + oldSubs.size > 20 -> "๐Ÿšจ CRITICAL: Severe subscription leaks (${oldSubs.size} old subs)" + oldSubs.size > 5 -> "โš ๏ธ WARNING: Possible subscription leaks (${oldSubs.size} old subs)" + metrics.reconnectionCount > 50 -> "โš ๏ธ WARNING: High reconnection rate (${metrics.reconnectionCount})" + metrics.averageEventSize > 10000 -> "โš ๏ธ WARNING: Very large events (${formatBytes(metrics.averageEventSize)} avg)" + metrics.bandwidthPerHour > 100 -> "โš ๏ธ WARNING: High bandwidth usage (${String.format("%.1f", metrics.bandwidthPerHour)} MB/hour)" + else -> "โœ… All metrics look healthy" + }} + +๐Ÿ“‹ RECOMMENDATIONS +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +${generateRecommendations(oldSubs.size, metrics.averageEventSize, metrics.reconnectionCount.toInt(), metrics.bandwidthPerHour)} + +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +Generated: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(now))} +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + """.trimIndent() + } + + /** + * Validate subscription consistency between client and relays + */ + fun validateSubscriptionConsistency(): SubscriptionConsistencyReport { + val expectedSubs = getActiveSubscriptions().keys + val actualSubsByRelay = getSubscriptions().toMap() + val inconsistencies = mutableListOf() + + val connections = getConnections() + for ((relayUrl, _) in connections) { + val actualSubs = actualSubsByRelay[relayUrl] ?: emptySet() + val expectedSubsForRelay = getActiveSubscriptions().filter { (_, info) -> + info.targetRelayUrls == null || info.targetRelayUrls.contains(relayUrl) + }.keys + + val missing = expectedSubsForRelay - actualSubs + val extra = actualSubs - expectedSubs + + 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, + connectedRelayCount = connections.size + ) + } + + private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1048576 -> String.format("%.1f KB", bytes / 1024.0) + bytes < 1073741824 -> String.format("%.1f MB", bytes / 1048576.0) + else -> String.format("%.2f GB", bytes / 1073741824.0) + } + } + + private fun generateRecommendations(oldSubCount: Int, avgEventSize: Long, reconnections: Int, bandwidthPerHour: Double): String { + val recommendations = mutableListOf() + + if (oldSubCount > 20) { + recommendations.add("๐Ÿšจ URGENT: Clear all subscriptions with panicReset() and restart") + } else if (oldSubCount > 5) { + recommendations.add("โš ๏ธ Call clearAllSubscriptions() to remove leaked subscriptions") + } + + if (avgEventSize > 10000) { + recommendations.add("โš ๏ธ Events are very large - investigate event content") + } + + if (reconnections > 50) { + recommendations.add("โš ๏ธ Network unstable - check WiFi/Tor connection") + } + + if (bandwidthPerHour > 100) { + recommendations.add("โš ๏ธ High bandwidth - consider reducing relay count from 5 to 2") + } + + return if (recommendations.isEmpty()) { + "โœ… No issues detected" + } else { + recommendations.joinToString("\n") + } + } +} + +/** + * Report of subscription consistency check + */ +data class SubscriptionConsistencyReport( + val isConsistent: Boolean, + val inconsistencies: List, + val connectedRelayCount: Int +) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index 247162a04..d6a322b78 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -42,7 +42,31 @@ data class NostrFilter( limit = limit ) } - + + /** + * Create filter for geohash chat messages only (kind 20000) + */ + fun geohashMessages(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 geohash presence heartbeats only (kind 20001) + */ + fun geohashPresence(geohash: String, since: Long? = null, limit: Int = 100): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + /** * Create filter for text notes from specific authors */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMetricsCollector.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMetricsCollector.kt new file mode 100644 index 000000000..6544e7eac --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMetricsCollector.kt @@ -0,0 +1,139 @@ +package com.bitchat.android.nostr + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Collects and tracks Nostr relay metrics + * Separates metric collection from relay management logic + */ +class NostrMetricsCollector { + + // Atomic counters for thread-safe updates + private val totalBytesReceived = AtomicLong(0) + private val totalBytesSent = AtomicLong(0) + private val totalEventsReceived = AtomicLong(0) + private val reconnectionCount = AtomicLong(0) + + // Per-relay and per-subscription tracking + private val bytesReceivedPerRelay = ConcurrentHashMap() + private val bytesSentPerRelay = ConcurrentHashMap() + private val eventsReceivedPerSubscription = ConcurrentHashMap() + + // Relay message counters + private val relayMessagesSent = ConcurrentHashMap() + private val relayMessagesReceived = ConcurrentHashMap() + + // Track start time for uptime calculation + private val startTime = System.currentTimeMillis() + + /** + * Record bytes received from a relay + */ + fun recordBytesReceived(relayUrl: String, bytes: Long) { + totalBytesReceived.addAndGet(bytes) + bytesReceivedPerRelay.merge(relayUrl, bytes) { old, new -> old + new } + } + + /** + * Record bytes sent to a relay + */ + fun recordBytesSent(relayUrl: String, bytes: Long) { + totalBytesSent.addAndGet(bytes) + bytesSentPerRelay.merge(relayUrl, bytes) { old, new -> old + new } + } + + /** + * Record an event received for a subscription + */ + fun recordEventReceived(subscriptionId: String, eventSize: Long) { + totalEventsReceived.incrementAndGet() + eventsReceivedPerSubscription.merge(subscriptionId, 1) { old, _ -> old + 1 } + } + + /** + * Record a relay reconnection + */ + fun recordReconnection() { + reconnectionCount.incrementAndGet() + } + + /** + * Record a message sent to a relay + */ + fun recordMessageSent(relayUrl: String) { + relayMessagesSent.merge(relayUrl, 1) { old, _ -> old + 1 } + } + + /** + * Record a message received from a relay + */ + fun recordMessageReceived(relayUrl: String) { + relayMessagesReceived.merge(relayUrl, 1) { old, _ -> old + 1 } + } + + /** + * Remove subscription tracking when unsubscribed + */ + fun removeSubscription(subscriptionId: String) { + eventsReceivedPerSubscription.remove(subscriptionId) + } + + /** + * Get current metrics snapshot + */ + fun getMetrics(): NostrMetrics { + return NostrMetrics( + totalBytesReceived = totalBytesReceived.get(), + totalBytesSent = totalBytesSent.get(), + totalEventsReceived = totalEventsReceived.get(), + reconnectionCount = reconnectionCount.get(), + bytesReceivedPerRelay = bytesReceivedPerRelay.toMap(), + bytesSentPerRelay = bytesSentPerRelay.toMap(), + eventsReceivedPerSubscription = eventsReceivedPerSubscription.toMap(), + relayMessagesSent = relayMessagesSent.toMap(), + relayMessagesReceived = relayMessagesReceived.toMap(), + uptimeMillis = System.currentTimeMillis() - startTime + ) + } + + /** + * Reset all metrics (for testing or panic reset) + */ + fun reset() { + totalBytesReceived.set(0) + totalBytesSent.set(0) + totalEventsReceived.set(0) + reconnectionCount.set(0) + bytesReceivedPerRelay.clear() + bytesSentPerRelay.clear() + eventsReceivedPerSubscription.clear() + relayMessagesSent.clear() + relayMessagesReceived.clear() + } +} + +/** + * Immutable snapshot of Nostr metrics + */ +data class NostrMetrics( + val totalBytesReceived: Long, + val totalBytesSent: Long, + val totalEventsReceived: Long, + val reconnectionCount: Long, + val bytesReceivedPerRelay: Map, + val bytesSentPerRelay: Map, + val eventsReceivedPerSubscription: Map, + val relayMessagesSent: Map, + val relayMessagesReceived: Map, + val uptimeMillis: Long +) { + val uptimeHours: Double + get() = uptimeMillis / 3600000.0 + + val averageEventSize: Long + get() = if (totalEventsReceived > 0) totalBytesReceived / totalEventsReceived else 0 + + val bandwidthPerHour: Double + get() = if (uptimeHours > 0) (totalBytesReceived / 1048576.0) / uptimeHours else 0.0 +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index d44e6e0bb..3ae73fa5e 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -46,7 +46,9 @@ class NostrRelayManager private constructor() { private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS - + + fun optimalRelayCount(geohash: String): Int = 3 + // Track gift-wraps we initiated for logging private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() @@ -123,6 +125,10 @@ class NostrRelayManager private constructor() { // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs + // Metrics and diagnostics - delegated to specialized classes + internal val metricsCollector = NostrMetricsCollector() + private lateinit var diagnosticsReporter: NostrDiagnosticsReporter + // --- Public API for geohash-specific operation --- /** @@ -229,6 +235,16 @@ class NostrRelayManager private constructor() { relaysList.addAll(defaultRelayUrls.map { Relay(it) }) _relays.value = relaysList.toList() updateConnectionStatus() + + // Initialize diagnostics reporter + diagnosticsReporter = NostrDiagnosticsReporter( + metricsCollector = metricsCollector, + scope = scope, + getActiveSubscriptions = { activeSubscriptions.toMap() }, + getConnections = { connections.toMap() }, + getSubscriptions = { subscriptions.toMap() } + ) + Log.d(TAG, "โœ… NostrRelayManager initialized with ${relaysList.size} default relays") } catch (e: Exception) { Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) @@ -318,8 +334,27 @@ class NostrRelayManager private constructor() { activeSubscriptions[id] = subscriptionInfo messageHandlers[id] = handler - - Log.d(TAG, "๐Ÿ“ก Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}") + + // Diagnostic logging with stack trace to identify subscription source + if (diagnosticsReporter.diagnosticLoggingEnabled) { + val stackTrace = Thread.currentThread().stackTrace + .take(8) + .drop(2) // Skip Thread.getStackTrace() and this method + .joinToString("\n") { " at $it" } + Log.d( + TAG, """ + โž• SUBSCRIPTION CREATED + ID: $id + Filter: ${filter.getDebugDescription()} + Targets: ${targetRelayUrls?.size ?: "all"} relays + Total active: ${activeSubscriptions.size} + Stack trace: + $stackTrace + """.trimIndent() + ) + } else { + Log.d(TAG, "๐Ÿ“ก Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}") + } // Send subscription to appropriate relays sendSubscriptionToRelays(subscriptionInfo) @@ -344,12 +379,16 @@ class NostrRelayManager private constructor() { val webSocket = connections[relayUrl] if (webSocket != null) { try { + // Track bytes sent + val messageSize = message.length.toLong() + metricsCollector.recordBytesSent(relayUrl, messageSize) + 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") @@ -380,8 +419,29 @@ class NostrRelayManager private constructor() { Log.w(TAG, "โš ๏ธ Attempted to unsubscribe from unknown subscription: $id") return } - - Log.d(TAG, "๐Ÿšซ Unsubscribing from subscription: $id") + + // Diagnostic logging + val subscriptionAge = System.currentTimeMillis() - subscriptionInfo.createdAt + val eventCount = metricsCollector.getMetrics().eventsReceivedPerSubscription[id] ?: 0 + metricsCollector.removeSubscription(id) + + if (diagnosticsReporter.diagnosticLoggingEnabled) { + val stackTrace = Thread.currentThread().stackTrace + .take(8) + .drop(2) + .joinToString("\n") { " at $it" } + Log.d(TAG, """ + โž– SUBSCRIPTION CLOSED + ID: $id + Age: ${subscriptionAge / 60000} minutes + Events received: $eventCount + Total active: ${activeSubscriptions.size - 1} + Stack trace: + $stackTrace + """.trimIndent()) + } else { + Log.d(TAG, "๐Ÿšซ Unsubscribing from subscription: $id (age: ${subscriptionAge / 60000}min, events: $eventCount)") + } val request = NostrRequest.Close(id) val message = gson.toJson(request, NostrRequest::class.java) @@ -391,6 +451,10 @@ class NostrRelayManager private constructor() { val currentSubs = subscriptions[relayUrl] if (currentSubs?.contains(id) == true) { try { + // Track bytes sent + val messageSize = message.length.toLong() + metricsCollector.recordBytesSent(relayUrl, messageSize) + webSocket.send(message) subscriptions[relayUrl] = currentSubs - id Log.v(TAG, "Unsubscribed '$id' from relay: $relayUrl") @@ -514,49 +578,36 @@ class NostrRelayManager private constructor() { fun getActiveSubscriptions(): Map { return activeSubscriptions.toMap() } + + /** + * Enable or disable diagnostic logging + */ + fun setDiagnosticLogging(enabled: Boolean) { + diagnosticsReporter.diagnosticLoggingEnabled = enabled + if (enabled) { + Log.i(TAG, "๐Ÿ” Diagnostic logging ENABLED - detailed logs will be generated") + diagnosticsReporter.startHealthCheck() + } else { + Log.i(TAG, "๐Ÿ” Diagnostic logging DISABLED") + diagnosticsReporter.stopHealthCheck() + } + } + + /** + * Generate comprehensive diagnostics report for data usage debugging + */ + fun generateDiagnosticsReport(): String { + return diagnosticsReporter.generateDiagnosticsReport() + } /** * 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() - - 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 - ) + return diagnosticsReporter.validateSubscriptionConsistency() } - data class SubscriptionConsistencyReport( - val isConsistent: Boolean, - val inconsistencies: List, - val totalActiveSubscriptions: Int, - val connectedRelayCount: Int - ) - /** * Start periodic subscription validation to ensure robustness */ @@ -573,18 +624,37 @@ class NostrRelayManager private constructor() { Log.w(TAG, "โš ๏ธ Subscription inconsistencies detected: ${report.inconsistencies}") // Auto-repair: re-establish subscriptions for relays with missing ones + // AND clean up extra subscriptions that shouldn't be there 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() - + + // Fix missing subscriptions val missingSubs = expectedSubs - currentSubs if (missingSubs.isNotEmpty()) { Log.i(TAG, "๐Ÿ”ง Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl") restoreSubscriptionsForRelay(relayUrl, webSocket) } + + // Clean up extra subscriptions (leaked/orphaned) + val extraSubs = currentSubs - expectedSubs + if (extraSubs.isNotEmpty()) { + Log.i(TAG, "๐Ÿงน Auto-cleaning ${extraSubs.size} extra subscriptions from $relayUrl: $extraSubs") + extraSubs.forEach { subId -> + try { + val closeRequest = NostrRequest.Close(subId) + val closeMessage = gson.toJson(closeRequest, NostrRequest::class.java) + webSocket.send(closeMessage) + subscriptions[relayUrl] = subscriptions[relayUrl]?.minus(subId) ?: emptySet() + Log.v(TAG, "Cleaned up orphaned subscription '$subId' from $relayUrl") + } catch (e: Exception) { + Log.w(TAG, "Failed to clean up subscription $subId from $relayUrl: ${e.message}") + } + } + } } } } catch (e: Exception) { @@ -604,6 +674,7 @@ class NostrRelayManager private constructor() { subscriptionValidationJob = null Log.v(TAG, "โน๏ธ Stopped subscription validation") } + // MARK: - Private Methods @@ -633,9 +704,13 @@ class NostrRelayManager private constructor() { try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) - + + // Track bytes sent + val messageSize = message.length.toLong() + metricsCollector.recordBytesSent(relayUrl, messageSize) + Log.v(TAG, "๐Ÿ“ค Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl") - + val success = webSocket.send(message) if (success) { // Update relay stats @@ -651,17 +726,24 @@ class NostrRelayManager private constructor() { } private fun handleMessage(message: String, relayUrl: String) { + // Track data received + val messageSize = message.length.toLong() + metricsCollector.recordBytesReceived(relayUrl, messageSize) + 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 -> { + // Track event statistics + metricsCollector.recordEventReceived(response.subscriptionId, messageSize) + // Update relay stats val relay = relaysList.find { it.url == relayUrl } relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1 @@ -736,7 +818,14 @@ class NostrRelayManager private constructor() { connections.remove(relayUrl) // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - + + metricsCollector.recordReconnection() + + if (diagnosticsReporter.diagnosticLoggingEnabled) { + val metrics = metricsCollector.getMetrics() + Log.d(TAG, "๐Ÿ”Œ DISCONNECTION #${metrics.reconnectionCount} from $relayUrl: ${error.message}") + } + updateRelayStatus(relayUrl, false, error) // Check if this is a DNS error @@ -830,13 +919,17 @@ class NostrRelayManager private constructor() { try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) - + + // Track bytes sent + val messageSize = message.length.toLong() + metricsCollector.recordBytesSent(relayUrl, messageSize) + 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") diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 54e6a5a72..628d3bfeb 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -30,7 +30,27 @@ class NostrSubscriptionManager( fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + val optimalRelays = NostrRelayManager.optimalRelayCount(geohash) + Log.d(TAG, "๐Ÿ“ก Subscribing to $geohash with $optimalRelays relays (precision: ${geohash.length} chars)") + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = optimalRelays) + } + } + + fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + val optimalRelays = NostrRelayManager.optimalRelayCount(geohash) + Log.d(TAG, "๐Ÿ“ก Subscribing to $geohash MESSAGES with $optimalRelays relays") + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = optimalRelays) + } + } + + fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) + val optimalRelays = NostrRelayManager.optimalRelayCount(geohash) + Log.d(TAG, "๐Ÿ“ก Subscribing to $geohash PRESENCE with $optimalRelays relays") + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = optimalRelays) } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 9aafe488f..9886c177e 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -60,7 +60,9 @@ class GeohashViewModel( ) private var currentGeohashSubId: String? = null + private var currentPresenceSubId: String? = null private var currentDmSubId: String? = null + private var currentActiveGeohash: String? = null private var geoTimer: Job? = null private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null @@ -157,13 +159,21 @@ class GeohashViewModel( repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() + + // Cancel background jobs + geoTimer?.cancel(); geoTimer = null + globalPresenceJob?.cancel(); globalPresenceJob = null + + // Clear all subscriptions (activeSubscriptions survives disconnect(), so clear first) + NostrRelayManager.getInstance(getApplication()).clearAllSubscriptions() subscriptionManager.disconnect() + + // Reset tracking state currentGeohashSubId = null + currentPresenceSubId = null currentDmSubId = null - geoTimer?.cancel() - geoTimer = null - globalPresenceJob?.cancel() - globalPresenceJob = null + currentActiveGeohash = null + try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} initialize() } @@ -173,8 +183,9 @@ class GeohashViewModel( val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) val relayManager = NostrRelayManager.getInstance(getApplication()) - // Presence is lightweight, send to geohash relays - relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) + // Presence is lightweight, send to geohash relays with optimal relay count + val optimalRelays = NostrRelayManager.optimalRelayCount(geohash) + relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = optimalRelays) Log.v(TAG, "๐Ÿ’“ Sent presence heartbeat for $geohash") } catch (e: Exception) { Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") @@ -206,7 +217,8 @@ class GeohashViewModel( val teleported = state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) + val optimalRelays = NostrRelayManager.optimalRelayCount(channel.geohash) + relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = optimalRelays) } finally { // Ensure we stop the per-message mining animation regardless of success/failure if (startedMining) { @@ -319,7 +331,9 @@ class GeohashViewModel( private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null } + currentPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentPresenceSubId = null } currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } + currentActiveGeohash = null when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -347,14 +361,31 @@ class GeohashViewModel( viewModelScope.launch { val geohash = channel.channel.geohash - val subId = "geohash-$geohash"; currentGeohashSubId = subId - subscriptionManager.subscribeGeohash( + currentActiveGeohash = geohash + + // Messages subscription (20000 only) โ€” stays open always + val msgSubId = "geohash-msg-$geohash"; currentGeohashSubId = msgSubId + subscriptionManager.subscribeGeohashMessages( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, - id = subId, + id = msgSubId, handler = { event -> geohashMessageHandler.onEvent(event, geohash) } ) + + // Presence subscription (20001 only) โ€” only open when foregrounded + if (isAppInForeground()) { + val presenceSubId = "geohash-presence-$geohash"; currentPresenceSubId = + presenceSubId + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 100, + id = presenceSubId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId subscriptionManager.subscribeGiftWraps( @@ -392,13 +423,27 @@ class GeohashViewModel( } override fun onStart(owner: LifecycleOwner) { - Log.d(TAG, "๐ŸŒ App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes") + Log.d(TAG, "๐ŸŒ App foregrounded: Resuming sampling and presence subscription") activeSamplingGeohashes.forEach { performSubscribeSampling(it) } + startGlobalPresenceHeartbeat() + // Reopen presence subscription for active channel + currentActiveGeohash?.let { geohash -> + val presenceSubId = "geohash-presence-$geohash" + currentPresenceSubId = presenceSubId + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 100, + id = presenceSubId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } } override fun onStop(owner: LifecycleOwner) { - Log.d(TAG, "๐ŸŒ App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes") + Log.d(TAG, "๐ŸŒ App backgrounded: Pausing sampling and presence subscription") activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } + currentPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentPresenceSubId = null } } private fun performSubscribeSampling(geohash: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 5bf7003b3..ad9a86743 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -534,13 +534,9 @@ fun LocationChannelsSheet( } // Sampling management: update sampling when channels/bookmarks change - LaunchedEffect(isPresented, availableChannels, bookmarks) { - if (isPresented) { - val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() - viewModel.beginGeohashSampling(geohashes) - } else { - viewModel.endGeohashSampling() - } + LaunchedEffect(availableChannels, bookmarks) { + val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() + viewModel.beginGeohashSampling(geohashes) } // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)