From df94c930d7f683db87d7d45f25385c96544126f0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Aug 2025 22:31:30 +0200 Subject: [PATCH] per-relay-chat --- .../com/bitchat/android/BitchatApplication.kt | 5 +- .../com/bitchat/android/geohash/Geohash.kt | 39 +++++++ .../android/nostr/NostrGeohashService.kt | 25 +++-- .../android/nostr/NostrRelayManager.kt | 102 +++++++++++++++++- .../bitchat/android/nostr/RelayDirectory.kt | 94 ++++++++++++++++ 5 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 99c9107b..c435fee4 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -1,6 +1,7 @@ package com.bitchat.android import android.app.Application +import com.bitchat.android.nostr.RelayDirectory /** * Main application class for bitchat Android @@ -10,7 +11,7 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() - // Initialize any global services or configurations - // For now, keep it simple + // Initialize relay directory (loads assets/nostr_relays.csv) + RelayDirectory.initialize(this) } } diff --git a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt index bc92f486..0194fe70 100644 --- a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt +++ b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt @@ -9,6 +9,7 @@ package com.bitchat.android.geohash object Geohash { private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray() + private val charToValue: Map = base32Chars.withIndex().associate { it.value to it.index } /** * Encodes the provided coordinates into a geohash string. @@ -62,4 +63,42 @@ object Geohash { return geohash.toString() } + + /** + * Decodes a geohash string to the center latitude/longitude of its cell. + * @return Pair(latitude, longitude) + */ + fun decodeToCenter(geohash: String): Pair { + if (geohash.isEmpty()) return 0.0 to 0.0 + + var latInterval = -90.0 to 90.0 + var lonInterval = -180.0 to 180.0 + var isEven = true + + geohash.lowercase().forEach { ch -> + val cd = charToValue[ch] ?: return 0.0 to 0.0 + for (mask in intArrayOf(16, 8, 4, 2, 1)) { + if (isEven) { + val mid = (lonInterval.first + lonInterval.second) / 2 + if ((cd and mask) != 0) { + lonInterval = mid to lonInterval.second + } else { + lonInterval = lonInterval.first to mid + } + } else { + val mid = (latInterval.first + latInterval.second) / 2 + if ((cd and mask) != 0) { + latInterval = mid to latInterval.second + } else { + latInterval = latInterval.first to mid + } + } + isEven = !isEven + } + } + + val latCenter = (latInterval.first + latInterval.second) / 2 + val lonCenter = (lonInterval.first + lonInterval.second) / 2 + return latCenter to lonCenter + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index fd6cc101..6ab09e90 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -185,7 +185,12 @@ class NostrGeohashService( ) val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.sendEvent(event) + nostrRelayManager.sendEventToGeohash( + event = event, + geohash = channel.geohash, + includeDefaults = true, + nRelays = 5 + ) Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") @@ -531,7 +536,7 @@ class NostrGeohashService( geohashSamplingJob = coroutineScope.launch { val nostrRelayManager = NostrRelayManager.getInstance(application) - // Subscribe to each geohash for ephemeral events (kind 20000) + // Subscribe to each geohash for ephemeral events (kind 20000) using geohash-specific relays geohashes.forEach { geohash -> val filter = NostrFilter.geohashEphemeral( geohash = geohash, @@ -539,12 +544,15 @@ class NostrGeohashService( limit = 200 ) - nostrRelayManager.subscribe( + nostrRelayManager.subscribeForGeohash( + geohash = geohash, filter = filter, id = "geohash-$geohash", handler = { event -> handleUnifiedGeohashEvent(event, geohash) - } + }, + includeDefaults = true, + nRelays = 5 ) Log.d(TAG, "Subscribed to geohash events for: $geohash") @@ -857,7 +865,7 @@ class NostrGeohashService( try { val nostrRelayManager = NostrRelayManager.getInstance(application) - // Subscribe to geohash ephemeral events for this specific channel + // Subscribe to geohash ephemeral events for this specific channel using geohash-specific relays val geohashSubId = "geohash-${channel.channel.geohash}" currentGeohashSubscriptionId = geohashSubId @@ -867,12 +875,15 @@ class NostrGeohashService( limit = 200 ) - nostrRelayManager.subscribe( + nostrRelayManager.subscribeForGeohash( + geohash = channel.channel.geohash, filter = geohashFilter, id = geohashSubId, handler = { event -> handleUnifiedGeohashEvent(event, channel.channel.geohash) - } + }, + includeDefaults = true, + nRelays = 5 ) Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}") 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 6b15fddb..f363b80a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -52,6 +52,8 @@ class NostrRelayManager private constructor() { fun registerPendingGiftWrap(id: String) { pendingGiftWrapIDs.add(id) } + + fun defaultRelays(): List = DEFAULT_RELAYS } /** @@ -93,7 +95,8 @@ class NostrRelayManager private constructor() { val filter: NostrFilter, val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays - val createdAt: Long = System.currentTimeMillis() + val createdAt: Long = System.currentTimeMillis(), + val originGeohash: String? = null // used for logging and grouping ) // Event deduplication system @@ -119,6 +122,103 @@ class NostrRelayManager private constructor() { private val gson by lazy { NostrRequest.createGson() } + // Per-geohash relay selection + private val geohashToRelays = ConcurrentHashMap>() // 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 = true) { + 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 { + 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 = true, + 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 = true, 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) { + // 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 { diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt new file mode 100644 index 00000000..98852e11 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -0,0 +1,94 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import kotlin.math.* + +/** + * Loads relay coordinates from assets and provides nearest-relay lookup by geohash. + */ +object RelayDirectory { + + private const val TAG = "RelayDirectory" + private const val ASSET_FILE = "nostr_relays.csv" + + data class RelayInfo( + val url: String, + val latitude: Double, + val longitude: Double + ) + + @Volatile + private var initialized: Boolean = false + + private val relays: MutableList = mutableListOf() + + fun initialize(application: Application) { + if (initialized) return + synchronized(this) { + if (initialized) return + try { + val input = application.assets.open(ASSET_FILE) + BufferedReader(InputStreamReader(input)).use { reader -> + var line: String? + var numLoaded = 0 + while (true) { + line = reader.readLine() + if (line == null) break + val trimmed = line!!.trim() + if (trimmed.isEmpty()) continue + // Skip header if present + if (trimmed.lowercase().startsWith("relay url")) continue + val parts = trimmed.split(",") + if (parts.size < 3) continue + val url = parts[0].trim() + val lat = parts[1].trim().toDoubleOrNull() + val lon = parts[2].trim().toDoubleOrNull() + if (url.isEmpty() || lat == null || lon == null) continue + relays.add(RelayInfo(url = url, latitude = lat, longitude = lon)) + numLoaded += 1 + } + Log.i(TAG, "📥 Loaded $numLoaded relay entries from assets/$ASSET_FILE") + } + initialized = true + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize RelayDirectory: ${e.message}") + } + } + } + + /** + * Return up to nRelays closest relay URLs to the geohash center. + */ + fun closestRelaysForGeohash(geohash: String, nRelays: Int): List { + if (relays.isEmpty()) return emptyList() + val center = try { + val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash) + c + } catch (e: Exception) { + Log.e(TAG, "Failed to decode geohash '$geohash': ${e.message}") + return emptyList() + } + + val (lat, lon) = center + return relays + .asSequence() + .sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) } + .take(nRelays.coerceAtLeast(0)) + .map { it.url } + .toList() + } + + private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val R = 6371000.0 // meters + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2.0) + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2.0) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return R * c + } +} + +