From 23c397fe7b5c49808a7f2f07689b21b93291ad05 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 24 Aug 2025 12:52:31 +0200 Subject: [PATCH] Geohash-specific relays (#306) * per-relay-chat * normalize relay URL * turn off default relays * add relay list * fetch relay list from the internet --- app/src/main/assets/nostr_relays.csv | 293 +++++++++++++++++ .../com/bitchat/android/BitchatApplication.kt | 5 +- .../com/bitchat/android/geohash/Geohash.kt | 39 +++ .../android/nostr/NostrGeohashService.kt | 32 +- .../android/nostr/NostrRelayManager.kt | 109 +++++- .../bitchat/android/nostr/RelayDirectory.kt | 309 ++++++++++++++++++ 6 files changed, 774 insertions(+), 13 deletions(-) create mode 100644 app/src/main/assets/nostr_relays.csv create mode 100644 app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv new file mode 100644 index 00000000..bd95f740 --- /dev/null +++ b/app/src/main/assets/nostr_relays.csv @@ -0,0 +1,293 @@ +Relay URL,Latitude,Longitude +relay.damus.io,37.7621,-122.3971 +nostr-pub.wellorder.net,45.5229,-122.9898 +nostr.mom,50.4779,12.3713 +nostr.slothy.win,37.7621,-122.3971 +nostr.einundzwanzig.space,50.1155,8.6842 +nos.lol,50.4779,12.3713 +relay.nostr.band,60.1695,24.9354 +no.str.cr,9.9339,-84.0849 +nostr.massmux.com,50.1155,8.6842 +nostr-relay.schnitzel.world,39.0437,-77.4875 +relay.nostr.com.au,37.7621,-122.3971 +knostr.neutrine.com,48.8534,2.3488 +nostr.nodeofsven.com,47.4875,8.2965 +nostr.vulpem.com,49.4542,11.0775 +nostr-verif.slothy.win,37.7621,-122.3971 +relay.lexingtonbitcoin.org,37.7621,-122.3971 +nostr-1.nbo.angani.co,-1.2615,36.7903 +relay.wellorder.net,45.5229,-122.9898 +nostr.easydns.ca,43.7064,-79.3986 +relay.dwadziesciajeden.pl,52.2298,21.0118 +nostr.data.haus,50.4779,12.3713 +nostr.einundzwanzig.space,50.1155,8.6842 +nostr.mom,50.4779,12.3713 +nos.lol,50.4779,12.3713 +relay.nostr.band,60.1695,24.9354 +nostr.massmux.com,50.1155,8.6842 +nostr.vulpem.com,49.4542,11.0775 +relay.damus.io,37.7621,-122.3971 +nostr-pub.wellorder.net,45.5229,-122.9898 +no.str.cr,9.9339,-84.0849 +relay.dwadziesciajeden.pl,52.2298,21.0118 +nostr.data.haus,50.4779,12.3713 +relay.wellorder.net,45.5229,-122.9898 +relay.nostromo.social,49.4542,11.0775 +offchain.pub,34.0522,-118.2437 +relay.nostr.wirednet.jp,35.9356,139.3044 +relay.nostrcheck.me,37.7621,-122.3971 +nostrue.com,40.8043,-74.0121 +nostr-relay.schnitzel.world,39.0437,-77.4875 +nproxy.kristapsk.lv,60.1695,24.9354 +nostr.spaceshell.xyz,37.7621,-122.3971 +nostr-dev.wellorder.net,45.5229,-122.9898 +nostr-verified.wellorder.net,45.5229,-122.9898 +nostr.roundrockbitcoiners.com,40.8043,-74.0121 +slick.mjex.me,39.0437,-77.4875 +nostr.yael.at,52.3740,4.8897 +relay.primal.net,37.7621,-122.3971 +nostr.oxtr.dev,50.4779,12.3713 +nostr.21crypto.ch,46.5160,6.6328 +nostr.liberty.fans,38.8003,-90.6265 +nostr-02.dorafactory.org,1.2897,103.8501 +relay.hodl.ar,-32.9468,-60.6393 +nostr.middling.mydns.jp,35.8089,140.1185 +nostr.namek.link,37.7621,-122.3971 +nostrja-kari.heguro.com,37.7621,-122.3971 +nostr.hifish.org,47.3667,8.5500 +nostr.rikmeijer.nl,50.4779,12.3713 +black.nostrcity.club,41.8119,-87.6873 +nostr.hekster.org,37.3924,-121.9623 +relay.wavlake.com,41.2619,-95.8608 +nostr.sagaciousd.com,49.2497,-123.1193 +nostr.fbxl.net,43.7064,-79.3986 +ithurtswhenip.ee,50.7990,-1.0913 +relay2.nostrchat.io,49.4542,11.0775 +relay1.nostrchat.io,60.1695,24.9354 +nostr-01.yakihonne.com,1.3215,103.6957 +nostr.sathoarder.com,48.5839,7.7455 +nostr.overmind.lol,37.7621,-122.3971 +relay.verified-nostr.com,37.7621,-122.3971 +purplerelay.com,50.1155,8.6842 +relay.orangepill.ovh,49.0127,1.9694 +nostr-relay.psfoundation.info,39.0437,-77.4875 +soloco.nl,37.7621,-122.3971 +relay.froth.zone,60.1695,24.9354 +nostr.stakey.net,52.5250,5.7181 +nostr.2b9t.xyz,34.0522,-118.2437 +pyramid.fiatjaf.com,50.1155,8.6842 +a.nos.lol,50.4779,12.3713 +relay.magiccity.live,25.8130,-80.2320 +nostr.notribe.net,40.8344,-74.1377 +freelay.sovbit.host,64.1355,-21.8954 +relay.credenso.cafe,43.4254,-80.5112 +nostr.huszonegy.world,47.4984,19.0404 +multiplexer.huszonegy.world,47.4984,19.0404 +bucket.coracle.social,37.7621,-122.3971 +nostr.kungfu-g.rip,33.7865,-84.4454 +relay.artx.market,43.7064,-79.3986 +relay.notoshi.win,13.3622,100.9835 +vitor.nostr1.com,40.7143,-74.0060 +nostr-02.yakihonne.com,1.3215,103.6957 +nostr-03.dorafactory.org,1.2897,103.8501 +n.ok0.org,-36.8485,174.7635 +nostr.0x7e.xyz,47.5056,8.7241 +relay.nostr.net,50.4779,12.3713 +strfry.openhoofd.nl,51.5717,3.7042 +relay.fountain.fm,39.0997,-94.5786 +relay.usefusion.ai,38.7135,-78.1594 +relay.varke.eu,52.6958,6.1944 +nostr.satstralia.com,64.1355,-21.8954 +relay.13room.space,37.7621,-122.3971 +nostr.myshosholoza.co.za,52.3710,4.9042 +nostr.carroarmato0.be,50.8517,3.6089 +nostr.dbtc.link,37.7621,-122.3971 +orangepiller.org,60.1695,24.9354 +adre.su,59.9386,30.3141 +relay.sincensura.org,37.7621,-122.3971 +relay.freeplace.nl,52.3740,4.8897 +bostr.bitcointxoko.com,64.1355,-21.8954 +nostr.plantroon.com,50.1025,8.6299 +srtrelay.c-stellar.net,37.7621,-122.3971 +nostr.jfischer.org,49.4453,11.0222 +nostr.novacisko.cz,52.2298,21.0118 +relay.lumina.rocks,49.4453,11.0222 +nostr.tavux.tech,50.9519,1.8563 +relay.nostrhub.fr,50.1155,8.6842 +relay.agorist.space,52.3740,4.8897 +chorus.pjv.me,45.5229,-122.9898 +relay.cosmicbolt.net,37.3924,-121.9623 +santo.iguanatech.net,40.8344,-74.1377 +relay.tagayasu.xyz,45.4112,-75.6981 +relay.mostro.network,40.8344,-74.1377 +relay.zone667.com,60.1695,24.9354 +relay5.bitransfer.org,37.7621,-122.3971 +relay.illuminodes.com,47.6062,-122.3321 +relay2.angor.io,50.1155,8.6842 +relay.satsdays.com,1.2897,103.8501 +relay.angor.io,50.1155,8.6842 +orangesync.tech,50.9333,6.9500 +nostr-relay.cbrx.io,37.7621,-122.3971 +relay.21e6.cz,50.0880,14.4208 +nostr.chaima.info,50.1155,8.6842 +relay.satlantis.io,32.8546,-79.9748 +relay.digitalezukunft.cyou,45.5088,-73.5878 +relay.tapestry.ninja,40.8043,-74.0121 +relay.minibolt.info,37.7621,-122.3971 +nostr.bilthon.dev,25.8130,-80.2320 +nostr.makibisskey.work,37.7621,-122.3971 +relay.mattybs.lol,37.7621,-122.3971 +noxir.kpherox.dev,34.8436,135.5084 +sendit.nosflare.com,37.7621,-122.3971 +relay.coinos.io,37.7621,-122.3971 +relay.nostraddress.com,37.7621,-122.3971 +wot.nostr.party,36.1659,-86.7844 +nostrelites.org,41.8500,-87.6500 +relay.nostriot.com,43.7064,-79.3986 +prl.plus,55.7522,37.6156 +zap.watch,45.5088,-73.5878 +wot.codingarena.top,50.4779,12.3713 +nostr.azzamo.net,52.2284,21.0522 +wot.sudocarlos.com,43.7064,-79.3986 +relay.lnfi.network,45.6241,8.7851 +wot.nostr.net,37.7621,-122.3971 +relay.nostrdice.com,-33.8678,151.2073 +wot.sebastix.social,51.3700,6.1681 +wheat.happytavern.co,37.7621,-122.3971 +relay.sigit.io,50.4779,12.3713 +strfry.bonsai.com,37.8716,-122.2728 +travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873 +satsage.xyz,37.3924,-121.9623 +relay.degmods.com,50.4779,12.3713 +nostr.community.ath.cx,45.5088,-73.5878 +nostr.coincrowd.fund,39.0437,-77.4875 +strfry.shock.network,41.8847,-88.2040 +cyberspace.nostr1.com,40.7143,-74.0060 +relay02.lnfi.network,39.0997,-94.5786 +nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875 +relay.davidebtc.me,50.1155,8.6842 +wot.dtonon.com,37.7621,-122.3971 +relay.goodmorningbitcoin.com,37.7621,-122.3971 +articles.layer3.news,37.3394,-121.8950 +bostr.syobon.net,37.7621,-122.3971 +nostr.agentcampfire.com,52.3740,4.8897 +nostr.thebiglake.org,32.7244,-96.6755 +schnorr.me,37.7621,-122.3971 +relay.wolfcoil.com,35.6090,139.7302 +nostr.camalolo.com,24.1469,120.6839 +nostr.tac.lol,47.4740,-122.2610 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.bitcoinveneto.org,64.1355,-21.8954 +nostr.red5d.dev,37.7621,-122.3971 +relay-testnet.k8s.layer3.news,37.3394,-121.8950 +promenade.fiatjaf.com,50.1155,8.6842 +nostrelay.memory-art.xyz,37.7621,-122.3971 +inbox.azzamo.net,52.2284,21.0522 +social.proxymana.net,60.1695,24.9354 +relay.netstr.io,53.3331,-6.2489 +premium.primal.net,37.7621,-122.3971 +nostr.lojong.info,37.7621,-122.3971 +nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971 +relay.stream.labs.h3.se,59.3294,18.0687 +tollbooth.stens.dev,51.4566,7.0123 +relay.chakany.systems,37.7621,-122.3971 +relay.mwaters.net,50.9519,1.8563 +nostr-relay.shirogaku.xyz,37.7621,-122.3971 +kitchen.zap.cooking,37.7621,-122.3971 +relay.arx-ccn.com,50.4779,12.3713 +relay.fr13nd5.com,50.1155,8.6842 +nostr.tegila.com.br,39.0437,-77.4875 +relay.jeffg.fyi,43.7064,-79.3986 +relay.bullishbounty.com,37.7621,-122.3971 +nostr.spicyz.io,37.7621,-122.3971 +relay04.lnfi.network,39.0997,-94.5786 +vidono.apps.slidestr.net,48.8534,2.3488 +relay03.lnfi.network,39.0997,-94.5786 +communities.nos.social,40.8344,-74.1377 +relay.evanverma.com,40.8344,-74.1377 +nostrelay.circum.space,51.4566,7.0123 +wot.brightbolt.net,47.6928,-116.7850 +relayrs.notoshi.win,37.7621,-122.3971 +fenrir-s.notoshi.win,37.7621,-122.3971 +relay.nsnip.io,60.1695,24.9354 +x.kojira.io,37.7621,-122.3971 +relay.hasenpfeffr.com,39.0437,-77.4875 +relay01.lnfi.network,39.0997,-94.5786 +nostr.rtvslawenia.com,49.4542,11.0775 +relay.g1sms.fr,43.9298,2.1480 +nostr.kalf.org,52.3740,4.8897 +nostr.rblb.it,37.7621,-122.3971 +nostr.4rs.nl,49.4453,11.0222 +relay.vrtmrz.net,37.7621,-122.3971 +nostr.hoppe-relay.it.com,45.5946,-121.1787 +relay-rpi.edufeed.org,49.4453,11.0222 +relay.copylaradio.com,50.7990,-1.0913 +relay.ru.ac.th,13.7540,100.5014 +relay.bitcoinartclock.com,50.4779,12.3713 +wot.downisontheup.ca,47.6062,-122.3321 +nostr.coincards.com,43.7064,-79.3986 +relay.etch.social,41.2619,-95.8608 +relay.mess.ch,47.1345,9.0964 +relay.holzeis.me,37.7621,-122.3971 +relay-admin.thaliyal.com,40.8220,-74.4488 +nostr.thaliyal.com,40.8220,-74.4488 +strfry.felixzieger.de,50.1025,8.6299 +nostr.smut.cloud,37.7621,-122.3971 +r.bitcoinhold.net,37.7621,-122.3971 +nostr.blankfors.se,60.1695,24.9354 +portal-relay.pareto.space,49.4453,11.0222 +relay.getsafebox.app,43.7064,-79.3986 +relay.anzenkodo.workers.dev,37.7621,-122.3971 +relay.nostrhub.tech,49.4453,11.0222 +nostr.prl.plus,52.3740,4.8897 +nostr-2.21crypto.ch,46.5160,6.6328 +nostr.zenon.network,40.7143,-74.0060 +nostr-relay.amethyst.name,35.7721,-78.6386 +relayone.geektank.ai,17.1210,-61.8433 +fanfares.nostr1.com,40.7143,-74.0060 +wot.geektank.ai,17.1210,-61.8433 +relay-dev.satlantis.io,40.8344,-74.1377 +relay.siamdev.cc,13.9178,100.4240 +relay.nosto.re,51.3700,6.1681 +wot.soundhsa.com,39.0997,-94.5786 +nostr.n7ekb.net,47.5707,-122.2221 +relayone.soundhsa.com,39.0997,-94.5786 +relay.puresignal.news,37.7621,-122.3971 +relay.nostx.io,37.7621,-122.3971 +nostr.now,35.6090,139.7302 +relay.artiostr.ch,37.7621,-122.3971 +relay.oldenburg.cool,50.1155,8.6842 +theoutpost.life,64.1355,-21.8954 +khatru.nostrver.se,51.3700,6.1681 +relay.wavefunc.live,37.7915,-122.4018 +nostr-relay.zimage.com,34.0522,-118.2437 +relay.javi.space,43.4628,11.8807 +bostr.shop,42.8865,-78.8784 +relay.letsfo.com,52.2298,21.0118 +alien.macneilmediagroup.com,37.7621,-122.3971 +rn1.sotiras.org,37.7621,-122.3971 +gnostr.com,42.6975,23.3241 +relay.conduit.market,37.7621,-122.3971 +relay.hivetalk.org,37.3924,-121.9623 +nostr.l484.com,30.2960,-97.6396 +relay.chorus.community,50.1155,8.6842 +nostr-relay.moe.gift,37.7621,-122.3971 +relay.nostrcal.com,37.7621,-122.3971 +temp.iris.to,37.7621,-122.3971 +librerelay.aaroniumii.com,37.7621,-122.3971 +nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971 +relay.barine.co,37.7621,-122.3971 +nostr.rohoss.com,48.1374,11.5755 +wot.nostr.place,30.2672,-97.7431 +relay.utxo.farm,34.7331,135.8183 +relay.bankless.at,37.7621,-122.3971 +relay.toastr.net,40.8043,-74.0121 +nostr.excentered.com,52.5244,13.4105 +relay.mccormick.cx,52.3740,4.8897 +relay.cypherflow.ai,48.8534,2.3488 +relay.laantungir.net,45.3134,-73.8725 +nostr.veladan.dev,37.7621,-122.3971 +nostr.tadryanom.me,37.7621,-122.3971 +nostr-relay.online,37.7621,-122.3971 +nostr.night7.space,50.4779,12.3713 +dev-nostr.bityacht.io,25.0531,121.5264 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 7777a554..4bccf5de 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -186,7 +186,12 @@ class NostrGeohashService( ) val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.sendEvent(event) + nostrRelayManager.sendEventToGeohash( + event = event, + geohash = channel.geohash, + includeDefaults = false, + nRelays = 5 + ) Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") @@ -532,7 +537,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, @@ -540,12 +545,15 @@ class NostrGeohashService( limit = 200 ) - nostrRelayManager.subscribe( + nostrRelayManager.subscribeForGeohash( + geohash = geohash, filter = filter, id = "geohash-$geohash", handler = { event -> handleUnifiedGeohashEvent(event, geohash) - } + }, + includeDefaults = false, + nRelays = 5 ) Log.d(TAG, "Subscribed to geohash events for: $geohash") @@ -858,7 +866,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 @@ -868,12 +876,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 = false, + nRelays = 5 ) Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}") @@ -892,12 +903,15 @@ class NostrGeohashService( since = System.currentTimeMillis() - 86400000L // Last 24 hours ) - nostrRelayManager.subscribe( + nostrRelayManager.subscribeForGeohash( + geohash = channel.channel.geohash, filter = dmFilter, id = dmSubId, handler = { giftWrap -> handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) - } + }, + includeDefaults = false, + nRelays = 5 ) Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") 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..9572b010 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 = 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 { + 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) { + // 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 { @@ -548,7 +648,12 @@ class NostrRelayManager private constructor() { val wasProcessed = eventDeduplicator.processEvent(response.event) { event -> // Only log non-gift-wrap events to reduce noise if (event.kind != NostrKind.GIFT_WRAP) { - Log.v(TAG, "📥 Processing new Nostr event (kind: ${event.kind}) from relay: $relayUrl") + 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 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..db353581 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -0,0 +1,309 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.content.SharedPreferences +import android.util.Log +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.io.InputStreamReader +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import kotlin.math.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Loads relay coordinates from assets and provides nearest-relay lookup by geohash. + */ +object RelayDirectory { + + private const val TAG = "RelayDirectory" + private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv" + private const val ASSET_FILE = "nostr_relays.csv" + private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" + private const val PREFS_NAME = "relay_directory_prefs" + private const val KEY_LAST_UPDATE_MS = "last_update_ms" + private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) + + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .callTimeout(15, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + } + + data class RelayInfo( + val url: String, + val latitude: Double, + val longitude: Double + ) + + @Volatile + private var initialized: Boolean = false + + private val relays: MutableList = mutableListOf() + private val relaysLock = Any() + + fun initialize(application: Application) { + if (initialized) return + synchronized(this) { + if (initialized) return + try { + val downloaded = getDownloadedFile(application) + val loadedFromDownloaded = if (downloaded.exists() && downloaded.canRead()) { + loadFromFile(downloaded, sourceLabel = "downloaded") + } else { + false + } + + if (!loadedFromDownloaded) { + loadFromAssets(application) + } + + initialized = true + + // Trigger an immediate fetch if the data is stale (older than 24h) + ioScope.launch { + if (isStale(application)) { + fetchAndMaybeSwap(application) + } + } + + // Start periodic staleness check every minute + startPeriodicRefresh(application) + } 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 { + val snapshot = synchronized(relaysLock) { relays.toList() } + if (snapshot.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 snapshot + .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 + } + + private fun normalizeRelayUrl(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return trimmed + return if ("://" in trimmed) trimmed else "wss://$trimmed" + } + + // ===== Implementation details ===== + + private fun getPrefs(application: Application): SharedPreferences = + application.getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE) + + private fun getDownloadedFile(application: Application): File = + File(application.filesDir, DOWNLOADED_FILE) + + private fun isStale(application: Application): Boolean { + val last = getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L) + val now = System.currentTimeMillis() + return now - last >= ONE_DAY_MS + } + + private fun startPeriodicRefresh(application: Application) { + ioScope.launch { + while (true) { + try { + if (isStale(application)) { + fetchAndMaybeSwap(application) + } + } catch (e: Exception) { + Log.w(TAG, "Periodic refresh encountered an error: ${e.message}") + } + delay(TimeUnit.MINUTES.toMillis(1)) + } + } + } + + private fun fetchAndMaybeSwap(application: Application) { + try { + val tmpFile = File.createTempFile("relays_", ".csv", application.cacheDir) + val ok = downloadToFile(ASSET_FILE_URL, tmpFile) + if (!ok) { + Log.w(TAG, "Failed to fetch latest relays; keeping current list (will fallback to bundled if none)") + tmpFile.delete() + return + } + + val parsed = parseCsv(FileInputStream(tmpFile)) + if (parsed.isEmpty()) { + Log.w(TAG, "Downloaded relay CSV parsed to 0 entries; ignoring") + tmpFile.delete() + return + } + + val dest = getDownloadedFile(application) + tmpFile.inputStream().use { input -> + FileOutputStream(dest, false).use { output -> + input.copyTo(output) + } + } + tmpFile.delete() + + val hash = fileSha256Hex(dest) + val entries = parsed.size + + synchronized(relaysLock) { + relays.clear() + relays.addAll(parsed) + } + + getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).apply() + + Log.i(TAG, "✅ Using downloaded relay list (${dest.absolutePath}), entries=$entries, sha256=$hash, updatedAtMs=${getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L)}") + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch and swap relay list: ${e.message}") + } + } + + private fun downloadToFile(url: String, dest: File): Boolean { + return try { + val req = Request.Builder().url(url).get().build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "HTTP ${'$'}{resp.code} when fetching $url") + return false + } + val body = resp.body ?: return false + FileOutputStream(dest).use { out -> + body.byteStream().use { input -> + input.copyTo(out) + } + } + true + } + } catch (e: Exception) { + Log.w(TAG, "Download error: ${e.message}") + false + } + } + + private fun loadFromFile(file: File, sourceLabel: String): Boolean { + return try { + val list = parseCsv(FileInputStream(file)) + if (list.isEmpty()) { + Log.w(TAG, "${sourceLabel} relay CSV has 0 entries; ignoring") + false + } else { + synchronized(relaysLock) { + relays.clear() + relays.addAll(list) + } + val hash = fileSha256Hex(file) + Log.i(TAG, "📄 Loaded ${list.size} relay entries from ${sourceLabel} file (${file.absolutePath}), sha256=$hash") + true + } + } catch (e: Exception) { + Log.w(TAG, "Failed loading ${sourceLabel} relay file: ${e.message}") + false + } + } + + private fun loadFromAssets(application: Application) { + val list = try { + parseCsv(application.assets.open(ASSET_FILE)) + } catch (e: Exception) { + Log.e(TAG, "Failed to open asset $ASSET_FILE: ${e.message}") + emptyList() + } + synchronized(relaysLock) { + relays.clear() + relays.addAll(list) + } + // Compute asset hash for logging + val hash = try { + application.assets.open(ASSET_FILE).use { input -> + streamSha256Hex(input) + } + } catch (e: Exception) { + "error:${'$'}{e.message}" + } + Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") + } + + private fun parseCsv(input: InputStream): List { + val result = mutableListOf() + BufferedReader(InputStreamReader(input)).use { reader -> + var line: String? + while (true) { + line = reader.readLine() + if (line == null) break + val trimmed = line!!.trim() + if (trimmed.isEmpty()) continue + if (trimmed.lowercase().startsWith("relay url")) continue + val parts = trimmed.split(",") + if (parts.size < 3) continue + val url = normalizeRelayUrl(parts[0].trim()) + val lat = parts[1].trim().toDoubleOrNull() + val lon = parts[2].trim().toDoubleOrNull() + if (url.isEmpty() || lat == null || lon == null) continue + result.add(RelayInfo(url = url, latitude = lat, longitude = lon)) + } + } + return result + } + + private fun fileSha256Hex(file: File): String = try { + FileInputStream(file).use { input -> + streamSha256Hex(input) + } + } catch (_: Exception) { "error" } + + private fun streamSha256Hex(input: InputStream): String { + val digest = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(8192) + var read: Int + while (true) { + read = input.read(buf) + if (read <= 0) break + digest.update(buf, 0, read) + } + val bytes = digest.digest() + return bytes.joinToString("") { b -> + val v = b.toInt() and 0xff + val s = Integer.toHexString(v) + if (s.length == 1) "0$s" else s + } + } +} + +