Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ android {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
compose = true
}
packaging {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/bitchat/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, NostrRelayManager.SubscriptionInfo>,
private val getConnections: () -> Map<String, *>,
private val getSubscriptions: () -> Map<String, Set<String>>
) {

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<String>()

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<String>()

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<String>,
val connectedRelayCount: Int
)
26 changes: 25 additions & 1 deletion app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading
Loading