Resolve conflicts and harden link authentication

This commit is contained in:
callebtc 2026-07-26 20:33:08 +02:00
commit 5dadf40213
34 changed files with 1890 additions and 1067 deletions

View File

@ -1,416 +1,442 @@
Relay URL,Latitude,Longitude
relay.lab.rytswd.com,49.4543,11.0746
relay.paulstephenborile.com:443,49.4543,11.0746
relay.binaryrobot.com,43.6532,-79.3832
nostr-2.21crypto.ch,47.5356,8.73209
spookstr2.nostr1.com:443,40.7057,-74.0136
fanfares.nostr1.com:443,40.7057,-74.0136
x.kojira.io,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
testnet.samt.st,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr-01.yakihonne.com,1.32123,103.695
nostr-relay.cbrx.io,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.snowbla.de,60.1699,24.9384
relay.zone667.com,60.1699,24.9384
nexus.libernet.app:443,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr-relay.xbytez.io,50.6924,3.20113
kasztanowa.bieda.it,43.6532,-79.3832
nostrcity-club.fly.dev,37.7648,-122.432
relay.typedcypher.com,51.5072,-0.127586
nostr.na.social:443,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
relay-dev.satlantis.io:443,40.8302,-74.1299
rilo.nostria.app,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
nostr-relay.amethyst.name:443,39.0067,-77.4291
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
syb.lol:443,43.6532,-79.3832
relay.sigit.io,50.4754,12.3683
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.wavefunc.live,41.8781,-87.6298
nostr.sathoarder.com,48.5734,7.75211
myvoiceourstory.org,37.3598,-121.981
relay.underorion.se,50.1109,8.68213
nostr.data.haus,50.4754,12.3683
relay.erybody.com,41.4513,-81.7021
espelho.girino.org,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
nostr.bitcoiner.social:443,47.6743,-117.112
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
treuzkas.branruz.com,48.8575,2.35138
relay1.nostrchat.io,60.1699,24.9384
kotukonostr.onrender.com,37.7775,-122.397
nostr.plantroon.com,50.1013,8.62643
nostr.davenov.com,50.1109,8.68213
node.kommonzenze.de,49.4521,11.0767
relay2.veganostr.com,60.1699,24.9384
armada.sharegap.net,43.6532,-79.3832
wot.makenomistakes.ca,43.7064,-79.3986
nostr.2b9t.xyz:443,34.0549,-118.243
relay.libernet.app:443,43.6532,-79.3832
relay.dreamith.to:443,43.6532,-79.3832
relay.lightning.pub:443,39.0438,-77.4874
nostr.rtvslawenia.com,49.4543,11.0746
nostr.21crypto.ch,47.5356,8.73209
relay.ditto.pub:443,43.6532,-79.3832
relay.plebchain.club,43.6532,-79.3832
memlay.v0l.io,53.3498,-6.26031
nostr.chaima.info:443,50.1109,8.68213
relay.wavlake.com:443,41.2619,-95.8608
nostr.thalheim.io:443,60.1699,24.9384
relay.lightning.pub,39.0438,-77.4874
dev.relay.edufeed.org:443,49.4521,11.0767
nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.binaryrobot.com:443,43.6532,-79.3832
wot.nostr.place,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
thecitadel.nostr1.com,40.7057,-74.0136
relay.artx.market,43.6548,-79.3885
nos.lol,50.4754,12.3683
nostr.plantroon.com:443,50.1013,8.62643
premium.primal.net,43.6532,-79.3832
nas01xanthosnet.synology.me:7778,47.1285,8.74735
nostrja-kari.heguro.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
mostro-p2p.tech,50.1109,8.68213
wot.shaving.kiwi,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostrelay.circum.space:443,52.6907,4.8181
relay.nostrdice.com,-33.8688,151.209
relay.getvia.xyz,60.1699,24.9384
strfry.shock.network:443,39.0438,-77.4874
relay.nostrmap.net:443,60.1699,24.9384
relay.nearhood.co.uk,51.5072,-0.127586
no.str.cr,10.6352,-85.4378
relay.getsafebox.app:443,43.6532,-79.3832
relay0.gfcom.info,13.6992,100.694
nostr.ps1829.com,33.8851,130.883
relay2.angor.io,48.1046,11.6002
relay.stickeroo.is-cool.dev,37.3387,-121.885
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
relay.typedcypher.com:443,51.5072,-0.127586
relay.paulstephenborile.com,49.4543,11.0746
nittom.nostr1.com,40.7057,-74.0136
conduitl2.fly.dev,37.7648,-122.432
nostr.rikmeijer.nl,51.7111,5.36809
relay.thecryptosquid.com,50.4754,12.3683
spookstr2.nostr1.com,40.7057,-74.0136
offchain.bostr.online,43.6532,-79.3832
nostr.planix.org,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
0x-nostr-relay.fly.dev,37.7648,-122.432
nostr.wecsats.io,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.nostrhub.fr,48.1045,11.6004
blossom.gnostr.cloud:443,43.6532,-79.3832
nostr-02.yakihonne.com:443,1.32123,103.695
dev.relay.stream,43.6532,-79.3832
ithurtswhenip.ee,51.5072,-0.127586
nostr.myshosholoza.co.za,52.3913,4.66545
relayrs.notoshi.win:443,43.6532,-79.3832
relay-rpi.edufeed.org:443,49.4521,11.0767
relay.olas.app:443,60.1699,24.9384
nostr.unkn0wn.world,46.8499,9.53287
relay.mitchelltribe.com,39.0438,-77.4874
yabu.me,35.6092,139.73
nostr.nodesmap.com,59.3327,18.0656
dm-test-strfry-generic.samt.st,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
wot.brightbolt.net,47.6735,-116.781
strfry.shock.network,39.0438,-77.4874
relay.kilombino.com,43.6532,-79.3832
relay.nostr.blockhenge.com,39.0438,-77.4874
shu04.shugur.net,25.2048,55.2708
relay-rpi.edufeed.org,49.4521,11.0767
relay.bullishbounty.com:443,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
offchain.pub:443,39.1585,-94.5728
soloco.nl,43.6532,-79.3832
relay.nostu.be,40.4167,-3.70329
nostr.pbfs.io,50.4754,12.3683
relay.directsponsor.net,42.8864,-78.8784
relay.decentralia.fr,49.4282,10.9796
relayrs.notoshi.win,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
relay.arx-ccn.com,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
rilo.nostria.app:443,43.6532,-79.3832
relay.trotters.cc:443,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.girino.org:443,43.6532,-79.3832
bitsat.molonlabe.holdings,51.4012,-1.3147
nostr.azzamo.net,52.2633,21.0283
insta-relay.apps3.slidestr.net,40.4167,-3.70329
bridge.tagomago.me,42.3601,-71.0589
nostr.thalheim.io,60.1699,24.9384
relay.artx.market:443,43.6548,-79.3885
nostr.openhoofd.nl,51.5717,3.70417
nostr.bond,50.1109,8.68213
relay.earthly.city,34.1749,-118.54
nexus.libernet.app,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213
relay.nostr.net,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.ohstr.com,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
relay01.lnfi.network,35.6764,139.65
relay.mostr.pub:443,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relayone.soundhsa.com,39.1008,-94.5811
relay.mostro.network,40.8302,-74.1299
ribo.eu.nostria.app,43.6532,-79.3832
chat-relay.zap-work.com,43.6532,-79.3832
relay.nostreon.com,60.1699,24.9384
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
nostr.quali.chat:443,60.1699,24.9384
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.satlantis.io,40.8054,-74.0241
nittom.nostr1.com:443,40.7057,-74.0136
nostr.janx.com,43.6532,-79.3832
nostr.carroarmato0.be:443,50.914,3.21378
relay.mmwaves.de:443,48.8575,2.35138
relay.chorus.community:443,48.5333,10.7
wot.utxo.one,43.6532,-79.3832
relay.plebeian.market:443,50.1109,8.68213
relay.cosmicbolt.net,37.3986,-121.964
x.kojira.io:443,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
dev.relay.edufeed.org,49.4521,11.0767
relayone.geektank.ai:443,39.1008,-94.5811
relay.nostar.org,43.6532,-79.3832
nostr.oxtr.dev:443,50.4754,12.3683
nostr.88mph.life,52.1941,-2.21905
relay.staging.commonshub.brussels,49.4543,11.0746
weboftrust.libretechsystems.xyz,55.4724,9.87335
relay.openfarmtools.org,60.1699,24.9384
cs-relay.nostrdev.com,50.4754,12.3683
relay.inforsupports.com,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
nostr.hekster.org,37.3986,-121.964
relay.gulugulu.moe:443,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
nostrcity-club.fly.dev:443,37.7648,-122.432
relay.vrtmrz.net:443,43.6532,-79.3832
relay.nostr.place,43.6532,-79.3832
relay.wavefunc.live:443,41.8781,-87.6298
nostr.islandarea.net,35.4669,-97.6473
purplerelay.com:443,43.6532,-79.3832
nostr-relay.psfoundation.info:443,39.0438,-77.4874
r.0kb.io,32.789,-96.7989
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.mulatta.io,37.5665,126.978
strfry.bonsai.com:443,39.0438,-77.4874
bendernostur.duckdns.org:8443,50.1109,8.68213
vault.iris.to,43.6532,-79.3832
ec2.f7z.io,60.1699,24.9384
nostr.debate.report,50.1109,8.68213
wot.codingarena.top,50.4754,12.3683
relay.layer.systems:443,49.0291,8.35695
relay.degmods.com,50.4754,12.3683
nostr.mom,50.4754,12.3683
ribo.us.nostria.app:443,43.6532,-79.3832
adre.su,59.9311,30.3609
wot.sudocarlos.com,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.endfiat.money:443,59.3327,18.0656
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr.carroarmato0.be,50.914,3.21378
relay.cypherflow.ai:443,48.8575,2.35138
nostr.girino.org,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
strfry.ymir.cloud,43.6532,-79.3832
relay.mypathtofire.de,42.8864,-78.8784
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.snowbla.de:443,60.1699,24.9384
relay.ditto.pub,43.6532,-79.3832
relay.damus.io,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
nrs-01.darkcloudarcade.com,39.1008,-94.5811
testnet-relay.samt.st,40.8302,-74.1299
antiprimal.net,43.6532,-79.3832
bitchat.nostr1.com,40.7057,-74.0136
relay.snort.social,53.3498,-6.26031
relay.mccormick.cx:443,52.3563,4.95714
relay02.lnfi.network,35.6764,139.65
srtrelay.c-stellar.net,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostrride.io,37.3986,-121.964
articles.layer3.news:443,37.3387,-121.885
rele.speyhard.fi,51.5072,-0.127586
relay.aarpia.com,37.3986,-121.964
nostr.chaima.info,50.1109,8.68213
relay.wisp.talk:443,49.4543,11.0746
relay.agorist.space:443,52.3734,4.89406
strfry.bonsai.com,39.0438,-77.4874
nostr.hifish.org,47.4244,8.57658
offchain.pub,39.1585,-94.5728
nostr.spicyz.io:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
relay.sharegap.net,43.6532,-79.3832
nostr.purpura.cloud,43.6532,-79.3832
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
relay.fountain.fm:443,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.mmwaves.de,48.8575,2.35138
relay.openresist.com:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.libernet.app,43.6532,-79.3832
relay.comcomponent.com,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
bcast.girino.org,43.6532,-79.3832
nostr.azzamo.net:443,52.2633,21.0283
relay.islandbitcoin.com:443,12.8498,77.6545
pool.libernet.app,43.6532,-79.3832
test.thedude.cloud,50.1109,8.68213
nostrelites.org,41.8781,-87.6298
nostr.infero.net,35.6764,139.65
relay.primal.net,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
relay.chorus.community,48.5333,10.7
bitcoiner.social:443,47.6743,-117.112
relay.wisp.talk,49.4543,11.0746
relay.layer.systems,49.0291,8.35695
relay-dev.satlantis.io,40.8302,-74.1299
nostr.bitcoiner.social,47.6743,-117.112
relay.lanavault.space:443,60.1699,24.9384
relay.staging.plebeian.market,51.5072,-0.127586
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
relay.dreamith.to,43.6532,-79.3832
relay.satmaxt.xyz:443,43.6532,-79.3832
shu03.shugur.net,25.2048,55.2708
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
ribo.us.nostria.app,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
nostr.hifish.org:443,47.4244,8.57658
nostr.vulpem.com,49.4543,11.0746
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-02.yakihonne.com,1.32123,103.695
r.0kb.io:443,32.789,-96.7989
nostr-relay.corb.net,38.8353,-104.822
ribo.eu.nostria.app:443,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.wellorder.net,45.5201,-122.99
relay.novospes.com,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.endfiat.money,59.3327,18.0656
relay.angor.io:443,48.1046,11.6002
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
strfry.openhoofd.nl,51.5717,3.70417
relay.getsafebox.app,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
nostr.notribe.net:443,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
no.str.cr:443,10.6352,-85.4378
relay.klabo.world,47.2343,-119.853
nostr.notribe.net,40.8302,-74.1299
relay.staging.plebeian.market:443,51.5072,-0.127586
relay.nostrmap.net,60.1699,24.9384
temp.iris.to,43.6532,-79.3832
nostr.sovereignservices.xyz,43.6532,-79.3832
nostr.liberty.fans,36.9104,-89.5875
relay.nostrian-conquest.com:443,41.223,-111.974
relay.nostriot.com,41.5695,-83.9786
nostrbtc.com,43.6532,-79.3832
shu02.shugur.net,21.4902,39.2246
relay.kalcafe.xyz,37.3986,-121.964
relay.illuminodes.com,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
nostr.ps1829.com:443,33.8851,130.883
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostr.mom:443,50.4754,12.3683
ribo.nostria.app:443,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
nostr.data.haus:443,50.4754,12.3683
armada.sharegap.net,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
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
relay2.veganostr.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
relay.sigit.io:443,50.4754,12.3683
relay.edufeed.org:443,49.4521,11.0767
nostr-01.yakihonne.com:443,1.32123,103.695
reraw.pbla2fish.cc,43.6532,-79.3832
cs-relay.nostrdev.com:443,50.4754,12.3683
herbstmeister.com,34.0549,-118.243
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
relay.sharegap.net,43.6532,-79.3832
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.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
relay2.angor.io:443,48.1046,11.6002
social.amanah.eblessing.co,48.1046,11.6002
nostr.stakey.net,52.3676,4.90414
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
nostr.computingcache.com:443,34.0356,-118.442
slick.mjex.me,39.0418,-77.4744
fanfares.nostr1.com,40.7057,-74.0136
bitcoinostr.duckdns.org,43.3434,-3.99532
nostr.oxtr.dev,50.4754,12.3683
cache.trustr.ing,43.6548,-79.3885
purplerelay.com,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr-relay.corb.net:443,38.8353,-104.822
relay-dev.gulugulu.moe,43.6532,-79.3832
prl.plus,55.7628,37.5983
nostr.tac.lol:443,47.4748,-122.273
relay.mostr.pub,43.6532,-79.3832
schnorr.me:443,43.6532,-79.3832
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
bucket.coracle.social,37.7775,-122.397
blossom.gnostr.cloud,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
relay.vrtmrz.net,43.6532,-79.3832
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.nostx.io,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
relay.notoshi.win,13.7829,100.546
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
relay.lanavault.space,60.1699,24.9384
public.crostr.com:443,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
relay.nostr.place:443,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.aruku.ovh,1.27994,103.849
satsage.xyz,37.3986,-121.964
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr2.girino.org,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
articles.layer3.news,37.3387,-121.885
aeon.libretechsystems.xyz,55.486,9.86577
relay.routstr.com,59.4016,17.9455
relay.ohstr.com:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
strfry.openhoofd.nl:443,51.5717,3.70417
nostr.blankfors.se,60.1699,24.9384
nostr-2.21crypto.ch:443,47.5356,8.73209
relayone.soundhsa.com:443,39.1008,-94.5811
relay.lab.rytswd.com:443,49.4543,11.0746
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
relay.0xchat.com:443,43.6532,-79.3832
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
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

