diff --git a/.github/workflows/fetch-georelays.yml b/.github/workflows/fetch-georelays.yml index 8f91c53df..24d9814ed 100644 --- a/.github/workflows/fetch-georelays.yml +++ b/.github/workflows/fetch-georelays.yml @@ -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 diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 7ea4c5d0f..f83e5ad0d 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -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 diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index 980b95efd..5040de91f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -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 = 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, lat: Double, lon: Double, nRelays: Int): List = + 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 { + internal fun parseCsv(input: InputStream): List { val result = mutableListOf() + // 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() 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) diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectoryCache.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectoryCache.kt new file mode 100644 index 000000000..f46ab1762 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectoryCache.kt @@ -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()) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryAddressTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryAddressTest.kt new file mode 100644 index 000000000..eecaad241 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryAddressTest.kt @@ -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/")) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheTest.kt new file mode 100644 index 000000000..95be205ab --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheTest.kt @@ -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()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryPortTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryPortTest.kt new file mode 100644 index 000000000..871e3bb67 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryPortTest.kt @@ -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")) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryRefreshRobolectricTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryRefreshRobolectricTest.kt new file mode 100644 index 000000000..b6385fcb9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryRefreshRobolectricTest.kt @@ -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() + 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 + 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() + 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()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt new file mode 100644 index 000000000..809ca1406 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -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 + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionManifestTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionManifestTest.kt new file mode 100644 index 000000000..626f6b3c9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionManifestTest.kt @@ -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) } + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionVectorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionVectorTest.kt new file mode 100644 index 000000000..4ba12a688 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelaySelectionVectorTest.kt @@ -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) { + @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> = parseCases(resource("cases.tsv")) + + internal fun parseCases(text: String): List> { + 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() + 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(fields[0], fields) + } + } + } +} diff --git a/app/src/test/resources/contracts/relay-selection-v1/cases.tsv b/app/src/test/resources/contracts/relay-selection-v1/cases.tsv new file mode 100644 index 000000000..a8c41e621 --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/cases.tsv @@ -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 diff --git a/app/src/test/resources/contracts/relay-selection-v1/dateline.csv b/app/src/test/resources/contracts/relay-selection-v1/dateline.csv new file mode 100644 index 000000000..2241c7860 --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/dateline.csv @@ -0,0 +1,4 @@ +Relay URL,Latitude,Longitude +far.example,0,150 +across.example,0,-179 +near.example,0,178 diff --git a/app/src/test/resources/contracts/relay-selection-v1/duplicates.csv b/app/src/test/resources/contracts/relay-selection-v1/duplicates.csv new file mode 100644 index 000000000..4e6f9dc43 --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/duplicates.csv @@ -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 diff --git a/app/src/test/resources/contracts/relay-selection-v1/latitude-weight.csv b/app/src/test/resources/contracts/relay-selection-v1/latitude-weight.csv new file mode 100644 index 000000000..c67e3c49e --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/latitude-weight.csv @@ -0,0 +1,4 @@ +Relay URL,Latitude,Longitude +east.example,60,23 +north.example,62,20 +far.example,63,24 diff --git a/app/src/test/resources/contracts/relay-selection-v1/longitude.csv b/app/src/test/resources/contracts/relay-selection-v1/longitude.csv new file mode 100644 index 000000000..4192830db --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/longitude.csv @@ -0,0 +1,4 @@ +Relay URL,Latitude,Longitude +far.example,0,70 +near.example,0,20 +middle.example,0,40 diff --git a/app/src/test/resources/contracts/relay-selection-v1/ties-reversed.csv b/app/src/test/resources/contracts/relay-selection-v1/ties-reversed.csv new file mode 100644 index 000000000..681087f75 --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/ties-reversed.csv @@ -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 diff --git a/app/src/test/resources/contracts/relay-selection-v1/ties.csv b/app/src/test/resources/contracts/relay-selection-v1/ties.csv new file mode 100644 index 000000000..cf68724a3 --- /dev/null +++ b/app/src/test/resources/contracts/relay-selection-v1/ties.csv @@ -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 diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 65f6c0087..647ea285a 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -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 diff --git a/tools/relay-selection/README.md b/tools/relay-selection/README.md new file mode 100644 index 000000000..18b04368a --- /dev/null +++ b/tools/relay-selection/README.md @@ -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 /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. diff --git a/tools/relay-selection/check_ios.py b/tools/relay-selection/check_ios.py new file mode 100644 index 000000000..b3a1d232e --- /dev/null +++ b/tools/relay-selection/check_ios.py @@ -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() diff --git a/tools/relay-selection/test_check_ios.py b/tools/relay-selection/test_check_ios.py new file mode 100644 index 000000000..3f2d7e733 --- /dev/null +++ b/tools/relay-selection/test_check_ios.py @@ -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()