diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index c89c9978..f83e5ad0 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -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 diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt b/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt new file mode 100644 index 00000000..c75b325d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt @@ -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()}" + } + } +} diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index 12cd3e9f..040acc8f 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -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() // noiseHex -> relationship - // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() @@ -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 = favorites.values.filter { it.isMutual } fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } + fun getAllRelationships(): List = 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>() {}.type val data: Map = 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, diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 462c2725..9a6dff60 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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. } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index a02b8418..375e6531 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index 26a63848..b3f2fcc4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d51fae66..beedd857 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -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", diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index dd775089..0fdae3b0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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 } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 61d68d4c..45e668e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 539c646c..ca49def9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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()) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 394f98bf..9d8ae8af 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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("-", "+") diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt index 3f70bbb7..755d9d34 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 01583dde..7b8552be 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -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) } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 26ba8369..1ce0fc9f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -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() } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 7a935396..c7971eb8 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -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) { + 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 diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt new file mode 100644 index 00000000..5301df6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -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> + ): Map> { + if (chats.isEmpty()) return chats + + val merged = linkedMapOf>() + 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 { it.timestamp.time }.thenBy { it.id }) + } + } + + fun aliasesForConversation(peerOrConversationID: String): Set { + val resolution = resolve(peerOrConversationID) + val aliases = mutableSetOf() + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt b/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt new file mode 100644 index 00000000..1af66393 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt @@ -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)}" } +} diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index d67ff432..e3c2c61d 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -8,28 +8,25 @@ object ConversationAliasResolver { selectedPeerID: String, connectedPeers: List, 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 ) { 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) } } } diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 77949697..652e0c93 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -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_), 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) { 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) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 11ebf499..4eb32739 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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() - 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() { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index f77e146d..8d18563a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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_) 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) */ diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index f2bccdb6..ec137437 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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 = 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 = 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() 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) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 40b7eb98..e93ef39a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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()) private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf()) 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) } diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 9eb72b48..69e84ff5 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -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") } /** diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1ddf8cbf..1af48a04 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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>() } - 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() - - // 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_) - 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 + ) } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt index 353b0e9c..d522c812 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt @@ -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( val relayAddress: String, val transport: T ) + fun matches( + expected: Claim?, + authenticatedRelayAddress: String?, + authenticatedLinkID: String? + ): Boolean = + expected != null && + expected.relayAddress == authenticatedRelayAddress && + expected.linkID == authenticatedLinkID + fun resolve( authenticatedLinkID: String?, authenticatedRelayAddress: String?, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 4b09a1ba..eab371e7 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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 >() + private val provisionalWifiClaims = + ConcurrentHashMap() + private val authenticatedWifiLinks = + ConcurrentHashMap() private val handleToPeerId = ConcurrentHashMap() // discovery mapping private val discoveredTimestamps = ConcurrentHashMap() // 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) } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index c5e42072..df0344cf 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -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() + val handshakeHandled = CompletableDeferred() val lastSeen = CompletableDeferred() @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) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index e912dafd..17156e00 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index eb5fcd71..577823ac 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -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]) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt new file mode 100644 index 00000000..852411e8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -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() + 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") + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt index 9480ea69..1f483543 100644 --- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt @@ -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() diff --git a/docs/NOISE_PEER_ID_BINDING.md b/docs/NOISE_PEER_ID_BINDING.md index 00021097..a6c23a0c 100644 --- a/docs/NOISE_PEER_ID_BINDING.md +++ b/docs/NOISE_PEER_ID_BINDING.md @@ -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 diff --git a/gradle.properties b/gradle.properties index 0838af96..4c4ae04f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true