1 Relay URL Latitude Longitude
relay.lab.rytswd.com 49.4543 11.0746
relay.paulstephenborile.com:443 49.4543 11.0746
relay.binaryrobot.com 43.6532 -79.3832
nostr-2.21crypto.ch 47.5356 8.73209
spookstr2.nostr1.com:443 40.7057 -74.0136
fanfares.nostr1.com:443 40.7057 -74.0136
x.kojira.io 43.6532 -79.3832
freelay.sovbit.host 60.1699 24.9384
nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
testnet.samt.st 43.6532 -79.3832
relay.angor.io 48.1046 11.6002
relay-arg.zombi.cloudrodion.com 1.35208 103.82
nostr-01.yakihonne.com 1.32123 103.695
nostr-relay.cbrx.io 43.6532 -79.3832
relay.guggero.org 46.5971 9.59652
nostr.snowbla.de 60.1699 24.9384
relay.zone667.com 60.1699 24.9384
nexus.libernet.app:443 43.6532 -79.3832
relay.islandbitcoin.com 12.8498 77.6545
relay-testnet.k8s.layer3.news 37.3387 -121.885
nostr-relay.xbytez.io 50.6924 3.20113
kasztanowa.bieda.it 43.6532 -79.3832
nostrcity-club.fly.dev 37.7648 -122.432
relay.typedcypher.com 51.5072 -0.127586
nostr.na.social:443 43.6532 -79.3832
relay.laantungir.net -19.4692 -42.5315
relay-dev.satlantis.io:443 40.8302 -74.1299
rilo.nostria.app 43.6532 -79.3832
nostr.hekster.org:443 37.3986 -121.964
nostr-relay.amethyst.name:443 39.0067 -77.4291
chat-relay.zap-work.com:443 43.6532 -79.3832
relay.edufeed.org 49.4521 11.0767
syb.lol:443 43.6532 -79.3832
relay.sigit.io 50.4754 12.3683
nostr-relay.xbytez.io:443 50.6924 3.20113
relay.wavefunc.live 41.8781 -87.6298
nostr.sathoarder.com 48.5734 7.75211
myvoiceourstory.org 37.3598 -121.981
relay.underorion.se 50.1109 8.68213
nostr.data.haus 50.4754 12.3683
relay.erybody.com 41.4513 -81.7021
espelho.girino.org 43.6532 -79.3832
nostr.pbfs.io:443 50.4754 12.3683
wot.dergigi.com 64.1476 -21.9392
nostr.bitcoiner.social:443 47.6743 -117.112
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
relay.gulugulu.moe 43.6532 -79.3832
nostr.spicyz.io 43.6532 -79.3832
relay.cypherflow.ai 48.8575 2.35138
treuzkas.branruz.com 48.8575 2.35138
relay1.nostrchat.io 60.1699 24.9384
kotukonostr.onrender.com 37.7775 -122.397
nostr.plantroon.com 50.1013 8.62643
nostr.davenov.com 50.1109 8.68213
node.kommonzenze.de 49.4521 11.0767
relay2.veganostr.com 60.1699 24.9384
armada.sharegap.net 43.6532 -79.3832
wot.makenomistakes.ca 43.7064 -79.3986
nostr.2b9t.xyz:443 34.0549 -118.243
relay.libernet.app:443 43.6532 -79.3832
relay.dreamith.to:443 43.6532 -79.3832
relay.lightning.pub:443 39.0438 -77.4874
nostr.rtvslawenia.com 49.4543 11.0746
nostr.21crypto.ch 47.5356 8.73209
relay.ditto.pub:443 43.6532 -79.3832
relay.plebchain.club 43.6532 -79.3832
memlay.v0l.io 53.3498 -6.26031
nostr.chaima.info:443 50.1109 8.68213
relay.wavlake.com:443 41.2619 -95.8608
nostr.thalheim.io:443 60.1699 24.9384
relay.lightning.pub 39.0438 -77.4874
dev.relay.edufeed.org:443 49.4521 11.0767
nostr.myshosholoza.co.za:443 52.3913 4.66545
relay.binaryrobot.com:443 43.6532 -79.3832
wot.nostr.place 43.6532 -79.3832
nostr.sathoarder.com:443 48.5734 7.75211
thecitadel.nostr1.com 40.7057 -74.0136
relay.artx.market 43.6548 -79.3885
nos.lol 50.4754 12.3683
nostr.plantroon.com:443 50.1013 8.62643
premium.primal.net 43.6532 -79.3832
nas01xanthosnet.synology.me:7778 47.1285 8.74735
nostrja-kari.heguro.com 43.6532 -79.3832
relay.mrmave.work 43.6532 -79.3832
nostrelay.circum.space 52.6907 4.8181
mostro-p2p.tech 50.1109 8.68213
wot.shaving.kiwi 43.6532 -79.3832
relay.fundstr.me 42.3601 -71.0589
nostrelay.circum.space:443 52.6907 4.8181
relay.nostrdice.com -33.8688 151.209
relay.getvia.xyz 60.1699 24.9384
strfry.shock.network:443 39.0438 -77.4874
relay.nostrmap.net:443 60.1699 24.9384
relay.nearhood.co.uk 51.5072 -0.127586
no.str.cr 10.6352 -85.4378
relay.getsafebox.app:443 43.6532 -79.3832
relay0.gfcom.info 13.6992 100.694
nostr.ps1829.com 33.8851 130.883
relay2.angor.io 48.1046 11.6002
relay.stickeroo.is-cool.dev 37.3387 -121.885
ricardo-oem.tailb5546.ts.net 40.7128 -74.006
relay.typedcypher.com:443 51.5072 -0.127586
relay.paulstephenborile.com 49.4543 11.0746
nittom.nostr1.com 40.7057 -74.0136
conduitl2.fly.dev 37.7648 -122.432
nostr.rikmeijer.nl 51.7111 5.36809
relay.thecryptosquid.com 50.4754 12.3683
spookstr2.nostr1.com 40.7057 -74.0136
offchain.bostr.online 43.6532 -79.3832
nostr.planix.org 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
0x-nostr-relay.fly.dev 37.7648 -122.432
nostr.wecsats.io 43.6532 -79.3832
schnorr.me 43.6532 -79.3832
relay.satmaxt.xyz 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
relay.nostrhub.fr 48.1045 11.6004
blossom.gnostr.cloud:443 43.6532 -79.3832
nostr-02.yakihonne.com:443 1.32123 103.695
dev.relay.stream 43.6532 -79.3832
ithurtswhenip.ee 51.5072 -0.127586
nostr.myshosholoza.co.za 52.3913 4.66545
relayrs.notoshi.win:443 43.6532 -79.3832
relay-rpi.edufeed.org:443 49.4521 11.0767
relay.olas.app:443 60.1699 24.9384
nostr.unkn0wn.world 46.8499 9.53287
relay.mitchelltribe.com 39.0438 -77.4874
yabu.me 35.6092 139.73
nostr.nodesmap.com 59.3327 18.0656
dm-test-strfry-generic.samt.st 43.6532 -79.3832
nostr2.girino.org:443 43.6532 -79.3832
wot.brightbolt.net 47.6735 -116.781
strfry.shock.network 39.0438 -77.4874
relay.kilombino.com 43.6532 -79.3832
relay.nostr.blockhenge.com 39.0438 -77.4874
shu04.shugur.net 25.2048 55.2708
relay-rpi.edufeed.org 49.4521 11.0767
relay.bullishbounty.com:443 43.6532 -79.3832
vault.iris.to:443 43.6532 -79.3832
relay.mostro.network:443 40.8302 -74.1299
offchain.pub:443 39.1585 -94.5728
soloco.nl 43.6532 -79.3832
relay.nostu.be 40.4167 -3.70329
nostr.pbfs.io 50.4754 12.3683
relay.directsponsor.net 42.8864 -78.8784
relay.decentralia.fr 49.4282 10.9796
relayrs.notoshi.win 43.6532 -79.3832
nostr-relay.amethyst.name 39.0067 -77.4291
relay.arx-ccn.com 50.4754 12.3683
nostr.spaceshell.xyz 43.6532 -79.3832
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
rilo.nostria.app:443 43.6532 -79.3832
relay.trotters.cc:443 43.6532 -79.3832
nostr.overmind.lol:443 43.6532 -79.3832
nostr.girino.org:443 43.6532 -79.3832
bitsat.molonlabe.holdings 51.4012 -1.3147
nostr.azzamo.net 52.2633 21.0283
insta-relay.apps3.slidestr.net 40.4167 -3.70329
bridge.tagomago.me 42.3601 -71.0589
nostr.thalheim.io 60.1699 24.9384
relay.artx.market:443 43.6548 -79.3885
nostr.openhoofd.nl 51.5717 3.70417
nostr.bond 50.1109 8.68213
relay.earthly.city 34.1749 -118.54
nexus.libernet.app 43.6532 -79.3832
relay.plebeian.market 50.1109 8.68213
relay.nostr.net 43.6532 -79.3832
nostr.overmind.lol 43.6532 -79.3832
relay.ohstr.com 43.6532 -79.3832
testnet-relay.samt.st:443 40.8302 -74.1299
relay01.lnfi.network 35.6764 139.65
relay.mostr.pub:443 43.6532 -79.3832
wot.nostr.party 36.1659 -86.7844
relayone.soundhsa.com 39.1008 -94.5811
relay.mostro.network 40.8302 -74.1299
ribo.eu.nostria.app 43.6532 -79.3832
chat-relay.zap-work.com 43.6532 -79.3832
relay.nostreon.com 60.1699 24.9384
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
nostr.quali.chat:443 60.1699 24.9384
relay.internationalright-wing.org:443 -22.5022 -48.7114
relay.mitchelltribe.com:443 39.0438 -77.4874
relay.satlantis.io 40.8054 -74.0241
nittom.nostr1.com:443 40.7057 -74.0136
nostr.janx.com 43.6532 -79.3832
nostr.carroarmato0.be:443 50.914 3.21378
relay.mmwaves.de:443 48.8575 2.35138
relay.chorus.community:443 48.5333 10.7
wot.utxo.one 43.6532 -79.3832
relay.plebeian.market:443 50.1109 8.68213
relay.cosmicbolt.net 37.3986 -121.964
x.kojira.io:443 43.6532 -79.3832
top.testrelay.top 43.6532 -79.3832
nos.lol:443 50.4754 12.3683
dev.relay.edufeed.org 49.4521 11.0767
relayone.geektank.ai:443 39.1008 -94.5811
relay.nostar.org 43.6532 -79.3832
nostr.oxtr.dev:443 50.4754 12.3683
nostr.88mph.life 52.1941 -2.21905
relay.staging.commonshub.brussels 49.4543 11.0746
weboftrust.libretechsystems.xyz 55.4724 9.87335
relay.openfarmtools.org 60.1699 24.9384
cs-relay.nostrdev.com 50.4754 12.3683
relay.inforsupports.com 43.6532 -79.3832
nostr-verified.wellorder.net 45.5201 -122.99
nostr.hekster.org 37.3986 -121.964
relay.gulugulu.moe:443 43.6532 -79.3832
relay.mwaters.net 50.9871 2.12554
nostrcity-club.fly.dev:443 37.7648 -122.432
relay.vrtmrz.net:443 43.6532 -79.3832
relay.nostr.place 43.6532 -79.3832
relay.wavefunc.live:443 41.8781 -87.6298
nostr.islandarea.net 35.4669 -97.6473
purplerelay.com:443 43.6532 -79.3832
nostr-relay.psfoundation.info:443 39.0438 -77.4874
r.0kb.io 32.789 -96.7989
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
relay.mulatta.io 37.5665 126.978
strfry.bonsai.com:443 39.0438 -77.4874
bendernostur.duckdns.org:8443 50.1109 8.68213
vault.iris.to 43.6532 -79.3832
ec2.f7z.io 60.1699 24.9384
nostr.debate.report 50.1109 8.68213
wot.codingarena.top 50.4754 12.3683
relay.layer.systems:443 49.0291 8.35695
relay.degmods.com 50.4754 12.3683
nostr.mom 50.4754 12.3683
ribo.us.nostria.app:443 43.6532 -79.3832
adre.su 59.9311 30.3609
wot.sudocarlos.com 43.6532 -79.3832
relay.nostrian-conquest.com 41.223 -111.974
nostr-relay.nextblockvending.com 47.2343 -119.853
relay.endfiat.money:443 59.3327 18.0656
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
nostr.carroarmato0.be 50.914 3.21378
relay.cypherflow.ai:443 48.8575 2.35138
nostr.girino.org 43.6532 -79.3832
nostr.thebiglake.org 32.71 -96.6745
strfry.ymir.cloud 43.6532 -79.3832
relay.mypathtofire.de 42.8864 -78.8784
relay.lanacoin-eternity.com 40.8302 -74.1299
nostr.snowbla.de:443 60.1699 24.9384
relay.ditto.pub 43.6532 -79.3832
relay.damus.io 43.6532 -79.3832
relay.ru.ac.th 13.7607 100.627
nrs-01.darkcloudarcade.com 39.1008 -94.5811
testnet-relay.samt.st 40.8302 -74.1299
antiprimal.net 43.6532 -79.3832
2 bitchat.nostr1.com 40.7057 -74.0136
3 relay.snort.social relay.fundstr.me 53.3498 42.3601 -6.26031 -71.0589
relay.mccormick.cx:443 52.3563 4.95714
relay02.lnfi.network 35.6764 139.65
srtrelay.c-stellar.net 43.6532 -79.3832
relay.minibolt.info 43.6532 -79.3832
nostrride.io 37.3986 -121.964
articles.layer3.news:443 37.3387 -121.885
rele.speyhard.fi 51.5072 -0.127586
relay.aarpia.com 37.3986 -121.964
nostr.chaima.info 50.1109 8.68213
relay.wisp.talk:443 49.4543 11.0746
relay.agorist.space:443 52.3734 4.89406
strfry.bonsai.com 39.0438 -77.4874
nostr.hifish.org 47.4244 8.57658
offchain.pub 39.1585 -94.5728
nostr.spicyz.io:443 43.6532 -79.3832
relay.beginningend.com 35.2227 -97.4786
relay.sharegap.net 43.6532 -79.3832
nostr.purpura.cloud 43.6532 -79.3832
nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
relay.fountain.fm:443 43.6532 -79.3832
relay.olas.app 60.1699 24.9384
relay.mmwaves.de 48.8575 2.35138
relay.openresist.com:443 43.6532 -79.3832
relay.homeinhk.xyz 35.694 139.754
relay.libernet.app 43.6532 -79.3832
relay.comcomponent.com 43.6532 -79.3832
nostr.tac.lol 47.4748 -122.273
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.nostriot.com:443 41.5695 -83.9786
bcast.girino.org 43.6532 -79.3832
nostr.azzamo.net:443 52.2633 21.0283
relay.islandbitcoin.com:443 12.8498 77.6545
pool.libernet.app 43.6532 -79.3832
test.thedude.cloud 50.1109 8.68213
nostrelites.org 41.8781 -87.6298
nostr.infero.net 35.6764 139.65
relay.primal.net 43.6532 -79.3832
ribo.nostria.app 43.6532 -79.3832
relay.chorus.community 48.5333 10.7
bitcoiner.social:443 47.6743 -117.112
relay.wisp.talk 49.4543 11.0746
relay.layer.systems 49.0291 8.35695
relay-dev.satlantis.io 40.8302 -74.1299
nostr.bitcoiner.social 47.6743 -117.112
relay.lanavault.space:443 60.1699 24.9384
relay.staging.plebeian.market 51.5072 -0.127586
infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
relay.fountain.fm 43.6532 -79.3832
nostr.middling.mydns.jp 35.8099 140.12
relay.dreamith.to 43.6532 -79.3832
relay.satmaxt.xyz:443 43.6532 -79.3832
shu03.shugur.net 25.2048 55.2708
zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
nostr.computingcache.com 34.0356 -118.442
ribo.us.nostria.app 43.6532 -79.3832
relay.agentry.com 42.8864 -78.8784
nostr.hifish.org:443 47.4244 8.57658
nostr.vulpem.com 49.4543 11.0746
relay.cosmicbolt.net:443 37.3986 -121.964
nostr-02.yakihonne.com 1.32123 103.695
r.0kb.io:443 32.789 -96.7989
nostr-relay.corb.net 38.8353 -104.822
ribo.eu.nostria.app:443 43.6532 -79.3832
nostr-relay.psfoundation.info 39.0438 -77.4874
relay.wellorder.net 45.5201 -122.99
relay.novospes.com 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
relay.endfiat.money 59.3327 18.0656
relay.angor.io:443 48.1046 11.6002
relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
strfry.openhoofd.nl 51.5717 3.70417
relay.getsafebox.app 43.6532 -79.3832
relay.openresist.com 43.6532 -79.3832
relay5.bitransfer.org 43.6532 -79.3832
nostr.na.social 43.6532 -79.3832
portal-relay.pareto.space 49.0291 8.35696
nostr.notribe.net:443 40.8302 -74.1299
relay.bitmacro.cloud 43.6532 -79.3832
no.str.cr:443 10.6352 -85.4378
relay.klabo.world 47.2343 -119.853
nostr.notribe.net 40.8302 -74.1299
relay.staging.plebeian.market:443 51.5072 -0.127586
relay.nostrmap.net 60.1699 24.9384
temp.iris.to 43.6532 -79.3832
nostr.sovereignservices.xyz 43.6532 -79.3832
nostr.liberty.fans 36.9104 -89.5875
relay.nostrian-conquest.com:443 41.223 -111.974
relay.nostriot.com 41.5695 -83.9786
nostrbtc.com 43.6532 -79.3832
shu02.shugur.net 21.4902 39.2246
relay.kalcafe.xyz 37.3986 -121.964
relay.illuminodes.com 43.6532 -79.3832
relay.wavlake.com 41.2619 -95.8608
nostr.ps1829.com:443 33.8851 130.883
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
nostr.wecsats.io:443 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
nostr.dlcdevkit.com:443 40.0992 -83.1141
nostr.mom:443 50.4754 12.3683
ribo.nostria.app:443 43.6532 -79.3832
4 nostr.2b9t.xyz 34.0549 -118.243
5 nostr.data.haus:443 armada.sharegap.net 50.4754 43.6532 12.3683 -79.3832
6 nostr.chaima.info 51.5072 -0.127586
7 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
8 ribo.eu.nostria.app:443 43.6532 -79.3832
9 relay.lightning.pub 39.0438 -77.4874
10 relay.nostu.be 40.4167 -3.70329
11 nostr.whitenode45.ddns.net 40.55 -74.4758
12 nostr.carroarmato0.be:443 50.914 3.21378
13 cdn.satellite.earth 40.8302 -74.1299
14 relay2.veganostr.com 60.1699 24.9384
15 relay.layer.systems:443 49.0291 8.35695
16 relay0.gfcom.info 13.7653 100.647
17 relay.mmwaves.de:443 48.8575 2.35138
18 offchain.pub 39.1585 -94.5728
19 bcast.girino.org 43.6532 -79.3832
20 staging.yabu.me 35.6092 139.73
21 relay.sigit.io:443 nostr.overpay.com 50.4754 29.7449 12.3683 -95.5343
22 relay.edufeed.org:443 bridge.tagomago.me 49.4521 42.3601 11.0767 -71.0589
23 nostr-01.yakihonne.com:443 nostr-01.yakihonne.com 1.32123 103.695
24 reraw.pbla2fish.cc strfry.bonsai.com 43.6532 39.0438 -79.3832 -77.4874
25 cs-relay.nostrdev.com:443 relay.sharegap.net 50.4754 43.6532 12.3683 -79.3832
26 herbstmeister.com nostr.islandarea.net 34.0549 35.4669 -118.243 -97.6473
27 dm-test-strfry-generic.samt.st 43.6532 -79.3832
28 treuzkas.branruz.com 48.8575 2.35138
29 relay-rpi.edufeed.org:443 49.4521 11.0767
30 vault.iris.to:443 43.6532 -79.3832
31 node.kommonzenze.de 49.4521 11.0767
32 nostr.thalheim.io:443 60.1699 24.9384
33 soloco.nl 43.6532 -79.3832
34 strfry.shock.network 39.0438 -77.4874
35 nostr-relay.zimage.com 34.0549 -118.243
36 public.crostr.com:443 43.6532 -79.3832
37 nostr.sathoarder.com:443 48.5734 7.75211
38 relay.angor.io 48.1046 11.6002
39 relay.wellorder.net 45.5201 -122.99
40 relay.mwaters.net 50.9871 2.12554
41 relay.staging.commonshub.brussels 49.4543 11.0746
42 nostr-verified.wellorder.net 45.5201 -122.99
43 nostr-pub.wellorder.net 45.5201 -122.99
44 nostr-2.21crypto.ch 47.5356 8.73209
45 relay.kaleidoswap.com 50.8476 4.35717
46 relay.libernet.app:443 43.6532 -79.3832
47 relay.homeinhk.xyz 35.694 139.754
48 relay.manneken.brussels 49.4543 11.0746
49 nostr.spicyz.io:443 43.6532 -79.3832
50 relay.lanacoin-eternity.com:443 40.8302 -74.1299
51 ribo.us.nostria.app:443 43.6532 -79.3832
52 relay.loveisbitcoin.com 43.6532 -79.3832
53 relay.angor.io:443 48.1046 11.6002
54 relay02.lnfi.network 35.6764 139.65
55 relay.cosmicbolt.net:443 37.3986 -121.964
56 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
57 nrs-01.darkcloudarcade.com 39.0997 -94.5786
58 relay.endfiat.money:443 59.3327 18.0656
59 relay.paulstephenborile.com 49.4543 11.0746
60 rele.speyhard.fi 51.5072 -0.127586
61 relay.froth.zone 60.1699 24.9384
62 relay.nostr.blockhenge.com 39.0438 -77.4874
63 nrl.ceskar.xyz 50.5145 16.0119
64 rilo.nostria.app 43.6532 -79.3832
65 nostr.overmind.lol:443 43.6532 -79.3832
66 nostr.snowbla.de:443 50.4754 12.3683
67 nostrrelay.taylorperron.com 45.5029 -73.5723
68 chorus.pjv.me 45.5201 -122.99
69 relay.nostr.place 43.6532 -79.3832
70 bucket.coracle.social 37.7775 -122.397
71 nostr.girino.org:443 43.6532 -79.3832
72 relay.aarpia.com 37.3986 -121.964
73 nostr.thalheim.io 60.1699 24.9384
74 ec2.f7z.io 60.1699 24.9384
75 relay.trotters.cc 43.6532 -79.3832
76 relay.mccormick.cx:443 52.3563 4.95714
77 relay.momostr.pink 43.6532 -79.3832
78 relay.nostr.net 43.6532 -79.3832
79 conduitl2.fly.dev 37.7648 -122.432
80 chat-relay.zap-work.com 43.6532 -79.3832
81 relay.ditto.pub 43.6532 -79.3832
82 relay.veganostr.com 60.1699 24.9384
83 relay.minibolt.info:443 43.6532 -79.3832
84 relay2.angor.io:443 adre.su 48.1046 59.9311 11.6002 30.3609
85 social.amanah.eblessing.co bitcoinostr.duckdns.org 48.1046 41.1976 11.6002 1.11167
nostr.stakey.net 52.3676 4.90414
86 nostr.computingcache.com:443 34.0356 -118.442
87 slick.mjex.me relay-fra.zombi.cloudrodion.com 39.0418 48.8566 -77.4744 2.35222
88 fanfares.nostr1.com nostr.hekster.org:443 40.7057 37.3986 -74.0136 -121.964
89 bitcoinostr.duckdns.org nostr.88mph.life 43.3434 52.1941 -3.99532 -2.21905
90 nostr.oxtr.dev wot.dergigi.com 50.4754 64.1476 12.3683 -21.9392
91 cache.trustr.ing nostr.planix.org 43.6548 43.6532 -79.3885 -79.3832
92 purplerelay.com relay.satsmarkt.club 43.6532 52.6907 -79.3832 4.8181
93 nostr-kyomu-haskell.onrender.com nostrcity-club.fly.dev:443 37.7775 37.7648 -122.397 -122.432
94 nostr-relay.corb.net:443 aeon.libretechsystems.xyz 38.8353 55.486 -104.822 9.86577
95 relay-dev.gulugulu.moe testnet.samt.st 43.6532 -79.3832
96 prl.plus nostr.data.haus 55.7628 50.4754 37.5983 12.3683
97 nostr.tac.lol:443 wot.sudocarlos.com 47.4748 43.6532 -122.273 -79.3832
98 relay.mostr.pub relay-fra.zombi.cloudrodion.com:443 43.6532 48.8566 -79.3832 2.35222
99 schnorr.me:443 shu01.shugur.net 43.6532 21.4902 -79.3832 39.2246
100 relay.gulugulu.moe:443 43.6532 -79.3832
101 relay2.angor.io:443 48.1046 11.6002
102 relay.libernet.app 43.6532 -79.3832
103 directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
104 wot.nostr.party 36.1659 -86.7844
105 relay.zone667.com 60.1699 24.9384
106 nostr.wild-vibes.ts.net 48.8566 2.35222
107 relay.nostr.com 50.1109 8.68213
108 nostr.iskarion.ddns.net 43.3076 -2.95421
109 relay-dev.satlantis.io 39.0438 -77.4874
110 relay.sovereignresonance.org 48.9006 2.25929
111 relay.nostrian-conquest.com 41.223 -111.974
112 relay.aidatanorge.no 43.6532 -79.3832
113 strfry.apps3.slidestr.net 40.4167 -3.70329
114 relay.klabo.world 47.2343 -119.853
115 nostr.data.haus:443 50.4754 12.3683
116 testr.nymble.world 40.8054 -74.0241
117 relay.inforsupports.com 43.6532 -79.3832
118 relay.nostrmap.net:443 60.1699 24.9384
119 nostr.stakey.net:443 52.3676 4.90414
120 dev-relay.nostreon.com 60.1699 24.9384
121 nostr.islandarea.net:443 35.4669 -97.6473
122 bucket.coracle.social nostr.rtvslawenia.com 37.7775 49.4543 -122.397 11.0746
123 blossom.gnostr.cloud relay.bowlafterbowl.com 43.6532 32.9483 -79.3832 -96.7299
124 relay.solife.me nostr.quali.chat:443 43.6532 60.1699 -79.3832 24.9384
125 nostr.quali.chat relay.plebeian.market 60.1699 50.1109 24.9384 8.68213
126 relay.vrtmrz.net relay-rpi.edufeed.org 43.6532 49.4521 -79.3832 11.0767
127 relay-dev.gulugulu.moe:443 r.0kb.io 43.6532 32.789 -79.3832 -96.7989
128 relay.bullishbounty.com nostr.notribe.net:443 43.6532 40.8302 -79.3832 -74.1299
129 relay.fckstate.net relay.getsafebox.app:443 59.3293 43.6532 18.0686 -79.3832
130 nostr.rtvslawenia.com:443 nostr.dlcdevkit.com:443 49.4543 40.0992 11.0746 -83.1141
131 relay.nostx.io nostrelites.org 43.6532 34.9582 -79.3832 -81.9907
132 relay.agorist.space nostr.hoppe-relay.it.com 52.3734 42.8864 4.89406 -78.8784
133 relay.notoshi.win nostr.thebiglake.org 13.7829 32.71 100.546 -96.6745
134 dm-test-strfry-discovery.samt.st:443 nostr-kyomu-haskell.onrender.com 43.6532 37.7775 -79.3832 -122.397
135 relay.trotters.cc relay.nostriot.com 43.6532 41.5695 -79.3832 -83.9786
136 relay.lanavault.space nostr.christiansass.de 60.1699 51.7634 24.9384 7.8887
137 public.crostr.com:443 relay.btcforplebs.com 43.6532 -79.3832
nostr.stakey.net:443 52.3676 4.90414
relay.nostr.place:443 43.6532 -79.3832
nostr.dlcdevkit.com 40.0992 -83.1141
nostr.aruku.ovh 1.27994 103.849
satsage.xyz 37.3986 -121.964
strfry.apps3.slidestr.net 40.4167 -3.70329
nostr2.girino.org 43.6532 -79.3832
relay.samt.st 40.8302 -74.1299
articles.layer3.news 37.3387 -121.885
aeon.libretechsystems.xyz 55.486 9.86577
relay.routstr.com 59.4016 17.9455
relay.ohstr.com:443 43.6532 -79.3832
relay.lanacoin-eternity.com:443 40.8302 -74.1299
strfry.openhoofd.nl:443 51.5717 3.70417
nostr.blankfors.se 60.1699 24.9384
nostr-2.21crypto.ch:443 47.5356 8.73209
relayone.soundhsa.com:443 39.1008 -94.5811
relay.lab.rytswd.com:443 49.4543 11.0746
138 nostr.tagomago.me 42.3601 -71.0589
139 relay.0xchat.com:443 relayone.geektank.ai 43.6532 39.0997 -79.3832 -94.5786
140 relay.dreamith.to:443 43.6532 -79.3832
141 nostr.liberty.fans 36.8767 -89.5879
142 wot.makenomistakes.ca 43.7064 -79.3986
143 relay.goodmorningbitcoin.com 43.6532 -79.3832
144 relay.layer.systems 49.0291 8.35695
145 relay.paulstephenborile.com:443 49.4543 11.0746
146 relay.ohstr.com 43.6532 -79.3832
147 nostr-relay.xbytez.io:443 50.6924 3.20113
148 nostr.ac 38.958 -77.3592
149 ribo.us.nostria.app 43.6532 -79.3832
150 nostr.21crypto.ch 47.5356 8.73209
151 relay.chorus.community:443 48.5333 10.7
152 relay.cypherflow.ai 48.8575 2.35138
153 relay.agorist.space:443 52.3734 4.89406
154 relay.nostrian-conquest.com:443 41.223 -111.974
155 relay.keykeeper.world 40.7824 -74.0711
156 relay.getvia.xyz 60.1699 24.9384
157 relay.nuts.cash 52.3676 4.90414
158 kotukonostr.onrender.com 37.7775 -122.397
159 relay.minibolt.info 43.6532 -79.3832
160 relay.dwadziesciajeden.pl 52.2297 21.0122
161 relay.fountain.fm:443 43.6532 -79.3832
162 relay.fountain.fm 43.6532 -79.3832
163 nostr-02.uid.ovh 50.9871 2.12554
164 relay.lanavault.space:443 60.1699 24.9384
165 nostr.carroarmato0.be 50.914 3.21378
166 nexus.libernet.app:443 43.6532 -79.3832
167 relay.artio.inf.unibe.ch 46.9501 7.43678
168 blossom.gnostr.cloud 43.6532 -79.3832
169 relay.binaryrobot.com 43.6532 -79.3832
170 relay.earthly.city 34.1749 -118.54
171 nostr.hifish.org 47.4244 8.57658
172 offchain.pub:443 39.1585 -94.5728
173 relay.bullishbounty.com:443 43.6532 -79.3832
174 strfry.openhoofd.nl:443 51.5717 3.70417
175 cs-relay.nostrdev.com:443 50.4754 12.3683
176 strfry.ymir.cloud 43.6532 -79.3832
177 nostrbtc.com 43.6532 -79.3832
178 relay.directsponsor.net 42.8864 -78.8784
179 nostr2.girino.org 43.6532 -79.3832
180 relay.sigit.io:443 50.4754 12.3683
181 relay.getsafebox.app 43.6532 -79.3832
182 antiprimal.net 43.6532 -79.3832
183 nostr.sathoarder.com 48.5734 7.75211
184 inbox.scuba323.com 40.8218 -74.45
185 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
186 nostr.tac.lol 47.4748 -122.273
187 nostr.davenov.com 50.1109 8.68213
188 relay.trotters.cc:443 43.6532 -79.3832
189 nostr.plantroon.com:443 50.1013 8.62643
190 relay.nostreon.com 60.1699 24.9384
191 nostr.easycryptosend.it 43.6532 -79.3832
192 nostr-01.yakihonne.com:443 1.32123 103.695
193 relay-testnet.k8s.layer3.news 37.3387 -121.885
194 nostr.purpura.cloud 43.6532 -79.3832
195 insta-relay.apps3.slidestr.net 40.4167 -3.70329
196 nostr.mifen.me 43.6532 -79.3832
197 testnet-relay.samt.st:443 40.8302 -74.1299
198 nostr.2b9t.xyz:443 34.0549 -118.243
199 relay.wavlake.com:443 41.2619 -95.8608
200 relay.wisp.talk:443 49.4543 11.0746
201 relay-dev.satlantis.io:443 39.0438 -77.4874
202 relay.satlantis.io 39.0438 -77.4874
203 relay.staging.plebeian.market 51.5072 -0.127586
204 relay.openfarmtools.org 60.1699 24.9384
205 relay.nostrhub.fr 48.1045 11.6004
206 nostr-relay.xbytez.io 50.6924 3.20113
207 relay.binaryrobot.com:443 43.6532 -79.3832
208 relay.samt.st 40.8302 -74.1299
209 relay.illuminodes.com 43.6532 -79.3832
210 relay.liberbitworld.org 43.6532 -79.3832
211 relay.olas.app:443 60.1699 24.9384
212 no.str.cr 8.96171 -83.5246
213 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
214 wot.rejecttheframe.xyz 43.6532 -79.3832
215 relay.nostriot.com:443 41.5695 -83.9786
216 nostr.plantroon.com 50.1013 8.62643
217 nostr-01.uid.ovh 50.9871 2.12554
218 relay.openresist.com:443 43.6532 -79.3832
219 nostr.overmind.lol 43.6532 -79.3832
220 relay.internationalright-wing.org -22.5022 -48.7114
221 nostr.myshosholoza.co.za:443 52.3676 4.90414
222 nostr.pbfs.io:443 50.4754 12.3683
223 21milionidinostr.duckdns.org 41.8967 12.4822
224 nostr.4rs.nl 49.0291 8.35696
225 relay.lanavault.space 60.1699 24.9384
226 relay.mostr.pub 43.6532 -79.3832
227 relay.nostar.org 43.6532 -79.3832
228 nostr.mom 50.4754 12.3683
229 relay.decentralia.fr 48.122 11.589
230 relay.agentry.com 42.8864 -78.8784
231 relay2.angor.io 48.1046 11.6002
232 slick.mjex.me 39.0418 -77.4744
233 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
234 relay.vrtmrz.net:443 43.6532 -79.3832
235 relay.beginningend.com 35.2227 -97.4786
236 chat-relay.zap-work.com:443 43.6532 -79.3832
237 relay.underorion.se 50.1109 8.68213
238 relay.mitchelltribe.com 39.0438 -77.4874
239 relay.qstr.app 51.5072 -0.127586
240 relay.cyberguy.fyi 52.6907 4.8181
241 strfry.bonsai.com:443 39.0438 -77.4874
242 relayone.soundhsa.com:443 39.0997 -94.5786
243 relay.sigit.io 50.4754 12.3683
244 relay.npubhaus.com 43.6532 -79.3832
245 relayrs.notoshi.win 43.6532 -79.3832
246 relay.mitchelltribe.com:443 39.0438 -77.4874
247 relay.44billion.net 43.6532 -79.3832
248 reraw.pbla2fish.cc 43.6532 -79.3832
249 articles.layer3.news:443 37.3387 -121.885
250 nostr.sovereignservices.xyz 43.6532 -79.3832
251 relay.nostx.io 43.6532 -79.3832
252 nostr-relay.amethyst.name 39.0067 -77.4291
253 0x-nostr-relay.fly.dev 37.7648 -122.432
254 relay.ohstr.com:443 43.6532 -79.3832
255 00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
256 nostr-relay.cbrx.io 43.6532 -79.3832
257 relay.wavlake.com 41.2619 -95.8608
258 purplerelay.com:443 43.6532 -79.3832
259 nostr-pr02.redscrypt.org 52.3676 4.90414
260 fanfares.nostr1.com:443 40.7057 -74.0136
261 kasztanowa.bieda.it 43.6532 -79.3832
262 relay.flashapp.me 43.6548 -79.3885
263 relay.typedcypher.com 51.5072 -0.127586
264 nostr.bond 50.1109 8.68213
265 nostr.azzamo.net 52.2633 21.0283
266 nexus.libernet.app 43.6532 -79.3832
267 relay.cosmicbolt.net 37.3986 -121.964
268 schnorr.me 43.6532 -79.3832
269 relay.mostro.network:443 40.8302 -74.1299
270 relay-arg.zombi.cloudrodion.com 1.35208 103.82
271 relay.chorus.community 48.5333 10.7
272 blossom.gnostr.cloud:443 43.6532 -79.3832
273 syb.lol:443 34.0549 -118.243
274 relay.dyne.org 49.0291 8.35705
275 btc.klendazu.com 41.2861 1.24993
276 wot.nostr.place 43.6532 -79.3832
277 relay.openresist.com 43.6532 -79.3832
278 rilo.nostria.app:443 43.6532 -79.3832
279 no.str.cr:443 8.96171 -83.5246
280 relay.mostr.pub:443 43.6532 -79.3832
281 relay.edufeed.org:443 49.4521 11.0767
282 nostr.debate.report 50.1109 8.68213
283 relay.satmaxt.xyz:443 43.6532 -79.3832
284 relay.artx.market:443 43.6548 -79.3885
285 relay-dev.gulugulu.moe 43.6532 -79.3832
286 relay.novospes.com 43.6532 -79.3832
287 relay.nostr-check.me 43.6532 -79.3832
288 nostr.computingcache.com 34.0356 -118.442
289 nostr.oxtr.dev 50.4754 12.3683
290 relay.fckstate.net 59.3293 18.0686
291 relay.vrtmrz.net 43.6532 -79.3832
292 relay.bornheimer.app 51.5072 -0.127586
293 relay.guggero.org 46.5971 9.59652
294 relay01.lnfi.network 35.6764 139.65
295 wot.shaving.kiwi 43.6532 -79.3832
296 nostr.twinkle.lol 51.902 7.6657
297 relay.edufeed.org 49.4521 11.0767
298 relay.lanacoin-eternity.com 40.8302 -74.1299
299 relay.satmaxt.xyz 43.6532 -79.3832
300 nostr.hifish.org:443 47.4244 8.57658
301 relay.cypherflow.ai:443 48.8575 2.35138
302 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
303 nostr.na.social:443 43.6532 -79.3832
304 nostr.rtvslawenia.com:443 49.4543 11.0746
305 relay.mypathtofire.de 42.8864 -78.8784
306 public.crostr.com 43.6532 -79.3832
307 relay.olas.app 60.1699 24.9384
308 relay.agora.social 50.7383 15.0648
309 ribo.nostria.app 43.6532 -79.3832
310 relay.lab.rytswd.com 49.4543 11.0746
311 relay.ditto.pub:443 43.6532 -79.3832
312 porchlight.social 43.6532 -79.3832
313 nostr.notribe.net 40.8302 -74.1299
314 relay.endfiat.money 59.3327 18.0656
315 nostr.myshosholoza.co.za 52.3676 4.90414
316 relay.nearhood.co.uk 51.5134 -0.0890675
317 relay.degmods.com 50.4754 12.3683
318 nostr.novacisko.cz 52.2026 20.9397
319 prl.plus 55.7628 37.5983
320 bruh.samt.st 43.6532 -79.3832
321 strfry.openhoofd.nl 51.5717 3.70417
322 nostr.spicyz.io 43.6532 -79.3832
323 nostr.na.social 43.6532 -79.3832
324 nip85.nosfabrica.com 39.0997 -94.5786
325 premium.primal.net 43.6532 -79.3832
326 fanfares.nostr1.com 40.7057 -74.0136
327 relay.scuba323.com 40.8218 -74.45
328 nostr2.girino.org:443 43.6532 -79.3832
329 relay.mmwaves.de 48.8575 2.35138
330 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
331 strfry.shock.network:443 39.0438 -77.4874
332 nostr.snowbla.de 50.4754 12.3683
333 nostr.spaceshell.xyz 43.6532 -79.3832
334 nostr.quali.chat 60.1699 24.9384
335 wot.utxo.one 43.6532 -79.3832
336 relay.mccormick.cx 52.3563 4.95714
337 mostro-p2p.tech 50.1109 8.68213
338 basspistol.org 49.0291 8.35696
339 ribo.nostria.app:443 43.6532 -79.3832
340 chorus.mikedilger.com:444 -36.8906 174.794
341 nostr.oxtr.dev:443 50.4754 12.3683
342 nostr.nodesmap.com 59.3327 18.0656
343 offchain.bostr.online 43.6532 -79.3832
344 purplerelay.com 43.6532 -79.3832
345 relayrs.notoshi.win:443 43.6532 -79.3832
346 relay.wavefunc.live 41.8781 -87.6298
347 relay.dreamith.to 43.6532 -79.3832
348 bendernostur.duckdns.org:8443 50.1109 8.68213
349 relay.nmail.li 50.9871 2.12554
350 nostr-relay.corb.net 39.6478 -104.988
351 relay.staging.plebeian.market:443 51.5072 -0.127586
352 spamspamspamspam.rest 43.6532 -79.3832
353 relay1.gfcom.info 13.9215 100.538
354 schnorr.me:443 43.6532 -79.3832
355 relay.lab.rytswd.com:443 49.4543 11.0746
356 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
357 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
358 relay.nostrmap.net 60.1699 24.9384
359 nostr.relay.hedwig.sh 60.1699 24.9384
360 relay.veganostr.com:443 60.1699 24.9384
361 relay.wavefunc.live:443 41.8781 -87.6298
362 nostr.mikoshi.de 52.52 13.405
363 syb.lol 34.0549 -118.243
364 relay1.nostrchat.io 60.1699 24.9384
365 nostr.wecsats.io:443 43.6532 -79.3832
366 nostr.chaima.info:443 51.5072 -0.127586
367 nostr.azzamo.net:443 52.2633 21.0283
368 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
369 nostr.unkn0wn.world 46.8499 9.53287
370 relayone.soundhsa.com 39.0997 -94.5786
371 x.kojira.io 43.6532 -79.3832
372 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
373 nostrelay.circum.space 52.6907 4.8181
374 relay.primal.net 43.6532 -79.3832
375 nostr.girino.org 43.6532 -79.3832
376 nostr.pbfs.io 50.4754 12.3683
377 relay.kalcafe.xyz 37.3986 -121.964
378 relay.gulugulu.moe 43.6532 -79.3832
379 top.testrelay.top 43.6532 -79.3832
380 relay.kilombino.com 43.6532 -79.3832
381 nos.lol:443 50.4754 12.3683
382 nos.lol 50.4754 12.3683
383 relay.nostr.place:443 43.6532 -79.3832
384 cache.trustr.ing 43.6548 -79.3885
385 relay.internationalright-wing.org:443 -22.5022 -48.7114
386 relay.laantungir.net -19.4692 -42.5315
387 relay.lightning.pub:443 39.0438 -77.4874
388 nostr.stakey.net 52.3676 4.90414
389 articles.layer3.news 37.3387 -121.885
390 relay.wisp.talk 49.4543 11.0746
391 relay.pyramid.li 47.4093 8.46503
392 relay.typedcypher.com:443 51.5072 -0.127586
393 dev.relay.stream 43.6532 -79.3832
394 relay.bullishbounty.com 43.6532 -79.3832
395 nostr.mom:443 50.4754 12.3683
396 relay.plebeian.market:443 50.1109 8.68213
397 nostr.hekster.org 37.3986 -121.964
398 nostrcity-club.fly.dev 37.7648 -122.432
399 nostr.vulpem.com 49.4543 11.0746
400 relay-dev.gulugulu.moe:443 43.6532 -79.3832
401 weboftrust.libretechsystems.xyz 55.4724 9.87335
402 nostr-relay.corb.net:443 39.6478 -104.988
403 wheat.happytavern.co 43.6532 -79.3832
404 relay.mappingbitcoin.com 43.6532 -79.3832
405 testnet-relay.samt.st 40.8302 -74.1299
406 relay.bitmacro.cloud 43.6532 -79.3832
407 dev.relay.edufeed.org 49.4521 11.0767
408 myvoiceourstory.org 37.3598 -121.981
409 relay.stickeroo.is-cool.dev 37.3387 -121.885
410 relay.agorist.space 52.3734 4.89406
411 freelay.sovbit.host 60.1699 24.9384
412 nostr-dev.wellorder.net 45.5201 -122.99
413 nostr.middling.mydns.jp 35.8099 140.12
414 cs-relay.nostrdev.com 50.4754 12.3683
415 x.kojira.io:443 43.6532 -79.3832
416 nostrelay.circum.space:443 52.6907 4.8181
417 nostr.janx.com 43.6532 -79.3832
418 relay.mrmave.work 43.6532 -79.3832
419 espelho.girino.org 43.6532 -79.3832
420 hol.is 43.6532 -79.3832
421 ribo.eu.nostria.app 43.6532 -79.3832
422 nostr.yutakobayashi.com 43.6532 -79.3832
423 relay.mostro.network 40.8302 -74.1299
424 communities.nos.social 40.8302 -74.1299
425 relay.solife.me 43.6532 -79.3832
426 yabu.me 35.6092 139.73
427 relay.islandbitcoin.com 12.8498 77.6545
428 nostr.wecsats.io 43.6532 -79.3832
429 nostr.tac.lol:443 47.4748 -122.273
430 relay.arx-ccn.com 50.4754 12.3683
431 nostrride.io 37.3986 -121.964
432 r.0kb.io:443 32.789 -96.7989
433 herbstmeister.com 34.0549 -118.243
434 relay.artx.market 43.6548 -79.3885
435 vault.iris.to 43.6532 -79.3832
436 relay.ru.ac.th 13.7607 100.627
437 temp.iris.to 43.6532 -79.3832
438 social.amanah.eblessing.co 48.1046 11.6002
439 nostr-relay.nextblockvending.com 47.2343 -119.853
440 wot.codingarena.top 50.4754 12.3683
441 relay.sincensura.org 43.6532 -79.3832
442 nostr.dlcdevkit.com 40.0992 -83.1141

