mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Resolve conflicts and harden link authentication
This commit is contained in:
commit
5dadf40213
@ -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
|
||||
|
||||
|
@ -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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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("-", "+")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)}" }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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?,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user