mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:05:20 +00:00
per-relay-chat
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.bitchat.android
|
package com.bitchat.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.bitchat.android.nostr.RelayDirectory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class for bitchat Android
|
* Main application class for bitchat Android
|
||||||
@@ -10,7 +11,7 @@ class BitchatApplication : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// Initialize any global services or configurations
|
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||||
// For now, keep it simple
|
RelayDirectory.initialize(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package com.bitchat.android.geohash
|
|||||||
object Geohash {
|
object Geohash {
|
||||||
|
|
||||||
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||||
|
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the provided coordinates into a geohash string.
|
* Encodes the provided coordinates into a geohash string.
|
||||||
@@ -62,4 +63,42 @@ object Geohash {
|
|||||||
|
|
||||||
return geohash.toString()
|
return geohash.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a geohash string to the center latitude/longitude of its cell.
|
||||||
|
* @return Pair(latitude, longitude)
|
||||||
|
*/
|
||||||
|
fun decodeToCenter(geohash: String): Pair<Double, Double> {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ class NostrGeohashService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val nostrRelayManager = NostrRelayManager.getInstance(application)
|
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)}")
|
Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}")
|
||||||
|
|
||||||
@@ -531,7 +536,7 @@ class NostrGeohashService(
|
|||||||
geohashSamplingJob = coroutineScope.launch {
|
geohashSamplingJob = coroutineScope.launch {
|
||||||
val nostrRelayManager = NostrRelayManager.getInstance(application)
|
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 ->
|
geohashes.forEach { geohash ->
|
||||||
val filter = NostrFilter.geohashEphemeral(
|
val filter = NostrFilter.geohashEphemeral(
|
||||||
geohash = geohash,
|
geohash = geohash,
|
||||||
@@ -539,12 +544,15 @@ class NostrGeohashService(
|
|||||||
limit = 200
|
limit = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
nostrRelayManager.subscribe(
|
nostrRelayManager.subscribeForGeohash(
|
||||||
|
geohash = geohash,
|
||||||
filter = filter,
|
filter = filter,
|
||||||
id = "geohash-$geohash",
|
id = "geohash-$geohash",
|
||||||
handler = { event ->
|
handler = { event ->
|
||||||
handleUnifiedGeohashEvent(event, geohash)
|
handleUnifiedGeohashEvent(event, geohash)
|
||||||
}
|
},
|
||||||
|
includeDefaults = true,
|
||||||
|
nRelays = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.d(TAG, "Subscribed to geohash events for: $geohash")
|
Log.d(TAG, "Subscribed to geohash events for: $geohash")
|
||||||
@@ -857,7 +865,7 @@ class NostrGeohashService(
|
|||||||
try {
|
try {
|
||||||
val nostrRelayManager = NostrRelayManager.getInstance(application)
|
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}"
|
val geohashSubId = "geohash-${channel.channel.geohash}"
|
||||||
currentGeohashSubscriptionId = geohashSubId
|
currentGeohashSubscriptionId = geohashSubId
|
||||||
|
|
||||||
@@ -867,12 +875,15 @@ class NostrGeohashService(
|
|||||||
limit = 200
|
limit = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
nostrRelayManager.subscribe(
|
nostrRelayManager.subscribeForGeohash(
|
||||||
|
geohash = channel.channel.geohash,
|
||||||
filter = geohashFilter,
|
filter = geohashFilter,
|
||||||
id = geohashSubId,
|
id = geohashSubId,
|
||||||
handler = { event ->
|
handler = { event ->
|
||||||
handleUnifiedGeohashEvent(event, channel.channel.geohash)
|
handleUnifiedGeohashEvent(event, channel.channel.geohash)
|
||||||
}
|
},
|
||||||
|
includeDefaults = true,
|
||||||
|
nRelays = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}")
|
Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}")
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class NostrRelayManager private constructor() {
|
|||||||
fun registerPendingGiftWrap(id: String) {
|
fun registerPendingGiftWrap(id: String) {
|
||||||
pendingGiftWrapIDs.add(id)
|
pendingGiftWrapIDs.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun defaultRelays(): List<String> = DEFAULT_RELAYS
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,7 +95,8 @@ class NostrRelayManager private constructor() {
|
|||||||
val filter: NostrFilter,
|
val filter: NostrFilter,
|
||||||
val handler: (NostrEvent) -> Unit,
|
val handler: (NostrEvent) -> Unit,
|
||||||
val targetRelayUrls: Set<String>? = null, // null means all relays
|
val targetRelayUrls: Set<String>? = 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
|
// Event deduplication system
|
||||||
@@ -119,6 +122,103 @@ class NostrRelayManager private constructor() {
|
|||||||
|
|
||||||
private val gson by lazy { NostrRequest.createGson() }
|
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 = 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<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 = 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<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 {
|
init {
|
||||||
// Initialize with default relays - avoid static initialization order issues
|
// Initialize with default relays - avoid static initialization order issues
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<RelayInfo> = 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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user