View File

@ -0,0 +1,33 @@
package com.bitchat.android.favorites
import com.bitchat.android.services.ContactIdentityResolver
data class FavoriteControlMessage(
val isFavorite: Boolean,
val npub: String?
) {
companion object {
private const val FAVORITED = "[FAVORITED]"
private const val UNFAVORITED = "[UNFAVORITED]"
fun parse(content: String): FavoriteControlMessage? {
val trimmed = content.trim()
val isFavorite = when {
trimmed.startsWith(FAVORITED) -> true
trimmed.startsWith(UNFAVORITED) -> false
else -> return null
}
val encodedKey = trimmed.substringAfter(":", "").trim()
val npub = encodedKey
.takeIf { it.isNotEmpty() }
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.let { ContactIdentityResolver.npubFromHex(it) }
return FavoriteControlMessage(isFavorite = isFavorite, npub = npub)
}
fun encode(isFavorite: Boolean, npub: String?): String {
val prefix = if (isFavorite) FAVORITED else UNFAVORITED
return "$prefix:${npub.orEmpty()}"
}
}
}

View File

@ -2,15 +2,15 @@ package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
/**
* Bridging Noise and Nostr favorites
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
* peerID (16-hex) -> npub indexing for Nostr DM routing.
*/
data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
@ -84,7 +84,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
@ -95,35 +94,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
}
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(pid)) return relationship
val pid = peerID.trim().lowercase()
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
return favorites[pid]
}
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid]?.let { indexedNpub ->
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
}
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
}
}
return null
}
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = nostrPubkey,
peerNostrPublicKey = normalizedNpub,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = nostrPubkey,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
@ -139,11 +158,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
/** Update Nostr pubkey for a specific mesh peerID. */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.lowercase()
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
peerIdIndex[pid] = nostrPubkey
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
@ -152,33 +174,32 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
fun findNostrPubkeyForPeerID(peerID: String): String? {
return peerIdIndex[peerID.lowercase()]
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
}
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
// First, try direct match in peerIdIndex (values are stored as npub strings)
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
// Attempt legacy mapping via favorites Noise key association
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
if (targetHex != null) {
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
if (rel != null) {
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
// Return 16-hex prefix as best-effort if no explicit mapping exists
return noiseHex.take(16)
}
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
}?.let { return it.key }
favorites.values.firstOrNull { relationship ->
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex
}?.let { relationship ->
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
@ -210,7 +231,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
if (existing != null) {
@ -228,6 +249,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() {
favorites.clear()
@ -240,15 +262,15 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
return favorites[keyHex]?.peerNostrPublicKey
}
@ -292,7 +314,12 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
peerIdIndex.putAll(data)
data.forEach { (peerID, npub) ->
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
@ -318,6 +345,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
@ -325,14 +353,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
} else value.lowercase()
} catch (_: Exception) { null }
}
/** Serializable data for JSON storage */
@ -348,7 +368,7 @@ private data class FavoriteRelationshipData(
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite,
@ -360,7 +380,7 @@ private data class FavoriteRelationshipData(
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,

View File

@ -1095,9 +1095,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
broadcastRoutedPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
// FIXED: Don't send didReceiveMessage for our own sent messages
// This was causing self-notifications - iOS doesn't do this
// The UI handles showing sent messages through its own message sending logic
// The UI handles sent messages through its own sending path.
} catch (e: Exception) {
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
@ -1107,8 +1105,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
// FIXED: Don't send didReceiveMessage for our own sent messages
// The UI will handle showing the message in the chat interface
// The UI handles sent messages through its own sending path.
}
}
}

