mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-08 08:01:09 +00:00
Merge a6591ec57dfc94dbb6d577b887bc8e125d2ead14 into 2fc6f4747275bdbfec2288ac1a45f7d5bedcd9ac
This commit is contained in:
commit
1efe643cab
6
.github/workflows/fetch-georelays.yml
vendored
6
.github/workflows/fetch-georelays.yml
vendored
@ -19,9 +19,11 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
# Must match RelayDirectory.ASSET_FILE_URL. This job refreshes the
|
||||
# bundled asset from the app's runtime source.
|
||||
run: |
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv
|
||||
mv online_relays_gps.csv ./app/src/main/assets/nostr_relays.csv
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
|
||||
@ -1,389 +1,442 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.nostrfy.org,35.6916,139.768
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
relay.monostr.com,50.4754,12.3683
|
||||
nostr.emanuelemiani.it,45.778,8.79414
|
||||
nostr.hifish.org,47.4009,8.57814
|
||||
nostr.aphelion.network,55.7623,37.6381
|
||||
nostr.synalysis.com,50.4754,12.3683
|
||||
nip85-staging.nosfabrica.com,39.0997,-94.5786
|
||||
relay.bnos.space,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
conduit-congee.fly.dev,38.7946,-106.535
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.stakey.net,52.6485,4.73787
|
||||
relay.satsapp.me,50.4754,12.3683
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
nostrcity-club.fly.dev,38.7946,-106.535
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
relay.agora.social,50.7383,15.0648
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
relay.thewired.app,39.0438,-77.4874
|
||||
relay.s-w.art,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.computingcache.com,45.5341,-122.956
|
||||
openrelay.ziomc.com,41.8967,12.4822
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
relay.islandbitcoin.com,20.3898,78.0903
|
||||
hasky.chat,40.7862,-74.0743
|
||||
relay.mitosystem.net,-23.5558,-46.6396
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
staging.yabu.me,35.6096,139.73
|
||||
nostr.dzo-hadar.ts.net,35.6764,139.65
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
nostr.benderhost.org,53.3498,-6.26031
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
relay.layer.systems,43.6532,-79.3832
|
||||
relay.budabit.club,49.4543,11.0746
|
||||
relay2.orangesync.tech,40.7128,-74.006
|
||||
r.senx.io,50.6924,3.20113
|
||||
relay.artiostr.ch,43.6532,-79.3832
|
||||
relay.fiatdenier.com,43.7787,-79.5393
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.chatbett.de,40.7128,-74.006
|
||||
relay.shadowbip.com,54.352,18.6466
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
portal-relay.pareto.space,53.5488,9.98717
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
basspistol.org,49.0291,8.35696
|
||||
relay.notoshi.win,13.3622,100.983
|
||||
nostrride.io,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
nostria.at,48.3064,14.2861
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
nostr.zeroflux.net,50.4754,12.3683
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr.mad-social.net,50.4754,12.3683
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.hetuniversum.nl,43.6532,-79.3832
|
||||
relay.lotek-distro.com,43.6532,-79.3832
|
||||
nr.yay.so,46.2126,6.1154
|
||||
pymd.kamp.site,38.9072,-77.0369
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.bornheimer.app,50.1109,8.68213
|
||||
nostrcity-club.fly.dev:443,38.7946,-106.535
|
||||
relay.hivescope.xyz,40.4332,-3.62431
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.masize.com,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.colombiap2p.com,43.6532,-79.3832
|
||||
nostr.carroarmato0.be,51.0355,4.48285
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.trustr.ing,43.6548,-79.3885
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
dare-sydney-ethical-aspect.trycloudflare.com,43.6532,-79.3832
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.pacsarcade.org,32.7767,-96.797
|
||||
relay.nostr.dev.br,48.1046,11.6002
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
cyberspace.nostr1.com,40.7057,-74.0136
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
free.relayted.de,50.1109,8.68213
|
||||
relay.klabo.world,47.674,-122.122
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.czas.plus,50.1109,8.68213
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay.kizuna-miki.com,35.694,139.754
|
||||
nostras.app,38.7946,-106.535
|
||||
nostr.pro.aw,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
relay.crovaxon.de,53.0894,7.32917
|
||||
relay.carlismo.mx,50.1109,8.68213
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
relay.t6x.host,40.8302,-74.1299
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
relay.hivetalk.org,40.8302,-74.1299
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
relay.formstr.app,43.6532,-79.3832
|
||||
offchain.pub,39.1585,-94.5728
|
||||
chorus.bonsai.com,39.0438,-77.4874
|
||||
relay.favillakey.io,-27.4705,153.026
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.pornstr.net,53.3498,-6.26031
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
test.relays.nuts.cash,52.3676,4.90414
|
||||
auth.nostr1.com,40.7057,-74.0136
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.ltgnet.work:8443,51.0511,-114.075
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostr-relay.zimage.com,34.282,-118.439
|
||||
prl.plus,54.9486,21.7283
|
||||
relay.satsmarkt.club,52.3676,4.90414
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostrelay.circum.space:443,52.2245,8.826
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
nostr.jcera.eu,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.plebdevs.com,40.7128,-74.006
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
21milionidinostr.duckdns.org,45.519,9.08615
|
||||
nostr.alex71btc.com,43.6532,-79.3832
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.qstr.app,50.1109,8.68213
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
relayone.geektank.ai:443,39.0997,-94.5786
|
||||
relay.shychat.lol,39.8965,-80.1792
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.bitos.space,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.twentyone.ist,43.7696,-79.3309
|
||||
relay2.nostrasia.net,43.6532,-79.3832
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.nostr.space,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.trevena.info,43.6532,-79.3832
|
||||
support.flotilla.social,32.9483,-96.7299
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
nostrelay.circum.space,52.2245,8.826
|
||||
nostr.tbxnetworx.de,47.8267,10.0205
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.hazeem.xyz,43.6532,-79.3832
|
||||
n.kojira.io,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
relay.nostrasia.net,43.6532,-79.3832
|
||||
buzz.cashu.space,43.6532,-79.3832
|
||||
greenart7c3.feeds.relay.tools,38.6327,-90.1961
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
relay.nostrcade.com,37.79,-122.401
|
||||
groups.beginningend.com,35.2227,-97.4786
|
||||
nostr.self-determined.de,51.4379,14.2355
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
relay.underorion.se,51.4998,-0.011072
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.snotr.nl:49999,51.9758,4.31389
|
||||
baunilha.ie.apps.lnvps.cloud,53.3498,-6.26031
|
||||
n.bogs.ccwu.cc,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
nostr.chaima.info,51.5072,-0.127586
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
relay1.privkey.io,25.7617,-80.1918
|
||||
yabu.me,35.6096,139.73
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
cdn.satellite.earth,40.8302,-74.1299
|
||||
nos.lol:443,50.4754,12.3683
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.orangem.art,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay-na1.metanomalist.com,43.6532,-79.3832
|
||||
relay.piazza.today,48.122,11.589
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
nostr.malin.onl,43.6532,-79.3832
|
||||
budabit.nostr1.com,40.7057,-74.0136
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
wheat.oslim.dev,43.6532,-79.3832
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
spurline.safebox.dev,43.6532,-79.3832
|
||||
relay.chorus.community:443,50.1109,8.68213
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
relay.oranjkid.com,41.8781,-87.6298
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nostr.trepechov.com,49.4543,11.0746
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
bitcoiner.social,47.6743,-117.112
|
||||
relay.jmoose.rocks:443,32.7767,-96.797
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.nostrfeed.com,60.1699,24.9384
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay0.gfcom.info,13.7653,100.647
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
offchain.pub,39.1585,-94.5728
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
staging.yabu.me,35.6092,139.73
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay2.0xprivacy.online,43.6532,-79.3832
|
||||
relay.conduit.market,38.7946,-106.535
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.noeudlibre.fr,50.6924,3.20113
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.cypherguide.org,1.32123,103.695
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
relay.nostrnostr.net,43.6532,-79.3832
|
||||
no.str.cr,8.96171,-83.5246
|
||||
relay.littlebitstudios.com,34.8688,-86.5502
|
||||
relay.decentnewsroom.com,50.4754,12.3683
|
||||
zencrown.relay2aeon.quest,52.3061,4.6907
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
nostr-relayflare.enpitsulin.workers.dev,43.6532,-79.3832
|
||||
relay.jcera.eu,43.6532,-79.3832
|
||||
soloco.nl,43.6532,-79.3832
|
||||
nostr.linky.fit,50.1109,8.68213
|
||||
relay.nostrops.com,32.71,-96.6745
|
||||
nostr.nestoris.online,55.568,37.5853
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
denimroad.feeds.relay.tools,38.6327,-90.1961
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.lacrypta.ar,43.6532,-79.3832
|
||||
torly.smesh.lol,32.7767,-96.797
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
nostr.christiansass.de,51.1798,7.1925
|
||||
nostr.eu.freedomrelay.dev,49.0524,14.4347
|
||||
ec1.f7z.io,60.1699,24.9384
|
||||
relay.selfrule.co.za,43.6532,-79.3832
|
||||
bbw-nostr.xyz,34.0549,-118.243
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
0x-nostr-relay.fly.dev,38.7946,-106.535
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
porchlight.social,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
borgar.lol,52.3676,4.90414
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nos.xmark.cc,50.6916,3.20151
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
talon.quest,43.6532,-79.3832
|
||||
relay.substation.ninja,43.6532,-79.3832
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.now,35.8099,140.12
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.nostr.black,43.6532,-79.3832
|
||||
relay.cyberguy.fyi,52.3676,4.90414
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
relay.cloistr.xyz,42.337,-77.3178
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.wasabiwallet.io,43.6532,-79.3832
|
||||
relay.atsocy.com,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
scores.brainstorm.world,39.0997,-94.5786
|
||||
relay.rebelbase.social,50.965,5.50082
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relay.pprgb.app,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
vidar.nestoris.online,52.3676,4.90414
|
||||
nostr.trcjr.xyz,35.3821,-78.5486
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.duck1123.com,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.i9.eti.br,43.6532,-79.3832
|
||||
nostriches.club,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.openspecs.uid.ovh,50.9871,2.12554
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
dev-relay.nostr.space,43.6532,-79.3832
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay.gorrdy.cz,50.1109,8.68213
|
||||
nostr.bitcoinx.gr,49.0291,8.35696
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.jmoose.rocks,32.7767,-96.797
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
adre.su,59.9311,30.3609
|
||||
nostr.tabordalab.com,60.1699,24.9384
|
||||
relay.nostrconnect.com,41.5695,-83.9786
|
||||
relay.hjkl.zip,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
syb.lol,43.6532,-79.3832
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.stakey.net:443,52.6485,4.73787
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
soloco.nl,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.froth.zone,60.1699,24.9384
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
chorus.pjv.me,45.5201,-122.99
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.momostr.pink,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
relay.sovereignresonance.org,48.9006,2.25929
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.aidatanorge.no,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nostr.ac,38.958,-77.3592
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
no.str.cr,8.96171,-83.5246
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.qstr.app,51.5072,-0.127586
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
relay.chorus.community,48.5333,10.7
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
syb.lol:443,34.0549,-118.243
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
btc.klendazu.com,41.2861,1.24993
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.agora.social,50.7383,15.0648
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
porchlight.social,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.novacisko.cz,52.2026,20.9397
|
||||
prl.plus,55.7628,37.5983
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
basspistol.org,49.0291,8.35696
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay1.gfcom.info,13.9215,100.538
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.mikoshi.de,52.52,13.405
|
||||
syb.lol,34.0549,-118.243
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,51.5072,-0.127586
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
hol.is,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
communities.nos.social,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostrride.io,37.3986,-121.964
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
|
||||
|
@ -3,6 +3,7 @@ package com.bitchat.android.nostr
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
@ -26,7 +27,12 @@ import okhttp3.Request
|
||||
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"
|
||||
|
||||
// The source URL matches iOS GeoRelayDirectory. Download timing and parser
|
||||
// acceptance can leave clients with different loaded snapshots.
|
||||
internal const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv"
|
||||
// Download cache of ASSET_FILE_URL above, and the bundled list the weekly job
|
||||
// refreshes from it; the file names predate the source's move to online_relays_gps.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"
|
||||
@ -49,7 +55,10 @@ object RelayDirectory {
|
||||
private val relays: MutableList<RelayInfo> = mutableListOf()
|
||||
private val relaysLock = Any()
|
||||
|
||||
fun initialize(application: Application) {
|
||||
fun initialize(application: Application) =
|
||||
initialize(application, ioScope, System::currentTimeMillis)
|
||||
|
||||
internal fun initialize(application: Application, refreshScope: CoroutineScope, nowMillis: () -> Long) {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
@ -67,15 +76,8 @@ object RelayDirectory {
|
||||
|
||||
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)
|
||||
// One worker checks immediately, then waits a minute after each iteration.
|
||||
startPeriodicRefresh(application, refreshScope, nowMillis)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize RelayDirectory: ${e.message}")
|
||||
}
|
||||
@ -97,14 +99,18 @@ object RelayDirectory {
|
||||
}
|
||||
|
||||
val (lat, lon) = center
|
||||
return snapshot
|
||||
.asSequence()
|
||||
.sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) }
|
||||
.take(nRelays.coerceAtLeast(0))
|
||||
.map { it.url }
|
||||
.toList()
|
||||
return closestRelays(snapshot, lat, lon, nRelays)
|
||||
}
|
||||
|
||||
// City-level coordinates create distance ties. Break them by endpoint key,
|
||||
// following GeoRelayDirectory.closestRelays for the accepted directory rows.
|
||||
internal fun closestRelays(entries: List<RelayInfo>, lat: Double, lon: Double, nRelays: Int): List<String> =
|
||||
entries
|
||||
.map { it to haversineMeters(lat, lon, it.latitude, it.longitude) }
|
||||
.sortedWith(compareBy({ it.second }, { it.first.url }))
|
||||
.take(nRelays.coerceAtLeast(0))
|
||||
.map { it.first.url }
|
||||
|
||||
private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val R = 6371000.0 // meters
|
||||
val dLat = Math.toRadians(lat2 - lat1)
|
||||
@ -128,18 +134,18 @@ object RelayDirectory {
|
||||
private fun getDownloadedFile(application: Application): File =
|
||||
File(application.filesDir, DOWNLOADED_FILE)
|
||||
|
||||
private fun isStale(application: Application): Boolean {
|
||||
private fun isStale(application: Application, nowMillis: () -> Long): Boolean {
|
||||
val last = getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMillis()
|
||||
return now - last >= ONE_DAY_MS
|
||||
}
|
||||
|
||||
private fun startPeriodicRefresh(application: Application) {
|
||||
ioScope.launch {
|
||||
private fun startPeriodicRefresh(application: Application, refreshScope: CoroutineScope, nowMillis: () -> Long) {
|
||||
refreshScope.launch {
|
||||
while (true) {
|
||||
try {
|
||||
if (isStale(application)) {
|
||||
fetchAndMaybeSwap(application)
|
||||
if (isStale(application, nowMillis)) {
|
||||
fetchAndMaybeSwap(application, nowMillis)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Periodic refresh encountered an error: ${e.message}")
|
||||
@ -149,30 +155,27 @@ object RelayDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchAndMaybeSwap(application: Application) {
|
||||
private fun fetchAndMaybeSwap(application: Application, nowMillis: () -> Long) {
|
||||
var temporaryDownload: File? = null
|
||||
try {
|
||||
val tmpFile = File.createTempFile("relays_", ".csv", application.cacheDir)
|
||||
temporaryDownload = tmpFile
|
||||
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()
|
||||
Log.w(TAG, "Failed to fetch latest relays; keeping current list")
|
||||
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)
|
||||
}
|
||||
RelayDirectoryCache.replace(input, dest)
|
||||
}
|
||||
tmpFile.delete()
|
||||
|
||||
val hash = fileSha256Hex(dest)
|
||||
val entries = parsed.size
|
||||
@ -182,11 +185,17 @@ object RelayDirectory {
|
||||
relays.addAll(parsed)
|
||||
}
|
||||
|
||||
getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).apply()
|
||||
getPrefs(application).edit { putLong(KEY_LAST_UPDATE_MS, nowMillis()) }
|
||||
|
||||
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}")
|
||||
} finally {
|
||||
temporaryDownload?.let { file ->
|
||||
if (file.exists() && !file.delete()) {
|
||||
Log.w(TAG, "Could not remove temporary relay download")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,10 +204,10 @@ object RelayDirectory {
|
||||
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")
|
||||
Log.w(TAG, "HTTP ${resp.code} when fetching $url")
|
||||
return false
|
||||
}
|
||||
val body = resp.body ?: return false
|
||||
val body = resp.body
|
||||
FileOutputStream(dest).use { out ->
|
||||
body.byteStream().use { input ->
|
||||
input.copyTo(out)
|
||||
@ -250,13 +259,17 @@ object RelayDirectory {
|
||||
streamSha256Hex(input)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"error:${'$'}{e.message}"
|
||||
"error:${e.message}"
|
||||
}
|
||||
Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash")
|
||||
}
|
||||
|
||||
private fun parseCsv(input: InputStream): List<RelayInfo> {
|
||||
internal fun parseCsv(input: InputStream): List<RelayInfo> {
|
||||
val result = mutableListOf<RelayInfo>()
|
||||
// The directory lists some relays twice, once bare and once with an explicit
|
||||
// :443, which has the same endpoint key over wss. Without this check both copies can
|
||||
// land in a nearest-N selection, and one of its slots connects nowhere new.
|
||||
val seenEndpoints = HashSet<String>()
|
||||
BufferedReader(InputStreamReader(input)).use { reader ->
|
||||
var line: String?
|
||||
while (true) {
|
||||
@ -267,16 +280,38 @@ object RelayDirectory {
|
||||
if (trimmed.lowercase().startsWith("relay url")) continue
|
||||
val parts = trimmed.split(",")
|
||||
if (parts.size < 3) continue
|
||||
val url = normalizeRelayUrl(parts[0].trim())
|
||||
val raw = 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))
|
||||
if (raw.isEmpty() || lat == null || lon == null) continue
|
||||
val canonical = canonicalHost(raw)
|
||||
if (canonical.isEmpty() || !seenEndpoints.add(canonical)) continue
|
||||
result.add(RelayInfo(url = "wss://$canonical", latitude = lat, longitude = lon))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint key for deduplication and distance ties: lowercase host, with
|
||||
* port 443 omitted. This parser does not implement iOS directory validation.
|
||||
*/
|
||||
internal fun canonicalHost(url: String): String {
|
||||
val uri = try { java.net.URI(normalizeRelayUrl(url)) } catch (_: Exception) { return "" }
|
||||
if (uri.scheme?.lowercase() !in setOf("wss", "https")) return ""
|
||||
if (uri.rawUserInfo != null || uri.rawQuery != null || uri.rawFragment != null) return ""
|
||||
if (!uri.rawPath.isNullOrEmpty() && uri.rawPath != "/") return ""
|
||||
val hostPort = uri.rawAuthority ?: return ""
|
||||
// Reject URL structure that cannot be represented by a directory endpoint key.
|
||||
if (!hostPort.matches(Regex("[A-Za-z0-9.-]+(?::[0-9]+)?"))) return ""
|
||||
val idx = hostPort.lastIndexOf(':')
|
||||
val hasPort = idx > 0
|
||||
val host = (if (hasPort) hostPort.substring(0, idx) else hostPort).lowercase()
|
||||
val port = if (hasPort) (hostPort.substring(idx + 1).toIntOrNull() ?: return "") else 443
|
||||
if (port !in 1..65535) return ""
|
||||
return if (port == 443) host else "$host:$port"
|
||||
}
|
||||
|
||||
private fun fileSha256Hex(file: File): String = try {
|
||||
FileInputStream(file).use { input ->
|
||||
streamSha256Hex(input)
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption.ATOMIC_MOVE
|
||||
|
||||
internal object RelayDirectoryCache {
|
||||
fun replace(input: InputStream, destination: File) {
|
||||
// Stage beside the destination to keep the rename on the same filesystem.
|
||||
val staged = File.createTempFile("relays_install_", ".csv", destination.absoluteFile.parentFile)
|
||||
try {
|
||||
staged.outputStream().use { output -> input.copyTo(output) }
|
||||
Files.move(staged.toPath(), destination.toPath(), ATOMIC_MOVE)
|
||||
} finally {
|
||||
Files.deleteIfExists(staged.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelayDirectoryAddressTest {
|
||||
private fun assertRejectedBetweenValidRows(address: String) {
|
||||
val csv = "Relay URL,Latitude,Longitude\n" +
|
||||
"first.example,1,2\n$address,3,4\nlast.example:8443,5,6\n"
|
||||
val entries = RelayDirectory.parseCsv(ByteArrayInputStream(csv.toByteArray()))
|
||||
assertEquals(address, listOf("wss://first.example", "wss://last.example:8443"), entries.map { it.url })
|
||||
assertEquals(listOf(1.0 to 2.0, 5.0 to 6.0), entries.map { it.latitude to it.longitude })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects paths and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example/a")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects nonsecure websocket schemes and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("ws://relay.example:80")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects http schemes and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("http://relay.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects userinfo and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://user@relay.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects queries and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example?query=1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects fragments and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example#fragment")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects nonnumeric ports and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example:invalid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects zero ports and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example:0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects ports above range and preserves neighboring rows`() {
|
||||
assertRejectedBetweenValidRows("wss://relay.example:65536")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `secure schemes and bare hosts retain endpoint normalization`() {
|
||||
for (address in listOf("relay.example", "wss://RELAY.example:443/", "https://relay.example/")) {
|
||||
assertEquals(address, "relay.example", RelayDirectory.canonicalHost(address))
|
||||
}
|
||||
for (port in listOf(1, 8443, 65535)) {
|
||||
assertEquals("relay.example:$port", RelayDirectory.canonicalHost("wss://relay.example:$port/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class RelayDirectoryCacheTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `failed staging keeps the previous cache and removes staging files`() {
|
||||
val destination = temporary.newFile("directory.csv").apply { writeText("previous") }
|
||||
val input = object : InputStream() {
|
||||
var reads = 0
|
||||
override fun read(): Int = if (reads++ < 4) 'x'.code else throw IOException("synthetic read failure")
|
||||
}
|
||||
assertThrows(IOException::class.java) { input.use { RelayDirectoryCache.replace(it, destination) } }
|
||||
assertEquals("previous", destination.readText())
|
||||
assertEquals(listOf("directory.csv"), temporary.root.list()!!.sorted())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful staging replaces the existing cache`() {
|
||||
val destination = temporary.newFile("directory.csv").apply { writeText("previous") }
|
||||
"replacement".byteInputStream().use { RelayDirectoryCache.replace(it, destination) }
|
||||
assertEquals("replacement", destination.readText())
|
||||
assertEquals(listOf("directory.csv"), temporary.root.list()!!.sorted())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed rename keeps the destination directory and removes staging files`() {
|
||||
val destination = temporary.newFolder("directory.csv")
|
||||
File(destination, "keep").writeText("previous")
|
||||
assertThrows(IOException::class.java) {
|
||||
"replacement".byteInputStream().use { RelayDirectoryCache.replace(it, destination) }
|
||||
}
|
||||
assertEquals("previous", File(destination, "keep").readText())
|
||||
assertEquals(listOf("directory.csv"), temporary.root.list()!!.sorted())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RelayDirectoryPortTest {
|
||||
@Test
|
||||
fun `an oversized numeric port does not discard surrounding valid rows`() {
|
||||
val csv = "Relay URL,Latitude,Longitude\n" +
|
||||
"first.example,0,0\n" +
|
||||
"oversized.example:999999999999999999999,1,2\n" +
|
||||
"last.example:8443,3,4\n"
|
||||
val entries = RelayDirectory.parseCsv(csv.byteInputStream())
|
||||
assertEquals(listOf("wss://first.example", "wss://last.example:8443"), entries.map { it.url })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary numeric ports retain their endpoint keys`() {
|
||||
assertEquals("relay.example", RelayDirectory.canonicalHost("wss://relay.example:443"))
|
||||
assertEquals("relay.example:8443", RelayDirectory.canonicalHost("wss://relay.example:8443"))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,193 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import java.io.IOException
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.net.OkHttpProvider
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScheduler
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowLog
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class RelayDirectoryRefreshRobolectricTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private val application = mock<Application>()
|
||||
private val context: Application = ApplicationProvider.getApplicationContext()
|
||||
private val prefs = context.getSharedPreferences("relay-refresh-test", Context.MODE_PRIVATE)
|
||||
private val scheduler = TestCoroutineScheduler()
|
||||
private val refreshScope = TestScope(StandardTestDispatcher(scheduler))
|
||||
private var requests = 0
|
||||
private val now = 172_800_000L
|
||||
private lateinit var files: File
|
||||
private lateinit var cache: File
|
||||
private val previous = "Relay URL,Latitude,Longitude\nprevious.example,0,0\n"
|
||||
private val update = previous + "updated.example,0,0\n" +
|
||||
(1..48).joinToString("\n", postfix = "\n") { "other-$it.example,1,1" }
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
RelayDirectory::class.java.getDeclaredField("initialized").apply { isAccessible = true }
|
||||
.setBoolean(RelayDirectory, false)
|
||||
files = temporary.newFolder("files")
|
||||
cache = temporary.newFolder("cache")
|
||||
whenever(application.filesDir).thenReturn(files)
|
||||
whenever(application.cacheDir).thenReturn(cache)
|
||||
whenever(application.getSharedPreferences("relay_directory_prefs", Context.MODE_PRIVATE)).thenReturn(prefs)
|
||||
prefs.edit().clear().putLong("last_update_ms", 123L)
|
||||
.putString("source_url", RelayDirectory.ASSET_FILE_URL).commit()
|
||||
val file = File(files, "nostr_relays_latest.csv").apply { writeText(previous) }
|
||||
RelayDirectory::class.java.getDeclaredMethod("loadFromFile", File::class.java, String::class.java)
|
||||
.apply { isAccessible = true }.invoke(RelayDirectory, file, "synthetic")
|
||||
ShadowLog.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
refreshScope.cancel()
|
||||
scheduler.runCurrent()
|
||||
RelayDirectory::class.java.getDeclaredField("initialized").apply { isAccessible = true }
|
||||
.setBoolean(RelayDirectory, false)
|
||||
val field = RelayDirectory::class.java.getDeclaredField("relays").apply { isAccessible = true }
|
||||
(field.get(RelayDirectory) as MutableList<*>).clear()
|
||||
prefs.edit().clear().commit()
|
||||
}
|
||||
|
||||
private fun withResponse(code: Int, body: String, block: () -> Unit) {
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
requests++
|
||||
Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1)
|
||||
.code(code).message("synthetic").body(body.toResponseBody()).build()
|
||||
}.build()
|
||||
val field = OkHttpProvider::class.java.getDeclaredField("httpClientRef").apply { isAccessible = true }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val reference = field.get(OkHttpProvider) as AtomicReference<OkHttpProvider.RoutedClient?>
|
||||
val saved = reference.getAndSet(OkHttpProvider.RoutedClient(client, OkHttpProvider.Route.DIRECT))
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
reference.set(saved)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(code: Int, body: String) = withResponse(code, body) {
|
||||
RelayDirectory::class.java.getDeclaredMethod("fetchAndMaybeSwap", Application::class.java, Function0::class.java)
|
||||
.apply { isAccessible = true }.invoke(RelayDirectory, application, { now })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale initialization uses one refresh worker and one request per interval`() {
|
||||
withResponse(503, "unavailable") {
|
||||
RelayDirectory.initialize(application, refreshScope) { now }
|
||||
RelayDirectory.initialize(application, refreshScope) { now }
|
||||
assertEquals(1, refreshScope.coroutineContext.job.children.count())
|
||||
scheduler.runCurrent()
|
||||
assertEquals(1, requests)
|
||||
scheduler.advanceTimeBy(59_999)
|
||||
scheduler.runCurrent()
|
||||
assertEquals(1, requests)
|
||||
scheduler.advanceTimeBy(1)
|
||||
scheduler.runCurrent()
|
||||
assertEquals(2, requests)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful initialization records injected time and refreshes at the day boundary`() {
|
||||
var clock = now
|
||||
val day = 86_400_000L
|
||||
withResponse(200, update) {
|
||||
RelayDirectory.initialize(application, refreshScope) { clock }
|
||||
scheduler.runCurrent()
|
||||
assertEquals(1, requests)
|
||||
assertEquals(clock, prefs.getLong("last_update_ms", 0L))
|
||||
|
||||
clock = now + day - 1
|
||||
scheduler.advanceTimeBy(60_000)
|
||||
scheduler.runCurrent()
|
||||
assertEquals("no refresh before the injected day boundary", 1, requests)
|
||||
assertEquals(now, prefs.getLong("last_update_ms", 0L))
|
||||
|
||||
clock = now + day
|
||||
scheduler.advanceTimeBy(60_000)
|
||||
scheduler.runCurrent()
|
||||
assertEquals("refresh at the injected day boundary", 2, requests)
|
||||
assertEquals(clock, prefs.getLong("last_update_ms", 0L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertPrevious() {
|
||||
assertEquals(previous, File(files, "nostr_relays_latest.csv").readText())
|
||||
assertEquals(listOf("wss://previous.example"), RelayDirectory.closestRelaysForGeohash("s0000", 5))
|
||||
assertEquals(123L, prefs.getLong("last_update_ms", 0L))
|
||||
assertTrue(cache.list()!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asset hash failure reports the exception text`() {
|
||||
val assets = mock<AssetManager>()
|
||||
whenever(application.assets).thenReturn(assets)
|
||||
whenever(assets.open("nostr_relays.csv")).thenAnswer { throw IOException("synthetic asset failure") }
|
||||
RelayDirectory::class.java.getDeclaredMethod("loadFromAssets", Application::class.java)
|
||||
.apply { isAccessible = true }.invoke(RelayDirectory, application)
|
||||
assertTrue(ShadowLog.getLogsForTag("RelayDirectory").any {
|
||||
it.msg.contains("sha256=error:synthetic asset failure")
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP failure retains state cleans download and reports status code`() {
|
||||
fetch(503, "unavailable")
|
||||
assertPrevious()
|
||||
assertTrue(ShadowLog.getLogsForTag("RelayDirectory").any { it.msg.contains("HTTP 503") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusable destination retains state and cleans temporary download`() {
|
||||
whenever(application.filesDir).thenReturn(File(files, "missing-parent"))
|
||||
fetch(200, update)
|
||||
assertPrevious()
|
||||
assertTrue(ShadowLog.getLogsForTag("RelayDirectory").any { it.msg.startsWith("Failed to fetch and swap") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected empty download retains state and cleans temporary download`() {
|
||||
fetch(200, "")
|
||||
assertPrevious()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful refresh installs cache updates selection and timestamp`() {
|
||||
fetch(200, update)
|
||||
assertEquals(update, File(files, "nostr_relays_latest.csv").readText())
|
||||
assertEquals(listOf("wss://previous.example", "wss://updated.example"), RelayDirectory.closestRelaysForGeohash("s0000", 2))
|
||||
assertEquals(now, prefs.getLong("last_update_ms", 0L))
|
||||
assertTrue(cache.list()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Covers the configured source URL, endpoint deduplication and deterministic distance ties. */
|
||||
class RelayDirectoryTest {
|
||||
|
||||
private fun parse(csv: String) =
|
||||
RelayDirectory.parseCsv(ByteArrayInputStream(csv.toByteArray()))
|
||||
|
||||
private val header = "Relay URL,Latitude,Longitude\n"
|
||||
|
||||
@Test
|
||||
fun `fetch url points to online_relays_gps csv`() {
|
||||
assertEquals(
|
||||
"https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv",
|
||||
RelayDirectory.ASSET_FILE_URL
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical host normalizes host case and default port`() {
|
||||
assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com"))
|
||||
assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com:443"))
|
||||
assertEquals("relay.example.com:8443", RelayDirectory.canonicalHost("wss://relay.example.com:8443"))
|
||||
assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://Relay.Example.Com/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `port 443 variant of a host is the same endpoint key and is not listed twice`() {
|
||||
val entries = parse(
|
||||
header +
|
||||
"relay.example.com,10.0,20.0\n" +
|
||||
"relay.example.com:443,10.0,20.0\n"
|
||||
)
|
||||
assertEquals(1, entries.size)
|
||||
assertEquals("wss://relay.example.com", entries[0].url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nonstandard port is a distinct endpoint and stays`() {
|
||||
// A nondefault port has a distinct endpoint key.
|
||||
val entries = parse(
|
||||
header +
|
||||
"port-variant.relay.example,1.0,1.0\n" +
|
||||
"port-variant.relay.example:8443,1.0,1.0\n"
|
||||
)
|
||||
assertEquals(2, entries.size)
|
||||
assertEquals("wss://port-variant.relay.example", entries[0].url)
|
||||
assertEquals("wss://port-variant.relay.example:8443", entries[1].url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first row wins when an endpoint is listed twice`() {
|
||||
val entries = parse(
|
||||
header +
|
||||
"relay.example.com,10.0,20.0\n" +
|
||||
"relay.example.com:443,50.0,60.0\n"
|
||||
)
|
||||
assertEquals(1, entries.size)
|
||||
assertEquals(10.0, entries[0].latitude, 0.0)
|
||||
assertEquals(20.0, entries[0].longitude, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `host case does not create a second endpoint`() {
|
||||
val entries = parse(
|
||||
header +
|
||||
"Relay.Example.Com:443,10.0,20.0\n" +
|
||||
"relay.example.com,10.0,20.0\n"
|
||||
)
|
||||
assertEquals(1, entries.size)
|
||||
assertEquals("wss://relay.example.com", entries[0].url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distance ties order by endpoint key`() {
|
||||
// Shared coordinates create ties; shuffled rows exercise the endpoint tie-break.
|
||||
val csv = header +
|
||||
"delta.example.com,12.34,56.78\n" +
|
||||
"foxtrot.example.com,12.34,56.78\n" +
|
||||
"alpha.example.com:443,12.34,56.78\n" +
|
||||
"echo.example.com,12.34,56.78\n" +
|
||||
"bravo.example.com,12.34,56.78\n" +
|
||||
"charlie.example.com,12.34,56.78\n"
|
||||
val five = RelayDirectory.closestRelays(parse(csv), 12.34, 56.78, 5)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"wss://alpha.example.com",
|
||||
"wss://bravo.example.com",
|
||||
"wss://charlie.example.com",
|
||||
"wss://delta.example.com",
|
||||
"wss://echo.example.com"
|
||||
),
|
||||
five
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five nearest means five distinct endpoint keys`() {
|
||||
// The nearest relay is listed twice
|
||||
// (bare and :443), which used to occupy two of the five selection slots and
|
||||
// push out the fifth distinct endpoint.
|
||||
val csv = header +
|
||||
"nearest.example.com,10.10,20.20\n" +
|
||||
"nearest.example.com:443,10.10,20.20\n" +
|
||||
"second.example.com,10.20,20.20\n" +
|
||||
"third.example.com,10.30,20.20\n" +
|
||||
"fourth.example.com,10.40,20.20\n" +
|
||||
"fifth.example.com,10.50,20.20\n" +
|
||||
"faraway.example.com,80.0,120.0\n"
|
||||
val five = RelayDirectory.closestRelays(parse(csv), 10.10, 20.20, 5)
|
||||
assertEquals(5, five.size)
|
||||
assertEquals("selected endpoint keys are distinct", 5, five.toSet().size)
|
||||
assertTrue("the fifth distinct endpoint makes the cut", five.contains("wss://fifth.example.com"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
"wss://nearest.example.com",
|
||||
"wss://second.example.com",
|
||||
"wss://third.example.com",
|
||||
"wss://fourth.example.com",
|
||||
"wss://fifth.example.com"
|
||||
),
|
||||
five
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class RelaySelectionManifestTest {
|
||||
private val header = "id\tcsv\tlatitude\tlongitude\tcount\texpected\n"
|
||||
private val row = "one\tties.csv\t0\t0\t1\twss://alpha.example\n"
|
||||
|
||||
@Test
|
||||
fun `valid manifest provides a named case`() {
|
||||
assertEquals("one", RelaySelectionVectorTest.parseCases(header + row).single()[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty manifest is rejected`() {
|
||||
for (text in listOf("", header)) {
|
||||
assertThrows(IllegalArgumentException::class.java) { RelaySelectionVectorTest.parseCases(text) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong header and duplicate IDs are rejected`() {
|
||||
for (text in listOf("wrong\n" + row, header + row + row)) {
|
||||
assertThrows(IllegalArgumentException::class.java) { RelaySelectionVectorTest.parseCases(text) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed fields are rejected`() {
|
||||
for (bad in listOf(
|
||||
row.replace("ties.csv", "../ties.csv"),
|
||||
row.replace("\t0\t0\t", "\tNaN\t0\t"),
|
||||
row.replace("\t1\t", "\t2147483648\t"),
|
||||
row.replace("wss://alpha.example", "")
|
||||
)) {
|
||||
assertThrows(IllegalArgumentException::class.java) { RelaySelectionVectorTest.parseCases(header + bad) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
/** Shared literal expectations for accepted directory rows, not parser equivalence. */
|
||||
@RunWith(Parameterized::class)
|
||||
class RelaySelectionVectorTest(private val id: String, private val fields: List<String>) {
|
||||
@Test
|
||||
fun `selection matches the shared vector`() {
|
||||
val entries = resource(fields[1]).byteInputStream().use { RelayDirectory.parseCsv(it) }
|
||||
val expected = if (fields[5] == "-") emptyList() else fields[5].split('|')
|
||||
assertEquals(id, expected, RelayDirectory.closestRelays(
|
||||
entries, fields[2].toDouble(), fields[3].toDouble(), fields[4].toInt()
|
||||
))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun resource(name: String): String = requireNotNull(
|
||||
RelaySelectionVectorTest::class.java.getResourceAsStream("/contracts/relay-selection-v1/$name")
|
||||
).bufferedReader().use { it.readText() }
|
||||
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun cases(): List<Array<Any>> = parseCases(resource("cases.tsv"))
|
||||
|
||||
internal fun parseCases(text: String): List<Array<Any>> {
|
||||
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
||||
require(lines.firstOrNull() == "id\tcsv\tlatitude\tlongitude\tcount\texpected") { "Invalid vector header" }
|
||||
require(lines.size > 1) { "No selection vectors" }
|
||||
val ids = mutableSetOf<String>()
|
||||
return lines.drop(1).map { line ->
|
||||
val fields = line.split('\t')
|
||||
require(fields.size == 6) { "Invalid vector fields" }
|
||||
require(fields[0].isNotBlank() && ids.add(fields[0])) { "Invalid or duplicate vector ID" }
|
||||
require(Regex("[a-z0-9-]+\\.csv").matches(fields[1])) { "Invalid vector filename" }
|
||||
require(fields[2].toDouble().isFinite() && fields[2].toDouble() in -90.0..90.0)
|
||||
require(fields[3].toDouble().isFinite() && fields[3].toDouble() in -180.0..180.0)
|
||||
fields[4].toInt()
|
||||
require(fields[5] == "-" || fields[5].split('|').all { it.startsWith("wss://") && it.length > 6 })
|
||||
arrayOf<Any>(fields[0], fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
id csv latitude longitude count expected
|
||||
ties-five ties.csv 12.34 56.78 5 wss://alpha.example|wss://bravo.example|wss://charlie.example|wss://delta.example|wss://echo.example
|
||||
ties-reversed ties-reversed.csv 12.34 56.78 5 wss://alpha.example|wss://bravo.example|wss://charlie.example|wss://delta.example|wss://echo.example
|
||||
ties-one ties.csv 12.34 56.78 1 wss://alpha.example
|
||||
ties-all ties.csv 12.34 56.78 10 wss://alpha.example|wss://bravo.example|wss://charlie.example|wss://delta.example|wss://echo.example|wss://foxtrot.example
|
||||
zero-count ties.csv 12.34 56.78 0 -
|
||||
negative-count ties.csv 12.34 56.78 -1 -
|
||||
duplicates-five duplicates.csv 10.1 20.2 5 wss://nearest.example|wss://nearest.example:8443|wss://second.example|wss://third.example|wss://fourth.example
|
||||
duplicates-all duplicates.csv 10.1 20.2 20 wss://nearest.example|wss://nearest.example:8443|wss://second.example|wss://third.example|wss://fourth.example|wss://far.example
|
||||
longitude-east longitude.csv 0 19 3 wss://near.example|wss://middle.example|wss://far.example
|
||||
longitude-west longitude.csv 0 69 3 wss://far.example|wss://middle.example|wss://near.example
|
||||
dateline-east dateline.csv 0 179 3 wss://near.example|wss://across.example|wss://far.example
|
||||
dateline-west dateline.csv 0 -179 3 wss://across.example|wss://near.example|wss://far.example
|
||||
latitude-weight latitude-weight.csv 60 20 3 wss://east.example|wss://north.example|wss://far.example
|
||||
|
@ -0,0 +1,4 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
far.example,0,150
|
||||
across.example,0,-179
|
||||
near.example,0,178
|
||||
|
@ -0,0 +1,8 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
Nearest.Example:443,10.1,20.2
|
||||
nearest.example,10.1,20.2
|
||||
nearest.example:8443,10.1,20.2
|
||||
second.example,10.2,20.2
|
||||
third.example,10.3,20.2
|
||||
fourth.example,10.4,20.2
|
||||
far.example,80,120
|
||||
|
@ -0,0 +1,4 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
east.example,60,23
|
||||
north.example,62,20
|
||||
far.example,63,24
|
||||
|
@ -0,0 +1,4 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
far.example,0,70
|
||||
near.example,0,20
|
||||
middle.example,0,40
|
||||
|
@ -0,0 +1,7 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
charlie.example,12.34,56.78
|
||||
bravo.example,12.34,56.78
|
||||
echo.example,12.34,56.78
|
||||
Alpha.Example:443,12.34,56.78
|
||||
foxtrot.example,12.34,56.78
|
||||
delta.example,12.34,56.78
|
||||
|
@ -0,0 +1,7 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
delta.example,12.34,56.78
|
||||
foxtrot.example,12.34,56.78
|
||||
Alpha.Example:443,12.34,56.78
|
||||
echo.example,12.34,56.78
|
||||
bravo.example,12.34,56.78
|
||||
charlie.example,12.34,56.78
|
||||
|
@ -1,25 +1,31 @@
|
||||
# Client rewrite compatibility contracts
|
||||
|
||||
This document defines the behavior a from-scratch BitChat client must preserve.
|
||||
The executable source of truth is the JVM test suite under
|
||||
`app/src/test/**/contracts`, together with the pre-existing protocol, security,
|
||||
mesh, and state tests.
|
||||
The JVM contract, protocol, security, mesh and state tests provide executable
|
||||
checks for parts of this contract.
|
||||
|
||||
The remaining implementation work and milestone progress are tracked in
|
||||
[test-implementation-plan.md](test-implementation-plan.md).
|
||||
|
||||
## Required contract layers
|
||||
|
||||
| Layer | Compatibility promise | Primary tests |
|
||||
The listed tests cover parts of each layer; they do not verify every requirement
|
||||
in the corresponding row.
|
||||
|
||||
| Layer | Required compatibility behavior | Selected tests (partial coverage) |
|
||||
|---|---|---|
|
||||
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes, per-type payload caps ([PACKET_PAYLOAD_LIMITS.md](PACKET_PAYLOAD_LIMITS.md)) | `BinaryProtocolTest`, `ClientRewriteWireContractTest`, `PacketPayloadLimitsWireContractTest` |
|
||||
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
|
||||
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
|
||||
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
|
||||
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest`, `AppStateStoreTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `NostrProtocolTest` (authenticated-seal cases and outbound timestamp bounds) |
|
||||
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
|
||||
|
||||
The private-history clock-skew case in `AppStateStoreTest` expects arrival
|
||||
order, which can differ from timestamp order. That test does not establish the
|
||||
chronological-history requirement above.
|
||||
|
||||
## Golden-vector policy
|
||||
|
||||
Golden vectors compare literal externally visible bytes or hashes. Do not update
|
||||
@ -28,13 +34,35 @@ wire protocol is intentionally versioned and interoperating clients are updated
|
||||
together.
|
||||
|
||||
Round-trip tests remain useful but are not sufficient on their own: an encoder
|
||||
and decoder can share the same defect. Each critical wire format therefore has
|
||||
at least one literal vector.
|
||||
and decoder can share the same defect. Protocol round-trip tests must be paired with literal golden vectors for critical
|
||||
externally visible formats. Round-trip success alone does not establish compatibility.
|
||||
|
||||
NIP-17 receivers should reserve safety slack beyond the maximum timestamp
|
||||
randomization used by senders. Android caps outbound seal and gift-wrap
|
||||
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
|
||||
subscription window, while retaining its 48-hour receive lookback.
|
||||
randomization used by senders. Android's default seal and gift-wrap timestamp
|
||||
randomization is up to 22h. iOS's DM subscription lookback is 24h, a two-hour
|
||||
difference. `NostrClient.subscribeToPrivateMessages` requests a 48-hour lookback.
|
||||
These intervals do not establish delivery under arbitrary delays or clock skew.
|
||||
|
||||
Geohash directory downloads and the weekly bundled-asset update use
|
||||
`relays/online_relays_gps.csv` from the bitchat repo, the source used by iOS
|
||||
`GeoRelayDirectory`. Android deduplicates parsed rows by a lowercase host key,
|
||||
omits port 443, includes other ports in 1 through 65535, and
|
||||
breaks distance ties by that key. `RelayDirectoryTest` covers endpoint normalization, duplicate removal and distance ties.
|
||||
`RelayDirectoryAddressTest` covers address rejection and port boundaries;
|
||||
`RelayDirectoryPortTest` covers integer-conversion overflow. Shared synthetic selection
|
||||
vectors and the Android/Swift check commands are documented in
|
||||
[the relay-selection test guide](../tools/relay-selection/README.md).
|
||||
|
||||
Matching selections depend on the loaded entries. Bundled and cached copies
|
||||
can differ between clients; a configured source URL does not establish equal
|
||||
snapshots or successful delivery. Parsing remains permissive: rows with fewer
|
||||
than three fields or unparseable coordinates are skipped, and duplicate
|
||||
endpoints keep the first accepted row. Directory addresses accept bare hostnames or
|
||||
`wss`/`https` URLs with an empty or root path. Userinfo, queries, fragments,
|
||||
non-root paths and unsupported schemes are skipped; hostname characters are
|
||||
limited to ASCII letters, digits, dots and hyphens. iOS also checks hosts and coordinate ranges
|
||||
and rejects a directory containing conflicting duplicate rows. For a host followed by a digit-only port, an integer-conversion failure skips
|
||||
that row. `RelayDirectoryPortTest` covers oversized ports between valid rows.
|
||||
|
||||
Geohash (recipient-less) `bitchat1:` envelopes and their acks carry 8 fresh
|
||||
random bytes in the embedded packet's sender field, never the mesh peer ID,
|
||||
@ -56,15 +84,16 @@ From a configured Android development environment, run:
|
||||
./gradlew clientRewriteContractTest
|
||||
```
|
||||
|
||||
The task runs the new golden vectors and the complete existing unit suite. A
|
||||
rewrite is compatible only when this gate passes. Tests should be ported
|
||||
The task runs `:app:testDebugUnitTest`, including the wire contract tests.
|
||||
It must pass before accepting a rewrite. Passing it does not establish Wear or physical-device compatibility. Tests should be ported
|
||||
unchanged when package boundaries change; adapter façades are preferable to
|
||||
weakening assertions.
|
||||
|
||||
## Device-only acceptance
|
||||
|
||||
Local JVM tests cannot prove Android radio and lifecycle behavior. Before
|
||||
shipping a rewrite, run the following on at least two physical devices:
|
||||
Local JVM tests alone do not establish radio operation or lifecycle behavior
|
||||
on physical Android devices. Before shipping a rewrite, run the following on
|
||||
at least two physical devices:
|
||||
|
||||
1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay.
|
||||
2. Runtime permission denial/retry for Bluetooth, location, notifications, and
|
||||
|
||||
54
tools/relay-selection/README.md
Normal file
54
tools/relay-selection/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Relay selection vectors
|
||||
|
||||
`app/src/test/resources/contracts/relay-selection-v1/` contains synthetic CSV
|
||||
files and literal expected URL lists. `cases.tsv` records each case's directory,
|
||||
query latitude/longitude, requested count and ordered result (`|` separated;
|
||||
`-` means an empty result).
|
||||
|
||||
For accepted directory rows, selection sorts by Haversine distance from the query
|
||||
coordinate, ascending, then by canonical endpoint key for equal computed distances.
|
||||
It returns up to `max(count, 0)` URLs. These coordinate-based vectors do not test
|
||||
geohash decoding or establish identical floating-point ordering for arbitrary
|
||||
coordinates.
|
||||
|
||||
Run the Android production parser and selection helper through the JVM suite:
|
||||
|
||||
```sh
|
||||
./gradlew :app:testDebugUnitTest --tests 'com.bitchat.android.nostr.RelaySelectionVectorTest'
|
||||
```
|
||||
|
||||
With Python 3 and Swift available, check the same cases against a local iOS
|
||||
source file:
|
||||
|
||||
```sh
|
||||
python3 tools/relay-selection/check_ios.py <ios-checkout>/bitchat/Nostr/GeoRelayDirectory.swift
|
||||
```
|
||||
|
||||
The script reads that file without modifying it or fetching anything. It extracts
|
||||
its validation policy, entry type, parser, address validator, coordinate-based
|
||||
selector and distance function into a temporary Swift wrapper. The selected
|
||||
declarations are unchanged; unrelated application dependencies are excluded.
|
||||
Source and extracted-declaration hashes identify the implementation checked.
|
||||
Missing declarations, malformed or empty vector manifests, and compilation failures
|
||||
stop the check. Source changes
|
||||
require checking that extraction still covers the relevant dependencies.
|
||||
|
||||
Check the local runner’s input and extraction guards with:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tools/relay-selection -p 'test_*.py'
|
||||
```
|
||||
|
||||
The fixtures cover identical-coordinate ties, reversed row order, lowercase
|
||||
endpoint keys, default-port duplicates, a nondefault port, requested-count limits,
|
||||
longitude-dependent order, latitude weighting and selection across the dateline. Both implementations
|
||||
are compared with literal expectations, rather than generating one client's
|
||||
expected output from the other. The Android test runs with the unit suite; the
|
||||
Swift check is an explicit local command and requires the separate source checkout.
|
||||
|
||||
The CSVs use reserved example hosts and invented coordinates. They are small,
|
||||
accepted directories; the Swift parser uses a one-entry minimum without a prior
|
||||
snapshot. These are selection tests, not remote-download admission tests. They do
|
||||
not require identical parser acceptance, exercise geohash decoding, or establish
|
||||
network delivery or physical-device compatibility. Update expectations only after
|
||||
explaining the intended selection-policy change and checking affected clients.
|
||||
104
tools/relay-selection/check_ios.py
Normal file
104
tools/relay-selection/check_ios.py
Normal file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check shared selection vectors using methods extracted from a supplied iOS source file."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def declaration(source, marker):
|
||||
# Match the declaration's own closing indentation, not nested closure braces.
|
||||
lines = source.splitlines(keepends=True)
|
||||
matches = [i for i, line in enumerate(lines) if line.strip().startswith(marker)]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Expected exactly one declaration: {marker}")
|
||||
start = matches[0]
|
||||
indent = re.match(r" *", lines[start]).group()
|
||||
for end in range(start + 1, len(lines)):
|
||||
if lines[end].rstrip() == indent + "}":
|
||||
return "".join(lines[start:end + 1])
|
||||
raise ValueError(f"Missing closing brace: {marker}")
|
||||
|
||||
|
||||
def validate_cases(text):
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
if not lines or lines[0] != "id\tcsv\tlatitude\tlongitude\tcount\texpected":
|
||||
raise ValueError("Invalid vector header")
|
||||
if len(lines) == 1:
|
||||
raise ValueError("No selection vectors")
|
||||
ids = set()
|
||||
for line in lines[1:]:
|
||||
f = line.split("\t")
|
||||
if len(f) != 6:
|
||||
raise ValueError("Invalid vector fields")
|
||||
if not f[0].strip() or f[0] in ids:
|
||||
raise ValueError("Invalid or duplicate vector ID")
|
||||
ids.add(f[0])
|
||||
if not re.fullmatch(r"[a-z0-9-]+\.csv", f[1]):
|
||||
raise ValueError("Invalid vector filename")
|
||||
lat, lon, count = float(f[2]), float(f[3]), int(f[4])
|
||||
if not (math.isfinite(lat) and -90 <= lat <= 90 and math.isfinite(lon) and -180 <= lon <= 180):
|
||||
raise ValueError("Invalid vector coordinates")
|
||||
if not -(2**31) <= count < 2**31:
|
||||
raise ValueError("Invalid vector count")
|
||||
if f[5] != "-" and not all(x.startswith("wss://") and len(x) > 6 for x in f[5].split("|")):
|
||||
raise ValueError("Invalid vector expectations")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("ios_source", type=Path, help="Read-only GeoRelayDirectory.swift")
|
||||
parser.add_argument("--fixtures", type=Path, default=Path(__file__).resolve().parents[2] /
|
||||
"app/src/test/resources/contracts/relay-selection-v1")
|
||||
args = parser.parse_args()
|
||||
validate_cases((args.fixtures / "cases.tsv").read_text())
|
||||
source_bytes = args.ios_source.read_bytes()
|
||||
source = source_bytes.decode("utf-8")
|
||||
parts = [declaration(source, marker) for marker in (
|
||||
"struct GeoRelayDirectoryValidationPolicy:", "struct Entry:",
|
||||
"nonisolated static func validatedEntries(",
|
||||
"nonisolated private static func validatedDirectoryAddress(",
|
||||
"func closestRelays(toLat", "private func haversineKm(")]
|
||||
# Keep the selected declarations verbatim. Supply only the containing type and entries.
|
||||
swift = "import Foundation\n" + parts[0] + "\nstruct VectorDirectory {\n" + parts[1]
|
||||
swift += "var entries: [Entry]\n" + "\n".join(parts[2:5]) + "\n}\n" + parts[5]
|
||||
swift += r'''
|
||||
let root = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let rows = try String(contentsOf: root.appendingPathComponent("cases.tsv"), encoding: .utf8)
|
||||
var failures = 0
|
||||
var count = 0
|
||||
for row in rows.split(separator: "\n").dropFirst() {
|
||||
let f = row.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
|
||||
guard f.count == 6, let lat = Double(f[2]), let lon = Double(f[3]), let n = Int(f[4]) else {
|
||||
fatalError("Malformed vector")
|
||||
}
|
||||
let data = try Data(contentsOf: root.appendingPathComponent(f[1]))
|
||||
guard let entries = VectorDirectory.validatedEntries(from: data, policy: .live, minimumEntries: 1) else {
|
||||
fatalError("Vector directory rejected: \(f[0])")
|
||||
}
|
||||
let expected = f[5] == "-" ? [] : f[5].split(separator: "|").map(String.init)
|
||||
let actual = VectorDirectory(entries: entries).closestRelays(toLat: lat, lon: lon, count: n)
|
||||
count += 1
|
||||
if actual != expected {
|
||||
failures += 1
|
||||
print("FAIL \(f[0]): expected \(expected), got \(actual)")
|
||||
}
|
||||
}
|
||||
print("\(count) selection vectors, \(failures) failures")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
'''
|
||||
print("iOS source SHA256:", hashlib.sha256(source_bytes).hexdigest(), flush=True)
|
||||
print("Extracted declarations SHA256:", hashlib.sha256("".join(parts).encode()).hexdigest(), flush=True)
|
||||
with tempfile.TemporaryDirectory(prefix="relay-vectors-") as tmp:
|
||||
path = Path(tmp)
|
||||
(path / "main.swift").write_text(swift)
|
||||
subprocess.run(["swiftc", "-module-cache-path", str(path / "modules"), str(path / "main.swift"), "-o", str(path / "check")], check=True)
|
||||
result = subprocess.run([str(path / "check"), str(args.fixtures.resolve())])
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
38
tools/relay-selection/test_check_ios.py
Normal file
38
tools/relay-selection/test_check_ios.py
Normal file
@ -0,0 +1,38 @@
|
||||
import unittest
|
||||
from check_ios import declaration, validate_cases
|
||||
|
||||
HEADER = 'id\tcsv\tlatitude\tlongitude\tcount\texpected\n'
|
||||
ROW = 'one\tties.csv\t0\t0\t1\twss://alpha.example\n'
|
||||
|
||||
|
||||
class VectorInputTest(unittest.TestCase):
|
||||
def test_valid_manifest(self):
|
||||
validate_cases(HEADER + ROW)
|
||||
|
||||
def test_empty_manifest_rejected(self):
|
||||
for text in ('', HEADER):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
validate_cases(text)
|
||||
|
||||
def test_bad_header_and_duplicate_id_rejected(self):
|
||||
for text in ('wrong\n' + ROW, HEADER + ROW + ROW):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
validate_cases(text)
|
||||
|
||||
def test_malformed_fields_rejected(self):
|
||||
for row in (ROW.replace('ties.csv', '../ties.csv'), ROW.replace('\t0\t0\t', '\tnan\t0\t'), ROW.replace('\t1\t', '\t2147483648\t'), ROW.replace('wss://alpha.example', '')):
|
||||
with self.subTest(row=row), self.assertRaises(ValueError):
|
||||
validate_cases(HEADER + row)
|
||||
|
||||
def test_extraction_preserves_nested_declaration(self):
|
||||
text = 'struct Example {\n func value() {\n if true {\n }\n }\n}\n'
|
||||
self.assertEqual(' func value() {\n if true {\n }\n }\n', declaration(text, 'func value('))
|
||||
|
||||
def test_missing_duplicate_or_unclosed_declaration_rejected(self):
|
||||
for text in ('', 'struct X {\n}\nstruct X {\n}\n', 'struct X {\n'):
|
||||
with self.subTest(text=text), self.assertRaises(ValueError):
|
||||
declaration(text, 'struct X')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user