View File

@ -898,6 +898,44 @@ class MeshCore(
}
}
/**
* Starts a fresh replacement handshake on one exact direct transport generation.
* This authenticates provisional transport claims without broadcasting the challenge or
* accidentally sending it through a socket that later reused the same alias.
*/
fun initiateNoiseHandshakeOnLink(
peerID: String,
relayAddress: String,
ingressLinkID: String
): Boolean {
return try {
val handshakeData = encryptionService.initiateHandshake(
peerID,
replaceEstablished = true
) ?: return false
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = maxTtl
)
transport.sendPacketToLink(
relayAddress,
ingressLinkID,
signPacketBeforeBroadcast(packet)
)
} catch (e: Exception) {
Log.e(
"MeshCore",
"Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
)
false
}
}
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)

View File

@ -13,6 +13,16 @@ interface MeshTransport {
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
/**
* Send through an exact transport generation rather than a reusable peer alias.
* Transports that cannot prove the link identity must decline the operation.
*/
fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean = false
fun cancelTransfer(transferId: String): Boolean = false
fun getDeviceAddressForPeer(peerID: String): String? = null

View File

@ -1,6 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.AuthenticatedPeerState
@ -11,7 +12,6 @@ import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
import kotlin.random.Random
sealed class AnnounceHandlingResult {
data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult()
@ -72,7 +72,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return
}
// NEW: Use NoisePayload system exactly like iOS
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
if (noisePayload == null) {
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
@ -90,7 +89,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Handle favorite/unfavorite notifications embedded as PMs
val pmContent = privateMessage.content
if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) {
if (FavoriteControlMessage.parse(pmContent) != null) {
handleFavoriteNotificationFromMesh(pmContent, peerID)
// Acknowledge delivery for UX parity
sendDeliveryAck(privateMessage.messageID, peerID)
@ -108,7 +107,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID,
mentions = null // TODO: Parse mentions if needed
mentions = null
)
// Notify delegate
@ -589,24 +588,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
*/
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) {
try {
val isFavorite = content.startsWith("[FAVORITED]")
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
val control = FavoriteControlMessage.parse(content) ?: return
// Update mutual favorite status in persistence
// Resolve full Noise key if available via delegate peer info
val peerInfo = delegate?.getPeerInfo(fromPeerID)
val noiseKey = peerInfo?.noisePublicKey
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
if (npub != null) {
// Index by noise key and current mesh peerID for fast Nostr routing
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
if (control.npub != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, control.npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, control.npub)
}
// Determine iOS-style guidance text
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val guidance = if (isFavorite) {
val guidance = if (control.isFavorite) {
if (rel?.isFavorite == true) {
" — mutual! You can continue DMs via Nostr when out of mesh."
} else {
@ -616,8 +610,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
". DMs over Nostr will pause unless you both favorite again."
}
// Emit system message via delegate callback
val action = if (isFavorite) "favorited" else "unfavorited"
val action = if (control.isFavorite) "favorited" else "unfavorited"
val sys = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "${peerInfo.nickname} $action you$guidance",

View File

@ -153,7 +153,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
@ -179,10 +179,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle Noise handshake message - SIMPLIFIED iOS-compatible version
*/
private suspend fun handleNoiseHandshake(routed: RoutedPacket) {
private suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
delegate?.handleNoiseHandshake(routed)
return delegate?.handleNoiseHandshake(routed) ?: false
}
/**

View File

@ -56,6 +56,22 @@ class SecurityManager(private val encryptionService: EncryptionService, private
val currentTime = System.currentTimeMillis()
val messageType = MessageType.fromValue(packet.type)
// LEAVE mutates presence immediately and cannot be safely replayed after the in-memory
// duplicate cache expires (or after an app restart). Bound it to the same five-minute
// security window used for duplicate retention while tolerating symmetric clock skew.
if (messageType == MessageType.LEAVE) {
val now = currentTime.coerceAtLeast(0).toULong()
val clockSkew = if (packet.timestamp >= now) {
packet.timestamp - now
} else {
now - packet.timestamp
}
if (clockSkew > MESSAGE_TIMEOUT.toULong()) {
Log.w(TAG, "Dropping stale or future-dated LEAVE from $peerID")
return false
}
}
// Duplicate detection
val messageID = generateMessageID(packet, peerID)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
@ -91,7 +92,7 @@ class UnifiedMeshService(
} catch (_: Exception) {
null
}
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = getPeerNicknames()[peerID] ?: peerID
if (hasEstablishedSession(peerID)) {
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())

View File

@ -2,6 +2,8 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
@ -9,10 +11,13 @@ import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MeshDelegateHandler
import com.bitchat.android.ui.PrivateChatManager
import com.bitchat.android.ui.PrivateMessageOrigin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -99,8 +104,9 @@ class NostrDirectMessageHandler(
}
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
val conversationID = ContactDirectory.canonicalConversationId(convKey)
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity)
} catch (e: Exception) {
Log.e(TAG, "onGiftWrap error: ${e.message}")
@ -110,7 +116,7 @@ class NostrDirectMessageHandler(
private suspend fun processNoisePayload(
payload: NoisePayload,
convKey: String,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
@ -119,9 +125,20 @@ class NostrDirectMessageHandler(
when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
seenStore.markDelivered(pm.messageID)
}
return
}
val message = BitchatMessage(
id = pm.messageID,
sender = senderNickname,
@ -130,15 +147,19 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey,
senderPeerID = conversationID,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasRead(pm.messageID)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
}
if (!seenStore.hasDelivered(pm.messageID)) {
@ -156,13 +177,13 @@ class NostrDirectMessageHandler(
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
}
}
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
}
}
NoisePayloadType.FILE_TRANSFER -> {
@ -180,14 +201,18 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey
senderPeerID = conversationID
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
} else {
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
}
NoisePayloadType.VERIFY_CHALLENGE,
@ -196,6 +221,63 @@ class NostrDirectMessageHandler(
}
}
private suspend fun handleFavoriteControl(
control: FavoriteControlMessage,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String
) {
try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
}
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
senderNpub?.let { FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) }
val targetConversationID = ContactDirectory.canonicalConversationId(conversationID)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val displayName = relationship
?.peerNickname
?.takeUnless { it.equals("Unknown", ignoreCase = true) }
?: senderNickname
val guidance = if (control.isFavorite) {
if (relationship?.isFavorite == true) {
" - mutual! You can continue DMs via Nostr when out of mesh."
} else {
" - favorite back to continue DMs later."
}
} else {
". DMs over Nostr will pause unless you both favorite again."
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
val systemMessage = BitchatMessage(
sender = "system",
content = "$displayName $action you$guidance",
timestamp = timestamp,
isRelay = false,
isPrivate = true,
senderPeerID = targetConversationID
)
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(
message = systemMessage,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
}
}
private fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")

View File

@ -2,15 +2,16 @@ package com.bitchat.android.nostr
import android.util.Base64
import android.util.Log
import com.bitchat.android.mesh.MeshPacketUtils
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
/**
* BitChat-over-Nostr Adapter
* Direct port from iOS implementation for 100% compatibility
*/
object NostrEmbeddedBitChat {
@ -173,26 +174,12 @@ object NostrEmbeddedBitChat {
* Normalize recipient peer ID (matches iOS implementation)
*/
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
try {
val maybeData = hexStringToByteArray(recipientPeerID)
return when (maybeData.size) {
32 -> {
// Treat as Noise static public key; derive peerID from fingerprint
// For now, return first 8 bytes as hex (simplified)
maybeData.take(8).joinToString("") { "%02x".format(it) }
}
8 -> {
// Already an 8-byte peer ID
recipientPeerID
}
else -> {
// Fallback: return as-is (expecting 16 hex chars)
recipientPeerID
}
}
} catch (e: Exception) {
// Fallback: return as-is
return recipientPeerID
val clean = recipientPeerID.trim().lowercase()
return when {
ContactIdentityResolver.isNoiseKeyHex(clean) ->
ContactIdentityResolver.peerIdForNoiseKeyHex(clean) ?: clean
ContactIdentityResolver.isMeshPeerId(clean) -> clean
else -> recipientPeerID
}
}
@ -210,25 +197,6 @@ object NostrEmbeddedBitChat {
/**
* Convert hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
if (hexString.length % 2 != 0) {
return ByteArray(8) // Return 8-byte array filled with zeros
}
val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
private fun hexStringToByteArray(hexString: String): ByteArray =
MeshPacketUtils.hexStringToByteArray(hexString)
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import java.security.MessageDigest
import java.security.SecureRandom
@ -131,8 +132,7 @@ object NostrIdentityBridge {
* Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing
* if the candidate is not a valid secp256k1 private key.
*
* Direct port from iOS implementation for 100% compatibility
* OPTIMIZED: Cached for UI responsiveness
* Cached for UI responsiveness.
*/
fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity {
// Check cache first for immediate response
@ -188,12 +188,8 @@ object NostrIdentityBridge {
* Associate a Nostr identity with a Noise public key (for favorites)
*/
fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) {
val stateManager = SecureIdentityStateManager(context)
// We'll use the existing signing key storage mechanism for associations
// For now, we'll store this as a preference since it's just for favorites mapping
// In a full implementation, you'd want a proper association storage system
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateNostrPublicKey(noisePublicKey, nostrPubkey)
Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key")
}
@ -201,9 +197,8 @@ object NostrIdentityBridge {
* Get Nostr public key associated with a Noise public key
*/
fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? {
// This would need proper implementation based on your favorites storage system
// For now, return null as we don't have the full association system
return null
FavoritesPersistenceService.initialize(context)
return FavoritesPersistenceService.shared.findNostrPubkey(noisePublicKey)
}
/**

View File

@ -2,15 +2,17 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Minimal Nostr transport for offline sending
* Direct port from iOS NostrTransport for 100% compatibility
* Nostr transport for offline private messages and receipts.
*/
class NostrTransport(
private val context: Context,
@ -53,11 +55,7 @@ class NostrTransport(
) {
transportScope.launch {
try {
// Resolve favorite by full noise key or by short peerID fallback
var recipientNostrPubkey: String? = null
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for peerID: $to")
@ -72,20 +70,12 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
// Convert recipient npub -> hex (x-only)
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") {
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
return@launch
}
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
return@launch
}
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
val recipientPeerIDForEmbed = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findPeerIDForNostrPubkey(recipientNostrPubkey)
@ -115,6 +105,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -147,10 +138,7 @@ class NostrTransport(
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
val recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
@ -167,15 +155,8 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
// Convert recipient npub -> hex
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") {
scheduleNextReadAck()
return@launch
}
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
scheduleNextReadAck()
return@launch
}
@ -201,6 +182,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -224,10 +206,7 @@ class NostrTransport(
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
@ -240,16 +219,12 @@ class NostrTransport(
return@launch
}
val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}"
val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub)
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
// Convert recipient npub -> hex
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -273,6 +248,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -285,10 +261,7 @@ class NostrTransport(
fun sendDeliveryAck(messageID: String, to: String) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
@ -303,11 +276,8 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -331,6 +301,7 @@ class NostrTransport(
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
@ -482,16 +453,17 @@ class NostrTransport(
*/
private fun resolveNostrPublicKey(peerID: String): String? {
try {
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
ContactDirectory.resolve(peerID).nostrPubkey?.let { return it }
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
// 2) Legacy path: resolve by noise public key association
val noiseKey = hexStringToByteArray(peerID)
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peerID) ?: return null
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
}
// 3) Prefix match on noiseHex from 16-hex peerID
if (peerID.length == 16) {
if (ContactIdentityResolver.isMeshPeerId(peerID)) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
}
@ -503,14 +475,6 @@ class NostrTransport(
}
}
/**
* Convert full hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun cleanup() {
transportScope.cancel()
}

View File

@ -99,10 +99,11 @@ object AppStateStore {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
@ -139,6 +140,57 @@ object AppStateStore {
}
}
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
if (keysToMerge.isEmpty()) return
synchronized(this) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val map = _privateMessages.value.toMutableMap()
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
val targetIds = targetList.map { it.id }.toMutableSet()
var changed = false
keysToMerge.distinct().forEach { key ->
val canonicalKey = ContactDirectory.canonicalConversationId(key)
if (canonicalKey == targetConversationID) {
val messages = map.remove(key)
if (messages != null) {
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) targetList.add(message)
}
}
return@forEach
}
if (key == targetConversationID) return@forEach
val messages = map.remove(key) ?: return@forEach
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) {
targetList.add(message)
}
}
}
if (changed) {
if (targetList.isEmpty()) {
map.remove(targetConversationID)
} else {
map[targetConversationID] = targetList
}
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
}
fun canonicalizePrivateChats() {
synchronized(this) {
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonical != _privateMessages.value) {
_privateMessages.value = canonical
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return

View File

@ -0,0 +1,201 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.GeohashAliasRegistry
object ContactDirectory {
data class ContactResolution(
val conversationID: String,
val meshPeerID: String?,
val noisePublicKey: ByteArray?,
val nostrPubkey: String?,
val displayName: String?,
val isMutualFavorite: Boolean
) {
val noiseKeyHex: String? get() = noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
}
@Volatile
private var appContext: Context? = null
@Volatile
private var meshProvider: (() -> MeshService?)? = null
fun initialize(context: Context, meshProvider: () -> MeshService?) {
appContext = context.applicationContext
this.meshProvider = meshProvider
}
fun isContactConversationID(value: String): Boolean =
ContactIdentityResolver.isContactConversationId(value)
fun canonicalConversationId(peerOrConversationID: String): String {
val value = peerOrConversationID.trim()
if (ContactIdentityResolver.isContactConversationId(value)) return value.lowercase()
noiseKeyForAlias(value)?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
favoriteForMeshPeerID(value)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
if (ContactIdentityResolver.isNostrAlias(value)) {
nostrPubkeyHexForAlias(value)?.let { pubHex ->
findFavoriteByNostrHex(pubHex)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
}
return value
}
fun resolve(peerOrConversationID: String): ContactResolution {
val conversationID = canonicalConversationId(peerOrConversationID)
val contactFingerprint = ContactIdentityResolver.fingerprintFromContactConversationId(conversationID)
val favorite = contactFingerprint?.let { findFavoriteByFingerprint(it) }
val noiseKey = when {
favorite != null -> favorite.peerNoisePublicKey
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
else -> null
}
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
?: peerOrConversationID.takeIf { ContactIdentityResolver.isMeshPeerId(it) && isMeshPeerConnected(it) }
return ContactResolution(
conversationID = conversationID,
meshPeerID = liveMeshPeerID,
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
isMutualFavorite = favorite?.isMutual == true
)
}
fun canonicalizePrivateChats(
chats: Map<String, List<BitchatMessage>>
): Map<String, List<BitchatMessage>> {
if (chats.isEmpty()) return chats
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
chats.forEach { (key, messages) ->
val canonical = canonicalConversationId(key)
val list = merged.getOrPut(canonical) { mutableListOf() }
list.addAll(messages)
}
return merged.mapValues { (_, messages) ->
messages
.distinctBy { it.id }
.sortedWith(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
}
}
fun aliasesForConversation(peerOrConversationID: String): Set<String> {
val resolution = resolve(peerOrConversationID)
val aliases = mutableSetOf<String>()
aliases.add(peerOrConversationID)
aliases.add(resolution.conversationID)
resolution.meshPeerID?.let { aliases.add(it) }
resolution.noiseKeyHex?.let { aliases.add(it) }
resolution.nostrPubkey
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { aliases.add(it) }
return aliases
}
private fun noiseKeyForAlias(value: String): ByteArray? {
if (ContactIdentityResolver.isNoiseKeyHex(value)) {
return ContactIdentityResolver.bytesFromHex(value)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
meshProvider?.invoke()?.getPeerInfo(value)?.noisePublicKey?.let { return it }
cachedNoiseKey(value)?.let { return it }
}
return null
}
private fun cachedNoiseKey(peerID: String): ByteArray? {
val context = appContext ?: return null
return try {
SecureIdentityStateManager(context)
.getCachedNoiseKey(peerID)
?.let { ContactIdentityResolver.bytesFromHex(it) }
} catch (_: Exception) {
null
}
}
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
private fun findFavoriteByFingerprint(fingerprint: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
ContactIdentityResolver.fingerprintHex(it.peerNoisePublicKey).equals(fingerprint, ignoreCase = true)
}
} catch (_: Exception) {
null
}
private fun findFavoriteByNostrHex(pubHex: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
it.peerNostrPublicKey
?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) }
?.equals(pubHex, ignoreCase = true) == true
}
} catch (_: Exception) {
null
}
private fun nostrPubkeyHexForAlias(alias: String): String? {
GeohashAliasRegistry.get(alias)?.let { return it.lowercase() }
val prefix = alias.removePrefix("nostr_").removePrefix("nostr:")
if (prefix.isBlank()) return null
return try {
FavoritesPersistenceService.shared.getAllRelationships()
.asSequence()
.mapNotNull { it.peerNostrPublicKey?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) } }
.firstOrNull { it.startsWith(prefix, ignoreCase = true) }
} catch (_: Exception) {
null
}
}
private fun findLiveMeshPeerForFingerprint(fingerprint: String): String? {
val mesh = meshProvider?.invoke() ?: return null
return mesh.getPeerNicknames().keys.firstOrNull { peerID ->
val info = mesh.getPeerInfo(peerID)
val noiseKey = info?.noisePublicKey ?: cachedNoiseKey(peerID)
noiseKey != null &&
ContactIdentityResolver.fingerprintHex(noiseKey).equals(fingerprint, ignoreCase = true) &&
info?.isConnected == true
}
}
private fun isMeshPeerConnected(peerID: String): Boolean =
try {
meshProvider?.invoke()?.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}

View File

@ -0,0 +1,83 @@
package com.bitchat.android.services
import com.bitchat.android.nostr.Bech32
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
object ContactIdentityResolver {
private const val CONTACT_PREFIX = "contact_"
private val meshPeerIdRegex = Regex("^[0-9a-fA-F]{16}$")
private val noiseKeyRegex = Regex("^[0-9a-fA-F]{64}$")
private val fingerprintRegex = Regex("^[0-9a-fA-F]{64}$")
fun isMeshPeerId(value: String): Boolean = meshPeerIdRegex.matches(value)
fun isNoiseKeyHex(value: String): Boolean = noiseKeyRegex.matches(value)
fun isNostrAlias(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
fun noiseKeyHex(noisePublicKey: ByteArray): String = noisePublicKey.hexEncodedString()
fun fingerprintHex(noisePublicKey: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(noisePublicKey)
return digest.hexEncodedString()
}
fun contactConversationIdForNoiseKey(noisePublicKey: ByteArray): String =
CONTACT_PREFIX + fingerprintHex(noisePublicKey)
fun contactConversationIdForFingerprint(fingerprint: String): String? =
fingerprint
.takeIf { fingerprintRegex.matches(it) }
?.let { CONTACT_PREFIX + it.lowercase() }
fun isContactConversationId(value: String): Boolean =
value.startsWith(CONTACT_PREFIX) &&
fingerprintRegex.matches(value.removePrefix(CONTACT_PREFIX))
fun fingerprintFromContactConversationId(value: String): String? =
value
.takeIf { isContactConversationId(it) }
?.removePrefix(CONTACT_PREFIX)
?.lowercase()
fun peerIdForNoiseKey(noisePublicKey: ByteArray): String =
fingerprintHex(noisePublicKey).take(16)
fun peerIdForNoiseKeyHex(noiseKeyHex: String): String? =
bytesFromHex(noiseKeyHex)
?.takeIf { it.size == 32 }
?.let { peerIdForNoiseKey(it) }
fun bytesFromHex(hex: String): ByteArray? {
val clean = hex.trim()
if (clean.length % 2 != 0) return null
if (!clean.matches(Regex("^[0-9a-fA-F]+$"))) return null
return clean.dataFromHexString()
}
fun nostrPubkeyHex(value: String): String? {
val clean = value.trim()
if (clean.startsWith("npub1", ignoreCase = true)) {
return try {
val (hrp, data) = Bech32.decode(clean.lowercase())
if (hrp == "npub" && data.size == 32) data.hexEncodedString() else null
} catch (_: Exception) {
null
}
}
return clean
.takeIf { noiseKeyRegex.matches(it) }
?.lowercase()
}
fun npubFromHex(hex: String): String? =
bytesFromHex(hex)
?.takeIf { it.size == 32 }
?.let { Bech32.encode("npub", it) }
fun nostrAliasForPubkey(value: String): String? =
nostrPubkeyHex(value)?.let { "nostr_${it.take(16)}" }
}

View File

@ -8,28 +8,25 @@ object ConversationAliasResolver {
selectedPeerID: String,
connectedPeers: List<String>,
meshNoiseKeyForPeer: (String) -> ByteArray?,
meshHasPeer: (String) -> Boolean,
nostrPubHexForAlias: (String) -> String?,
findNoiseKeyForNostr: (String) -> ByteArray?
): String {
var peer = selectedPeerID
try {
if (peer.startsWith("nostr_")) {
if (ContactIdentityResolver.isNostrAlias(peer)) {
val pubHex = nostrPubHexForAlias(peer)
if (pubHex != null) {
val noiseKey = findNoiseKeyForNostr(pubHex)
if (noiseKey != null) {
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
// Prefer a connected mesh peer that matches this noise key
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
peer = meshPeer ?: noiseHex
}
}
} else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) {
// Peer is full noise key hex: upgrade to active mesh peer if available
val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else if (ContactIdentityResolver.isNoiseKeyHex(peer)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peer) ?: return peer
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
@ -38,7 +35,7 @@ object ConversationAliasResolver {
}
}
} catch (_: Exception) { /* no-op */ }
return peer
return ContactDirectory.canonicalConversationId(peer)
}
fun unifyChatsIntoPeer(
@ -47,11 +44,17 @@ object ConversationAliasResolver {
keysToMerge: List<String>
) {
if (keysToMerge.isEmpty()) return
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val mergeKeys = (keysToMerge + targetPeerID)
.flatMap { ContactDirectory.aliasesForConversation(it) }
.distinct()
AppStateStore.unifyPrivateChatsIntoPeer(targetConversationID, mergeKeys)
val currentChats = state.getPrivateChatsValue().toMutableMap()
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
val targetList = currentChats[targetConversationID]?.toMutableList() ?: mutableListOf()
var didMerge = false
keysToMerge.distinct().forEach { key ->
if (key == targetPeerID) return@forEach
mergeKeys.forEach { key ->
if (key == targetConversationID) return@forEach
val list = currentChats[key]
if (!list.isNullOrEmpty()) {
targetList.addAll(list)
@ -60,27 +63,28 @@ object ConversationAliasResolver {
}
}
if (didMerge) {
// Preserve arrival order; do not sort by timestamp
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
currentChats[targetConversationID] = targetList
.distinctBy { it.id }
.sortedBy { it.timestamp.time }
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats))
// Move unread flags
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
var hadUnread = false
keysToMerge.forEach { key -> if (unread.remove(key)) hadUnread = true }
if (hadUnread) unread.add(targetPeerID)
mergeKeys.forEach { key -> if (unread.remove(key)) hadUnread = true }
if (hadUnread) unread.add(targetConversationID)
state.setUnreadPrivateMessages(unread)
// Switch selection if currently viewing an alias that got merged
val selected = state.getSelectedPrivateChatPeerValue()
if (selected != null && keysToMerge.contains(selected)) {
state.setSelectedPrivateChatPeer(targetPeerID)
if (selected != null && mergeKeys.contains(selected)) {
state.setSelectedPrivateChatPeer(targetConversationID)
}
// Switch sheet peer if currently viewing an alias that got merged
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
if (sheetPeer != null && mergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetConversationID)
}
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
@ -14,6 +15,13 @@ class MessageRouter private constructor(
private var mesh: MeshService,
private val nostr: NostrTransport
) {
enum class RouteResult {
MESH,
NOSTR,
QUEUED,
DROPPED
}
companion object {
private const val TAG = "MessageRouter"
@Volatile private var INSTANCE: MessageRouter? = null
@ -46,53 +54,58 @@ class MessageRouter private constructor(
override fun onFavoriteChanged(noiseKeyHex: String) {
flushOutboxFor(noiseKeyHex)
// Also try 16-hex short id commonly used in UI if any client used that
val shortId = noiseKeyHex.take(16)
flushOutboxFor(shortId)
ContactIdentityResolver.peerIdForNoiseKeyHex(noiseKeyHex)?.let { flushOutboxFor(it) }
}
override fun onAllCleared() {
// Nothing special; leave queued items until routing becomes possible
}
}
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult {
val resolution = ContactDirectory.resolve(toPeerID)
val conversationID = resolution.conversationID
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}")
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
// Resolve the conversation's source geohash, so we can send from anywhere
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
// If repository knows the source geohash, pass it so NostrTransport derives the correct identity
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
return
return RouteResult.NOSTR
}
return RouteResult.DROPPED
}
val hasMesh = isConnected(mesh, toPeerID)
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(conversationID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}")
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
return RouteResult.QUEUED
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing READ via mesh to ${meshTarget.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, meshTarget, mesh.getPeerNicknames()[meshTarget] ?: mesh.myPeerID)
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
nostr.sendReadReceipt(receipt, toPeerID)
nostr.sendReadReceipt(receipt, nostrTarget)
}
}
@ -106,50 +119,48 @@ class MessageRouter private constructor(
return
}
}
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
nostr.sendDeliveryAck(messageID, toPeerID)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (!(meshTarget != null && (mesh.getPeerInfo(meshTarget)?.isConnected == true) && mesh.hasEstablishedSession(meshTarget))) {
nostr.sendDeliveryAck(messageID, resolution.noiseKeyHex ?: toPeerID)
}
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) {
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
mesh.sendPrivateMessage(content, toPeerID, nickname, null)
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget
mesh.sendPrivateMessage(content, meshTarget, nickname, null)
} else {
nostr.sendFavoriteNotification(toPeerID, isFavorite)
nostr.sendFavoriteNotification(resolution.noiseKeyHex ?: toPeerID, isFavorite)
}
}
// Flush any queued messages for a specific peerID
fun flushOutboxFor(peerID: String) {
val queued = outbox[peerID] ?: return
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}")
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
val hasMesh = isReady(mesh, peerID)
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
if (meshPeer != null && isReady(mesh, meshPeer)) {
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
iterator.remove()
continue
}
}
val canNostr = canSendViaNostr(peerID)
if (hasMesh) {
mesh.sendPrivateMessage(content, peerID, nickname, messageID)
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
iterator.remove()
} else if (canNostr) {
nostr.sendPrivateMessage(content, peerID, nickname, messageID)
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID)
outbox.remove(peerID)
}
}
@ -161,14 +172,15 @@ class MessageRouter private constructor(
private fun canSendViaNostr(peerID: String): Boolean {
return try {
// Full Noise key hex
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKey = hexToBytes(peerID)
val resolution = ContactDirectory.resolve(peerID)
if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true
val target = resolution.noiseKeyHex ?: peerID
if (ContactIdentityResolver.isNoiseKeyHex(target)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(target) ?: return false
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
// Ephemeral 16-hex mesh ID: resolve via prefix match in favorites
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} else if (ContactIdentityResolver.isMeshPeerId(target)) {
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(target)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else {
false
@ -176,11 +188,6 @@ class MessageRouter private constructor(
} catch (_: Exception) { false }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = if (hex.length % 2 == 0) hex else "0$hex"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun isConnected(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true
@ -198,22 +205,12 @@ class MessageRouter private constructor(
}
}
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
return try {
service.getPeerNicknames().keys.firstOrNull { pid ->
val info = service.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
}
} catch (_: Exception) { null }
}
// Called when mesh peer list changes; attempt to flush any matching outbox entries
fun onPeersUpdated(peers: List<String>) {
peers.forEach { pid ->
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
@ -223,7 +220,7 @@ class MessageRouter private constructor(
fun onSessionEstablished(peerID: String) {
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}

View File

@ -22,16 +22,14 @@ import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random
import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@ -220,6 +218,8 @@ class ChatViewModel(
init {
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
@ -235,14 +235,14 @@ class ChatViewModel(
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
// Replace with store snapshot
state.setPrivateChats(byPeer)
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
state.setPrivateChats(canonicalChats)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
canonicalChats.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
@ -417,17 +417,18 @@ class ChatViewModel(
val success = privateChatManager.startPrivateChat(peerID, mesh)
if (success) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
// Notify notification manager about current private chat
setCurrentPrivateChatPeer(peerID)
setCurrentPrivateChatPeer(conversationID)
// Clear notifications for this sender since user is now viewing the chat
clearNotificationsForSender(peerID)
clearNotificationsForSender(conversationID)
// Persistently mark all messages in this conversation as read so Nostr fetches
// after app restarts won't re-mark them as unread.
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val chats = state.getPrivateChatsValue()
val messages = chats[peerID] ?: emptyList()
val messages = chats[conversationID] ?: emptyList()
messages.forEach { msg ->
try { seen.markRead(msg.id) } catch (_: Exception) { }
}
@ -481,12 +482,11 @@ class ChatViewModel(
} else {
// Resolve to a canonical mesh peer if needed
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
canonical ?: targetKey
}
@ -525,21 +525,19 @@ class ChatViewModel(
}
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
var selectedPeer = state.getSelectedPrivateChatPeerValue()
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeer = ContactDirectory.canonicalConversationId(
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = selectedPeer,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, mesh)
@ -558,9 +556,11 @@ class ChatViewModel(
state.getNicknameValue(),
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent)
}
}
} else {
// Check if we're in a location channel
@ -624,25 +624,23 @@ class ChatViewModel(
var noiseKey: ByteArray? = null
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = mesh.getPeerInfo(peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
} else {
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
try {
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Prefer nickname from favorites store if available
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
if (rel != null) nickname = rel.peerNickname
} catch (_: Exception) { }
} else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
noiseKey = ContactIdentityResolver.bytesFromHex(peerID)
val rel = noiseKey?.let {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
if (rel != null) nickname = rel.peerNickname
} else {
val contact = ContactDirectory.resolve(peerID)
noiseKey = contact.noisePublicKey
contact.displayName?.let { nickname = it }
}
if (noiseKey != null) {
// Determine current favorite state from DataManager using fingerprint
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
@ -653,7 +651,6 @@ class ChatViewModel(
isFavorite = isNowFavorite
)
// Send favorite notification via mesh or Nostr with our npub if available
try {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), mesh)
@ -860,7 +857,8 @@ class ChatViewModel(
}
fun showPrivateChatSheet(peerID: String) {
state.setPrivateChatSheetPeer(peerID)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
state.setPrivateChatSheetPeer(conversationID)
}
fun hidePrivateChatSheet() {
@ -953,8 +951,6 @@ class ChatViewModel(
return meshDelegateHandler.isFavorite(peerID)
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
// MARK: - Emergency Clear
fun panicClearAllData() {

View File

@ -5,6 +5,7 @@ import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Date
@ -26,7 +27,6 @@ class MeshDelegateHandler(
override fun didReceiveMessage(message: BitchatMessage) {
coroutineScope.launch {
// FIXED: Deduplicate messages from dual connection paths
val messageKey = messageManager.generateMessageKey(message)
if (messageManager.isMessageProcessed(messageKey)) {
return@launch // Duplicate message, ignore
@ -111,88 +111,31 @@ class MeshDelegateHandler(
// Clean up channel members who disconnected
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
// Handle chat view migration based on current selection and new peer list
runCatching { com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() }
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
val isNostrAlias = currentPeer.startsWith("nostr_")
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
if (isNostrAlias || isNoiseHex) {
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
// Resolve canonical target (prefer connected mesh peer if available)
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = currentPeer,
connectedPeers = mergedPeers,
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mergedPeers.contains(pid) },
nostrPubHexForAlias = { alias ->
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
} else {
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
val prefix = alias.removePrefix("nostr_")
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
}
}
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
}
},
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
val canonical = ContactDirectory.canonicalConversationId(currentPeer)
if (canonical != currentPeer) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = canonical,
keysToMerge = ContactDirectory.aliasesForConversation(currentPeer).toList()
)
if (canonical != currentPeer) {
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
state.setSelectedPrivateChatPeer(canonical)
}
} else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) {
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
val favoriteRel = try {
val info = getPeerInfo(currentPeer)
val noiseKey = info?.noisePublicKey
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
} else null
} catch (_: Exception) { null }
if (favoriteRel?.isMutual == true) {
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
if (noiseHex != currentPeer) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = noiseHex,
keysToMerge = listOf(currentPeer)
)
state.setSelectedPrivateChatPeer(noiseHex)
}
} else {
privateChatManager.cleanupDisconnectedPeer(currentPeer)
}
state.setSelectedPrivateChatPeer(canonical)
}
}
state.getPrivateChatSheetPeerValue()?.let { sheetPeer ->
val canonical = ContactDirectory.canonicalConversationId(sheetPeer)
if (canonical != sheetPeer) {
state.setPrivateChatSheetPeer(canonical)
}
}
// Global unification: for each connected peer, merge any offline/stable conversations
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
mergedPeers.forEach { pid ->
try {
val info = getPeerInfo(pid)
val noiseKey = info?.noisePublicKey ?: return@forEach
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
// Derive temp nostr key from favorites npub
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
val tempNostrKey: String? = try {
if (npub != null) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
} else null
} catch (_: Exception) { null }
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
val canonical = ContactDirectory.canonicalConversationId(pid)
unifyChatsIntoPeer(canonical, ContactDirectory.aliasesForConversation(pid).toList())
} catch (_: Exception) { }
}
}
@ -294,16 +237,30 @@ class MeshDelegateHandler(
// Send read receipt if user is currently focused on this specific chat
val senderPeerID = message.senderPeerID
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
val senderConversationID = senderPeerID?.let { ContactDirectory.canonicalConversationId(it) }
val focusedConversationID = currentPrivateChatPeer?.let { ContactDirectory.canonicalConversationId(it) }
val shouldSendReadReceipt = !isAppInBackground &&
senderConversationID != null &&
focusedConversationID == senderConversationID
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
android.util.Log.d(
"MeshDelegateHandler",
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
)
val nickname = state.getNicknameValue() ?: "unknown"
val mesh = getMeshService()
val sent = try {
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
if (hasMesh) {
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
val meshPeerID = senderConversationID
?.let { ContactDirectory.resolve(it).meshPeerID }
?: senderPeerID?.takeIf {
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
}
if (meshPeerID != null &&
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
mesh.hasEstablishedSession(meshPeerID)
) {
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
true
} else {
false
@ -315,7 +272,8 @@ class MeshDelegateHandler(
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
if (current.remove(senderPeerID)) {
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
if (changed) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
@ -324,9 +282,6 @@ class MeshDelegateHandler(
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
/**
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
*/

View File

@ -18,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -30,10 +31,16 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
/**
@ -286,6 +293,11 @@ fun PeopleSection(
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
val context = LocalContext.current
val identityStateManager = remember(context) {
SecureIdentityStateManager(context.applicationContext)
}
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.people).uppercase(),
@ -322,9 +334,8 @@ fun PeopleSection(
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
// Reactive favorite computation - same as ChatHeader
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
}
@ -334,13 +345,35 @@ fun PeopleSection(
}
}
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
// Build mapping of connected peerID -> Noise key hex to unify with offline favorites.
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
?: identityStateManager.getCachedNoiseKey(pid)
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
val nostrHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
FavoritesPersistenceService.shared
.findNostrPubkeyForPeerID(pid)
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
val connectedNoiseHexes = noiseHexByPeerID.values.map { it.lowercase() }.toSet()
val connectedNostrHexes = nostrHexByPeerID.values.map { it.lowercase() }.toSet()
fun isFavoriteMappedToConnected(favorite: FavoriteRelationship): Boolean {
val noiseHex = ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey).lowercase()
if (connectedNoiseHexes.contains(noiseHex)) return true
val nostrHex = favorite.peerNostrPublicKey
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.lowercase()
return nostrHex != null && connectedNostrHexes.contains(nostrHex)
}
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
@ -369,11 +402,10 @@ fun PeopleSection(
}
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (!isFavoriteMappedToConnected(fav)) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
@ -382,12 +414,11 @@ fun PeopleSection(
// Nostr-only conversations
val connectedIds = sortedPeers.toSet()
val appendedOfflineIds = mutableSetOf<String>()
privateChats.keys
.filter { key ->
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
!connectedIds.contains(key) &&
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
!connectedNoiseHexes.contains(key.lowercase())
}
.forEach { convKey ->
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
@ -396,16 +427,17 @@ fun PeopleSection(
}
sortedPeers.forEach { peerID ->
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID]
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
val meshUnread = hasUnreadPrivateMessages.contains(conversationID) || hasUnreadPrivateMessages.contains(peerID)
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
val combinedHasUnread = meshUnread || nostrUnread
val combinedUnreadCount = (
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
privateChats[conversationID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
) + (
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
)
@ -421,7 +453,7 @@ fun PeopleSection(
displayName = displayName,
isDirect = isDirectLive,
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = peerID == selectedPrivatePeer,
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
isFavorite = isFavorite,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
@ -440,29 +472,19 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (isFavoriteMappedToConnected(fav)) return@forEach
// Resolve potential Nostr conversation key for this favorite (for unread detection)
val nostrConvKey: String? = try {
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
} else {
npubOrHex.lowercase()
}
hex?.let { "nostr_${it.take(16)}" }
} else null
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
} catch (_: Exception) { null }
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
val conversationID = ContactDirectory.canonicalConversationId(favPeerID)
val hasUnread = hasUnreadPrivateMessages.contains(conversationID) ||
hasUnreadPrivateMessages.contains(favPeerID) ||
(nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
// open chat with the connected peerID instead of the noise hex for a seamless window
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = splitSuffix(dn)
@ -470,9 +492,8 @@ fun PeopleSection(
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0
) + (
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
)
@ -481,7 +502,7 @@ fun PeopleSection(
peerID = favPeerID,
displayName = dn,
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
isVerified = isVerified,
hasUnreadDM = hasUnread,
@ -496,51 +517,7 @@ fun PeopleSection(
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
showHashSuffix = showHash
)
appendedOfflineIds.add(favPeerID)
}
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
/*
val alreadyShownIds = connectedIds + appendedOfflineIds
privateChats.keys
.filter { key ->
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
hex64Regex.matches(key) &&
!alreadyShownIds.contains(key) &&
// Skip if this key maps to a connected peer via noiseHex mapping
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
}
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
.forEach { convKey ->
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
val (bName, _) = splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
PeerItem(
peerID = convKey,
displayName = dn,
isDirect = false,
isSelected = convKey == selectedPrivatePeer,
isFavorite = false,
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
colorScheme = colorScheme,
viewModel = viewModel,
onItemClick = { onPrivateChatStart(convKey) },
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
unreadCount = privateChats[convKey]?.count { msg ->
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
showNostrGlobe = false,
showHashSuffix = showHash
)
}
*/
// End intentional removal
}
}
@ -788,7 +765,11 @@ fun PrivateChatSheet(
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val isWifiAware = peerID in wifiAwareConnected.keys
val contactResolution = remember(peerID, connectedPeers, favoritePeers) {
ContactDirectory.resolve(peerID)
}
val activeMeshPeerID = contactResolution.meshPeerID
val isWifiAware = activeMeshPeerID in wifiAwareConnected.keys || peerID in wifiAwareConnected.keys
// Start private chat when screen opens
LaunchedEffect(peerID) {
@ -796,10 +777,27 @@ fun PrivateChatSheet(
}
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
val favoriteRelationship = remember(peerID, favoritePeers) {
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
}
val isDirect = activeMeshPeerID?.let { peerDirectMap[it] } == true || peerDirectMap[peerID] == true
val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect
val isNostrReachableFavorite =
!isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null
// Compute display name and title text reactively
val displayName = peerNicknames[peerID] ?: peerID.take(12)
val titleText = remember(peerID, peerNicknames) {
val displayName = remember(peerID, peerNicknames, favoriteRelationship) {
peerNicknames[peerID]
?: activeMeshPeerID?.let { peerNicknames[it] }
?: contactResolution.displayName
?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: viewModel.resolvePeerDisplayNameForFingerprint(peerID)
}
val titleText = remember(peerID, peerNicknames, favoriteRelationship) {
if (isNostrPeer) {
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
@ -810,16 +808,17 @@ fun PrivateChatSheet(
}
"#$gh/@$name"
} else {
peerNicknames[peerID] ?: peerID.take(12)
displayName
}
}
val messages = privateChats[peerID] ?: emptyList()
val isDirect = peerDirectMap[peerID] == true
val isConnected = connectedPeers.contains(peerID) || isDirect
val sessionState = peerSessionStates[peerID]
val fingerprint = peerFingerprints[peerID]
val isFavorite = remember(favoritePeers, fingerprint) {
val conversationID = contactResolution.conversationID
val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList()
val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID]
val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] }
?: peerFingerprints[peerID]
?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
@ -827,7 +826,7 @@ fun PrivateChatSheet(
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
val securityModifier = if (!isNostrPeer) {
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
@ -941,7 +940,7 @@ fun PrivateChatSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer -> {
isNostrPeer || isNostrReachableFavorite -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
@ -981,14 +980,14 @@ fun PrivateChatSheet(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
),
color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.then(securityModifier)
) {
if (!isNostrPeer) {
if (!isNostrPeer && !isNostrReachableFavorite) {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
import java.util.*
import java.util.Collections
@ -10,7 +11,7 @@ import java.util.Collections
*/
class MessageManager(private val state: ChatState) {
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
// Message deduplication for duplicate deliveries from multiple local transports.
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
@ -100,57 +101,63 @@ class MessageManager(private val state: ChatState) {
// MARK: - Private Message Management
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
currentPrivateChats[conversationID] = chatMessages
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(peerID)
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
}
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
if (!currentPrivateChats.containsKey(peerID)) {
currentPrivateChats[peerID] = mutableListOf()
if (!currentPrivateChats.containsKey(conversationID)) {
currentPrivateChats[conversationID] = mutableListOf()
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
currentPrivateChats[conversationID] = chatMessages
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
}
fun clearPrivateMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
updatedChats[conversationID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun initializePrivateChat(peerID: String) {
if (state.getPrivateChatsValue().containsKey(peerID)) return
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (state.getPrivateChatsValue().containsKey(conversationID)) return
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats[peerID] = emptyList()
updatedChats[conversationID] = emptyList()
state.setPrivateChats(updatedChats)
}
fun clearPrivateUnreadMessages(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
updatedUnread.remove(peerID)
updatedUnread.remove(conversationID)
state.setUnreadPrivateMessages(updatedUnread)
}

View File

@ -12,6 +12,7 @@ import androidx.core.app.Person
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.util.NotificationIntervalManager
import java.util.concurrent.ConcurrentHashMap
@ -128,8 +129,8 @@ class NotificationManager(
* Update current private chat peer - affects notification logic
*/
fun setCurrentPrivateChatPeer(peerID: String?) {
currentPrivateChatPeer = peerID
Log.d(TAG, "Current private chat peer changed: $peerID")
currentPrivateChatPeer = peerID?.let { ContactDirectory.canonicalConversationId(it) }
Log.d(TAG, "Current private chat peer changed: $currentPrivateChatPeer")
}
/**
@ -144,28 +145,30 @@ class NotificationManager(
* Show a notification for a private message with proper grouping and state awareness
*/
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
// Only show notifications if app is in background OR user is not viewing this specific chat
val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID)
val shouldNotify = isAppInBackground ||
(!isAppInBackground && currentPrivateChatPeer != conversationID)
if (!shouldNotify) {
Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname")
return
}
Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)")
Log.d(TAG, "Showing notification for message from $senderNickname (conversationID: $conversationID)")
val notification = PendingNotification(
senderPeerID = senderPeerID,
senderPeerID = conversationID,
senderNickname = senderNickname,
messageContent = messageContent,
timestamp = System.currentTimeMillis()
)
// Add to pending notifications for this sender
pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification)
pendingNotifications.computeIfAbsent(conversationID) { mutableListOf() }.add(notification)
// Create or update notification for this sender
showNotificationForSender(senderPeerID)
showNotificationForSender(conversationID)
// Update summary notification if we have multiple senders
if (pendingNotifications.size > 1) {
@ -404,11 +407,15 @@ class NotificationManager(
* Clear notifications for a specific sender (e.g., when user opens their chat)
*/
fun clearNotificationsForSender(senderPeerID: String) {
pendingNotifications.remove(senderPeerID)
// Cancel the individual notification
val notificationId = senderPeerID.hashCode()
notificationManager.cancel(notificationId)
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
val matchingKeys = pendingNotifications.keys.filter { key ->
ContactDirectory.canonicalConversationId(key) == conversationID
}
matchingKeys.forEach { key ->
pendingNotifications.remove(key)
notificationManager.cancel(key.hashCode())
}
notificationManager.cancel(conversationID.hashCode())
// Update or remove summary notification
if (pendingNotifications.isEmpty()) {
@ -421,7 +428,7 @@ class NotificationManager(
showSummaryNotification()
}
Log.d(TAG, "Cleared notifications for sender: $senderPeerID")
Log.d(TAG, "Cleared notifications for conversation: $conversationID")
}
/**

View File

@ -1,10 +1,12 @@
package com.bitchat.android.ui
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.MeshService
import java.security.MessageDigest
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
import android.util.Log
@ -19,9 +21,13 @@ interface NoiseSessionDelegate {
fun getMyPeerID(): String
}
enum class PrivateMessageOrigin {
MESH,
NOSTR
}
/**
* Handles private chat functionality including peer management and blocking
* Now uses centralized PeerFingerprintManager for all fingerprint operations
* Handles private chat functionality including peer management and blocking.
*/
class PrivateChatManager(
private val state: ChatState,
@ -34,7 +40,6 @@ class PrivateChatManager(
private const val TAG = "PrivateChatManager"
}
// Use centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance()
// Track received private messages that need read receipts
@ -43,8 +48,12 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService)
val peerNickname = route.displayName ?: getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
sender = "system",
content = "cannot start chat with $peerNickname: user is blocked.",
@ -55,24 +64,24 @@ class PrivateChatManager(
return false
}
// Establish Noise session if needed before starting the chat
establishNoiseSessionIfNeeded(peerID, meshService)
if (meshPeerID != null && meshService.getPeerInfo(meshPeerID)?.isConnected == true) {
establishNoiseSessionIfNeeded(meshPeerID, meshService)
}
// Consolidate any temporary Nostr conversation for this peer into the stable/current peerID
try {
consolidateNostrTempConversationIfNeeded(peerID)
consolidateNostrTempConversationIfNeeded(conversationID, meshService)
} catch (_: Exception) { }
state.setSelectedPrivateChatPeer(peerID)
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
messageManager.clearPrivateUnreadMessages(peerID)
messageManager.clearPrivateUnreadMessages(conversationID)
// Initialize chat if needed
messageManager.initializePrivateChat(peerID)
messageManager.initializePrivateChat(conversationID)
// Send read receipts for all unread messages from this peer
sendReadReceiptsForPeer(peerID, meshService)
sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
return true
}
@ -89,6 +98,7 @@ class PrivateChatManager(
myPeerID: String,
onSendMessage: (String, String, String, String) -> Unit
): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
if (isPeerBlocked(peerID)) {
val systemMessage = BitchatMessage(
sender = "system",
@ -111,8 +121,8 @@ class PrivateChatManager(
deliveryStatus = DeliveryStatus.Sending
)
messageManager.addPrivateMessage(peerID, message)
onSendMessage(content, peerID, recipientNickname ?: "", message.id)
messageManager.addPrivateMessage(conversationID, message)
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
return true
}
@ -121,20 +131,19 @@ class PrivateChatManager(
fun isPeerBlocked(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: ContactDirectory.resolve(peerID).noisePublicKey?.let { ContactIdentityResolver.fingerprintHex(it) }
return fingerprint != null && dataManager.isUserBlocked(fingerprint)
}
fun toggleFavorite(peerID: String) {
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
if (fingerprint == null && ContactIdentityResolver.isNoiseKeyHex(peerID)) {
try {
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val digest = java.security.MessageDigest.getInstance("SHA-256")
val fpBytes = digest.digest(pubBytes)
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
val pubBytes = ContactIdentityResolver.bytesFromHex(peerID) ?: return
fingerprint = ContactIdentityResolver.fingerprintHex(pubBytes)
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
} catch (e: Exception) {
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
@ -172,10 +181,26 @@ class PrivateChatManager(
fun isFavorite(peerID: String): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
val isFav = dataManager.isFavorite(fingerprint)
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
return isFav
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
?: if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
ContactIdentityResolver.bytesFromHex(peerID)?.let { ContactIdentityResolver.fingerprintHex(it) }
} else {
null
}
if (fingerprint != null && dataManager.isFavorite(fingerprint)) {
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=true")
return true
}
val persistedFavorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isFavorite == true
} catch (_: Exception) {
false
}
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$persistedFavorite")
return persistedFavorite
}
fun getPeerFingerprint(peerID: String): String? {
@ -288,33 +313,44 @@ class PrivateChatManager(
// MARK: - Message Handling
fun handleIncomingPrivateMessage(message: BitchatMessage) {
handleIncomingPrivateMessage(message, suppressUnread = false)
handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.MESH
)
}
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
fun handleIncomingPrivateMessage(
message: BitchatMessage,
suppressUnread: Boolean,
origin: PrivateMessageOrigin = PrivateMessageOrigin.MESH
) {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) {
// Ensure chat exists
messageManager.initializePrivateChat(senderPeerID)
messageManager.initializePrivateChat(conversationID)
// Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here.
if (senderPeerID.startsWith("nostr_")) {
// Mesh messages are already reflected through AppStateStore by the mesh service.
// Nostr messages originate here and must be added explicitly, even after their
// sender alias has canonicalized to a contact_* conversation ID.
if (origin == PrivateMessageOrigin.NOSTR) {
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
messageManager.addPrivateMessageNoUnread(conversationID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
messageManager.addPrivateMessage(conversationID, message)
}
}
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(senderPeerID)
currentUnread.add(conversationID)
state.setUnreadPrivateMessages(currentUnread)
}
}
@ -333,24 +369,39 @@ class PrivateChatManager(
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
fun sendReadReceiptsForPeer(
conversationID: String,
meshPeerID: String?,
meshService: MeshService
) {
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
val messages = chats[canonicalConversationID].orEmpty()
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
Log.d(TAG, "No messages found for conversation $canonicalConversationID to send read receipts")
}
val myNickname = state.getNicknameValue() ?: "unknown"
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
val hasMesh = meshPeerID != null && try {
meshService.getPeerInfo(meshPeerID)?.isConnected == true &&
meshService.hasEstablishedSession(meshPeerID)
} catch (_: Exception) {
false
}
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
if (msg.senderPeerID == peerID) {
val senderPeerID = msg.senderPeerID
val isFromTarget = senderPeerID != null && (
senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
)
if (isFromTarget && meshPeerID != null) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
sentCount += 1
}
} catch (e: Exception) {
@ -360,10 +411,13 @@ class PrivateChatManager(
}
// Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(peerID)
unreadReceivedMessages.remove(canonicalConversationID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
try { messageManager.clearPrivateUnreadMessages(canonicalConversationID) } catch (_: Exception) { }
Log.d(
TAG,
"Sent $sentCount read receipts for conversation $canonicalConversationID via mesh peer $meshPeerID"
)
}
fun cleanupDisconnectedPeer(peerID: String) {
@ -426,65 +480,37 @@ class PrivateChatManager(
// MARK: - Consolidation
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String) {
// If target is a mesh/noise-based peerID, merge any messages from its temp Nostr key
if (targetPeerID.startsWith("nostr_")) return
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String, meshService: MeshService) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
if (ContactIdentityResolver.isNostrAlias(targetPeerID)) return
// Find favorites mapping and corresponding temp key
val tryMergeKeys = mutableListOf<String>()
// If we know the sender's Nostr pubkey for this peer via favorites, derive temp key
try {
val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes)
if (npub != null) {
// Normalize to hex to match how we formed temp keys (nostr_<pub16>)
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") {
val pubHex = data.joinToString("") { "%02x".format(it) }
tryMergeKeys.add("nostr_${pubHex.take(16)}")
}
}
} catch (_: Exception) { }
// Also merge any directly-addressed temp key used by incoming messages (without mapping yet)
// Search existing chats for keys that begin with "nostr_" and have messages from the same nickname
state.getPrivateChatsValue().keys.filter { it.startsWith("nostr_") }.forEach { tempKey ->
if (!tryMergeKeys.contains(tempKey)) tryMergeKeys.add(tempKey)
val noiseKey = when {
ContactIdentityResolver.isNoiseKeyHex(targetPeerID) ->
ContactIdentityResolver.bytesFromHex(targetPeerID)
ContactIdentityResolver.isMeshPeerId(targetPeerID) ->
meshService.getPeerInfo(targetPeerID)?.noisePublicKey
else -> null
}
if (tryMergeKeys.isEmpty()) return
val currentChats = state.getPrivateChatsValue().toMutableMap()
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
var didMerge = false
tryMergeKeys.forEach { tempKey ->
val tempList = currentChats[tempKey]
if (!tempList.isNullOrEmpty()) {
targetList.addAll(tempList)
currentChats.remove(tempKey)
didMerge = true
if (noiseKey != null) {
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
if (!noiseHex.equals(targetPeerID, ignoreCase = true)) {
tryMergeKeys.add(noiseHex)
}
try {
FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { tryMergeKeys.add(it) }
} catch (_: Exception) { }
}
if (didMerge) {
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
// Also remove unread flag from temp keys and apply to target
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
val hadUnread = tryMergeKeys.any { unread.remove(it) }
if (hadUnread) {
unread.add(targetPeerID)
state.setUnreadPrivateMessages(unread)
}
// If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
}
if (tryMergeKeys.isNotEmpty()) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
targetPeerID = targetConversationID,
keysToMerge = tryMergeKeys
)
}
}

View File

@ -5,11 +5,25 @@ package com.bitchat.android.wifiaware
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
*/
internal object AuthenticatedIngressLinkPolicy {
data class Claim(
val relayAddress: String,
val linkID: String
)
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun matches(
expected: Claim?,
authenticatedRelayAddress: String?,
authenticatedLinkID: String?
): Boolean =
expected != null &&
expected.relayAddress == authenticatedRelayAddress &&
expected.linkID == authenticatedLinkID
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,

View File

@ -75,6 +75,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
private const val CLIENT_SOCKET_ATTEMPTS = 3
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
// Discovery freshness window for reconnection maintenance
private const val DISCOVERY_STALE_MS = 5L * 60 * 1000
private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000
@ -126,6 +127,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
>()
private val provisionalWifiClaims =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val authenticatedWifiLinks =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
@ -175,11 +180,31 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Discovery IDs from older clients can be provisional. A verified direct
// announce is enough to start a handshake for the canonical ID, but not to
// rebind the socket. The resulting packet broadcasts when no canonical
// alias exists; only a same-link Noise completion may promote that alias.
// rebind the socket. A fresh challenge is sent through the exact transport
// generation, and only its same-link completion may promote that alias.
val relay = routed.relayAddress
if (routed.packet.ttl == MAX_TTL && relay != null && relay != pid) {
meshCore.initiateNoiseHandshake(pid)
val linkID = routed.ingressLinkID
if (
routed.packet.ttl == MAX_TTL &&
relay != null &&
linkID != null
) {
val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
if (!AuthenticatedIngressLinkPolicy.matches(
authenticatedWifiLinks[pid],
relay,
linkID
)
) {
registerProvisionalWifiClaim(pid, claim)
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
provisionalWifiClaims.remove(pid, claim)
Log.w(
TAG,
"Could not send Noise challenge on exact Wi-Fi link for ${pid.take(8)}"
)
}
}
}
}
},
@ -579,6 +604,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
subscribeHandles.clear()
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
meshCore.shutdown()
@ -623,6 +651,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
subscribeHandles.clear()
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@ -1267,6 +1298,21 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
relayAddress: String,
ingressLinkID: String
) {
val expectedClaim = provisionalWifiClaims[canonicalPeerId]
if (!AuthenticatedIngressLinkPolicy.matches(
expectedClaim,
relayAddress,
ingressLinkID
)
) {
Log.w(
TAG,
"Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}"
)
return
}
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
@ -1280,6 +1326,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
if (existingCanonical == canonicalPeerId) {
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
return
}
@ -1298,6 +1346,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
)
return
}
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
}
@ -1360,6 +1410,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
ingressLinks.remove(ingressLinkID, ingressLink)
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
@ -1367,6 +1418,28 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
socket.close()
}
private fun registerProvisionalWifiClaim(
peerID: String,
claim: AuthenticatedIngressLinkPolicy.Claim
) {
provisionalWifiClaims[peerID] = claim
serviceScope.launch {
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
if (provisionalWifiClaims.remove(peerID, claim)) {
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
}
}
}
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
provisionalWifiClaims.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
authenticatedWifiLinks.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
}
private fun handleNetworkFailure(peerId: String) {
serviceScope.launch {
Log.d(TAG, "Network failure cleanup for: $peerId")
@ -1656,6 +1729,29 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
override fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false
val data = packet.toBinaryData() ?: return false
return try {
link.transport.write(data)
true
} catch (e: IOException) {
Log.e(
TAG,
"TX: exact-link write to ${relayAddress.take(8)} failed: ${e.message}"
)
false
}
}
override fun cancelTransfer(transferId: String): Boolean {
return fragmentingSender.cancelTransfer(transferId)
}

View File

@ -43,6 +43,27 @@ class PacketProcessorAnnounceSideEffectTest {
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
}
@Test
fun `rejected handshake does not update last seen`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = false)
val processor = processor(delegate)
processor.processPacket(handshake())
withTimeout(1_000) { delegate.handshakeHandled.await() }
assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() })
}
@Test
fun `accepted handshake updates last seen`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = true)
val processor = processor(delegate)
processor.processPacket(handshake())
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
}
private fun processor(delegate: RecordingDelegate): PacketProcessor =
PacketProcessor(MY_PEER_ID).also {
it.delegate = delegate
@ -62,10 +83,25 @@ class PacketProcessorAnnounceSideEffectTest {
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private fun handshake(): RoutedPacket {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = PEER_ID.hexToBytes(),
recipientID = MY_PEER_ID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = byteArrayOf(0x01),
ttl = 7u
)
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private class RecordingDelegate(
private val acceptAnnounce: Boolean
private val acceptAnnounce: Boolean,
private val acceptHandshake: Boolean = false
) : PacketProcessorDelegate {
val handled = CompletableDeferred<Unit>()
val handshakeHandled = CompletableDeferred<Unit>()
val lastSeen = CompletableDeferred<String>()
@Volatile var relayCount = 0
@ -76,7 +112,10 @@ class PacketProcessorAnnounceSideEffectTest {
override fun getPeerNickname(peerID: String): String? = null
override fun getNetworkSize() = 1
override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST
override fun handleNoiseHandshake(routed: RoutedPacket) = false
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
handshakeHandled.complete(Unit)
return acceptHandshake
}
override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
handled.complete(Unit)

View File

@ -254,6 +254,28 @@ class SecurityManagerTest {
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
}
@Test
fun `validatePacket rejects signed LEAVE outside replay window`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val stale = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
timestamp = (System.currentTimeMillis() - 5 * 60 * 1_000L - 1).toULong(),
payload = byteArrayOf()
).also { it.signature = validSignature }
val future = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
timestamp = (System.currentTimeMillis() + 5 * 60 * 1_000L + 1_000).toULong(),
payload = byteArrayOf()
).also { it.signature = validSignature }
assertFalse("Captured LEAVE must expire even after replay-cache loss", securityManager.validatePacket(stale, otherPeerID))
assertFalse("Future-dated LEAVE must not extend its replay lifetime", securityManager.validatePacket(future, otherPeerID))
}
@Test
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
val announcement = IdentityAnnouncement(

View File

@ -96,4 +96,45 @@ class AppStateStoreTest {
assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers())
}
@Test
fun `private chat aliases merge into canonical peer`() {
val live = BitchatMessage(id = "live-message", sender = "alice", content = "live", timestamp = Date(1))
val noise = BitchatMessage(id = "noise-message", sender = "alice", content = "noise", timestamp = Date(2))
val nostr = BitchatMessage(id = "nostr-message", sender = "alice", content = "nostr", timestamp = Date(3))
AppStateStore.addPrivateMessage("live-peer", live)
AppStateStore.addPrivateMessage("noise-hex", noise)
AppStateStore.addPrivateMessage("nostr_alias", nostr)
AppStateStore.unifyPrivateChatsIntoPeer("live-peer", listOf("noise-hex", "nostr_alias"))
assertEquals(setOf("live-peer"), AppStateStore.privateMessages.value.keys)
assertEquals(listOf(live, noise, nostr), AppStateStore.privateMessages.value["live-peer"])
}
@Test
fun `noise hex private messages are stored under stable contact conversation`() {
val noiseKeyHex = "00".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32))
val message = BitchatMessage(id = "noise-message", sender = "alice", content = "hello", timestamp = Date(1))
AppStateStore.addPrivateMessage(noiseKeyHex, message)
assertEquals(setOf(contactID), AppStateStore.privateMessages.value.keys)
assertEquals(listOf(message), AppStateStore.privateMessages.value[contactID])
}
@Test
fun `canonicalized private chat history is chronological after alias merge`() {
val noiseKeyHex = "01".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 })
val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3))
val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1))
AppStateStore.addPrivateMessage(contactID, later)
AppStateStore.addPrivateMessage(noiseKeyHex, earlier)
assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID])
}
}

View File

@ -0,0 +1,107 @@
package com.bitchat.android.ui
import android.os.Build
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PeerInfo
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Date
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@OptIn(ExperimentalCoroutinesApi::class)
class PrivateChatManagerTest {
private lateinit var state: ChatState
private lateinit var manager: PrivateChatManager
@Before
fun setUp() {
AppStateStore.clear()
state = ChatState(TestScope())
manager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = DataManager(RuntimeEnvironment.getApplication()),
noiseSessionDelegate = mock()
)
}
@After
fun tearDown() {
AppStateStore.clear()
}
@Test
fun `nostr message is stored after sender alias canonicalizes to contact id`() {
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 7 })
val message = BitchatMessage(
id = "nostr-message",
sender = "alice",
content = "hello over nostr",
timestamp = Date(1),
isPrivate = true,
senderPeerID = conversationID
)
manager.handleIncomingPrivateMessage(
message = message,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID])
}
@Test
fun `canonical conversation sends read receipt through live mesh peer id`() {
val noiseKey = ByteArray(32) { 9 }
val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey)
val message = BitchatMessage(
id = "mesh-message",
sender = "alice",
content = "hello over mesh",
timestamp = Date(1),
isPrivate = true,
senderPeerID = meshPeerID
)
val meshService = mock<MeshService>()
val peerInfo = PeerInfo(
id = meshPeerID,
nickname = "alice",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = null,
isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
state.setNickname("bob")
state.setPrivateChats(mapOf(conversationID to listOf(message)))
whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(peerInfo)
whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true)
manager.sendReadReceiptsForPeer(
conversationID = conversationID,
meshPeerID = meshPeerID,
meshService = meshService
)
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
}

View File

@ -2,9 +2,38 @@ package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedIngressLinkPolicyTest {
@Test
fun `promotion claim must match the challenged relay and link`() {
val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link")
assertTrue(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "different-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
expected = null,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
}
@Test
fun `authentication promotes only the exact ingress link`() {
val attackerSocket = Any()

View File

@ -15,19 +15,22 @@ session remains active until the candidate completes and passes the binding;
failure or mismatch destroys only the candidate. BLE mappings, Wi-Fi socket
rebinds, gossip, sync, and peer-last-seen effects run only after announcement
validation succeeds. A Wi-Fi discovery identity is not destructively rebound
from a self-signed announce. A direct announce may start the canonical
handshake, but the alias is promoted only when the exact, still-active socket
delivers the Noise frame that completes bound authentication. A peer-ID-only
or stale-socket callback cannot authorize that rebind; the final
expected-socket comparison and alias mutation are atomic with socket replacement.
from a self-signed announce. A direct announce creates only a provisional claim
and starts a fresh replacement handshake on that exact transport generation.
The alias is promoted only when the same challenged, still-active socket
delivers the Noise frame that completes bound authentication. A peer-ID-only,
unsolicited, cross-link, or stale-socket callback cannot authorize that rebind;
the expected-socket comparison and alias mutation are atomic with socket
replacement.
Promotion also refuses to displace a different live socket already authenticated
under the canonical peer ID.
Leave packets use the existing signed wire format and are accepted only when
the signature matches the key learned from a verified announcement. Invalid or
unsigned leaves therefore cannot evict the claimed peer or be relayed. A valid
leave removes the peer through the normal peer-manager path, which also clears
its active Noise session.
the signature matches the key learned from a verified announcement and the
timestamp is within the five-minute security window. Invalid, stale, future,
or unsigned leaves therefore cannot evict the claimed peer or be relayed. A
valid leave removes the peer through the normal peer-manager path, which also
clears its active Noise session.
Announcements no longer write fingerprint mappings. Those mappings are created
only by the authenticated Noise-session callback. A known peer's signing key

View File

@ -26,4 +26,6 @@ android.nonTransitiveRClass=false
kotlin.code.style=official
# JVM heap size configuration to prevent OutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true