From 1d99434a25b4f74a902a4fdccd3bdd75df31a7c6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 3 May 2026 06:54:32 +0000 Subject: [PATCH 01/32] Automated update of relay data - Sun May 3 06:54:32 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 561 +++++++++++++-------------- 1 file changed, 279 insertions(+), 282 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index b6ed6cba..25de11a1 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,299 +1,296 @@ Relay URL,Latitude,Longitude -nostr.simplex.icu,50.1109,8.68213 -v-relay.d02.vrtmrz.net,34.6937,135.502 -relay.angor.io,48.1046,11.6002 -nostr.twinkle.lol,51.902,7.6657 -relay.klabo.world,47.2343,-119.853 -cache.trustr.ing,45.4473,-73.7533 -soloco.nl,43.6532,-79.3832 -rusty-uat.siberian-albacore.ts.net:8443,35.6764,139.65 -relay.artx.market,43.6548,-79.3885 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.qstr.app,50.1109,8.68213 -nostr.islandarea.net,35.4669,-97.6473 -relay.homeinhk.xyz,35.694,139.754 -nostr.overmind.lol,43.6532,-79.3832 -no.str.cr,10.074,-84.2155 -relay.nostrcheck.me,43.6532,-79.3832 -wot.makenomistakes.ca,43.7064,-79.3986 -nostr.computingcache.com,34.0356,-118.442 -relay.bebond.net,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -strfry.bitsbytom.com,51.4968,-0.018337 -nostr.2b9t.xyz,34.0549,-118.243 -wot.dergigi.com,64.1476,-21.9392 -offchain.pub,39.1585,-94.5728 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.lab.rytswd.com,49.4543,11.0746 -relay.hook.cafe,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -relay.minibolt.info,43.6532,-79.3832 -nostr.na.social,43.6532,-79.3832 -relay.erybody.com,41.4513,-81.7021 -relay.malxte.de,52.52,13.405 -kasztanowa.bieda.it,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.purplefrog.cloud,35.6916,139.768 -nostr.vulpem.com,49.4543,11.0746 -relay.arx-ccn.com,50.4754,12.3683 -relay.mitchelltribe.com,39.0438,-77.4874 -nostr.bond,50.1109,8.68213 -bitcoiner.social,47.6743,-117.112 -slick.mjex.me,39.0418,-77.4744 -relay.libernet.app,43.6532,-79.3832 -relay.damus.io,43.6532,-79.3832 -strfry.openhoofd.nl,51.9229,4.40833 -relay.agorist.space,52.3734,4.89406 -relay.trotters.cc,43.6532,-79.3832 -relay.illuminodes.com,47.6061,-122.333 -testr.nymble.world,40.8054,-74.0241 -wot.rejecttheframe.xyz,43.6532,-79.3832 -nostr.carroarmato0.be,51.0368,3.21186 -nostr.mikoshi.de,51.2821,6.78285 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -nostr.n7ekb.net,36.1527,-95.9902 -nostr.hekster.org,37.3986,-121.964 -nostr.faultables.net,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -nostr.easycryptosend.it,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -relay.tagayasu.xyz,43.6715,-79.38 -r.0kb.io,32.789,-96.7989 -herbstmeister.com,34.0549,-118.243 -dev.relay.stream,43.6532,-79.3832 -relay-freeharmonypeople.space,38.7223,-9.13934 -r.bitcoinhold.net,43.6532,-79.3832 -nostr.aruku.kro.kr,37.3589,127.115 -relay.nostriches.club,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -nostr.tagomago.me,3.139,101.687 -nostr.plantroon.com,50.1013,8.62643 -relay.nostrhub.fr,48.1045,11.6004 -relay.fundstr.me,42.3601,-71.0589 -ephemeral.snowflare.cc,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.inforsupports.com,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -seed-options-few-cache.trycloudflare.com,43.6532,-79.3832 -relay.nostrzh.org,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -relay.decentnewsroom.com,50.4754,12.3683 +spookstr2.nostr1.com,40.7057,-74.0136 +espelho.girino.org,43.6532,-79.3832 myvoiceourstory.org,37.3598,-121.981 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.hostr.network,41.2619,-95.8608 -nostr.wecsats.io,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -rilo.nostria.app,43.6532,-79.3832 -nostr-01.yakihonne.com,1.32123,103.695 -nostr.notribe.net,40.8302,-74.1299 -relay.mccormick.cx,52.3563,4.95714 -relay.openfarmtools.org,60.1699,24.9384 -yabu.me,35.6092,139.73 -nostr.girino.org,43.6532,-79.3832 -relay.agora.social,50.7383,15.0648 -wot.nostr.place,32.7767,-96.797 -nostr.tac.lol,47.4748,-122.273 -nostr.spacecitynode.com,29.7057,-95.2706 -relay.sigit.io,50.4754,12.3683 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr-relay.amethyst.name,39.0067,-77.4291 -relay.dreamith.to,43.6532,-79.3832 -relay.sloannetworks.com,37.5234,-77.3158 -nostr.zoracle.org,45.6018,-121.185 -ribo.us.nostria.app,43.6532,-79.3832 -us-east.nostr.pikachat.org,39.0438,-77.4874 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.wellorder.net,45.5201,-122.99 -speakeasy.cellar.social,49.4543,11.0746 -relay.notoshi.win,13.3622,100.983 -relay.nostrian-conquest.com,41.223,-111.974 -relay.astrolabe.finance,39.0438,-77.4874 -rele.speyhard.fi,50.1109,8.68213 -eu.nostr.pikachat.org,49.4543,11.0746 -bridge.tagomago.me,3.139,101.687 -aeon.libretechsystems.xyz,55.486,9.86577 -relay.samt.st,40.8302,-74.1299 -nostrbtc.com,43.6532,-79.3832 +0x-nostr-relay.fly.dev,37.7648,-122.432 +relay.cypherflow.ai,48.8575,2.35138 +relay2.angor.io,48.1046,11.6002 +fanfares.nostr1.com,40.7057,-74.0136 +relay.trotters.cc,43.6532,-79.3832 +relay.erybody.com,41.4513,-81.7021 +soloco.nl,43.6532,-79.3832 +relay.snotr.nl:49999,52.0195,4.42946 +nostr.vulpem.com,49.4543,11.0746 +nostr.rtvslawenia.com,49.4543,11.0746 +nostr.n7ekb.net,36.1527,-95.9902 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +relay.ohstr.com,43.6532,-79.3832 +relay.directsponsor.net,42.8864,-78.8784 +inbox.mycelium.social,38.627,-90.1994 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.getsafebox.app,43.6532,-79.3832 relay.henryxplace.eu.org:9988,31.2304,121.474 -relay.islandbitcoin.com,12.8498,77.6545 -testrelay.era21.space,43.6532,-79.3832 -sandbox.registros.aarpia.net,50.1109,8.68213 -relay.flashapp.me,43.6548,-79.3885 +nexus.libernet.app,43.6532,-79.3832 +nos.xmark.cc,50.6924,3.20113 +relay.shadowbip.com,50.1109,8.68213 +nostr.88mph.life,52.1941,-2.21905 +freelay.sovbit.dev,60.1699,24.9384 +social.amanah.eblessing.co,48.1046,11.6002 +nostr.2b9t.xyz,34.0549,-118.243 +nostr.liberty.fans,36.9104,-89.5875 +relay.lightning.pub,39.0438,-77.4874 +relay.openresist.com,43.6532,-79.3832 +nostr.computingcache.com,34.0356,-118.442 +relay.devcsu.fr,48.8575,2.35138 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.ngengine.org,43.6532,-79.3832 librerelay.aaroniumii.com,43.6532,-79.3832 -premium.primal.net,43.6532,-79.3832 -relay.gulugulu.moe,43.6532,-79.3832 -nostr.thalheim.io,60.1699,24.9384 +relay.fountain.fm,43.6532,-79.3832 +relay.cosmicbolt.net,37.3986,-121.964 +speakeasy.cellar.social,49.4543,11.0746 +relay.nostx.io,43.6532,-79.3832 +relay.bao.network,43.6532,-79.3832 +offchain.pub,39.1585,-94.5728 +relay.angor.io,48.1046,11.6002 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.nostriches.club,43.6532,-79.3832 +nostrja-kari.heguro.com,43.6532,-79.3832 +relay.beginningend.com,35.2227,-97.4786 +nittom.nostr1.com,40.7057,-74.0136 +ms.chinacounty.com,39.9042,116.407 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.mitchelltribe.com,39.0438,-77.4874 +relay-freeharmonypeople.space,41.1542,-8.60964 +nostr.carroarmato0.be,51.0368,3.21186 +relay.mmwaves.de,48.8575,2.35138 +strfry.apps3.slidestr.net,40.4167,-3.70329 +librepress.libretechsystems.xyz,55.4724,9.87335 +wot.codingarena.top,50.4754,12.3683 +relay.staging.commonshub.brussels,49.4543,11.0746 +relay.ditto.pub,43.6532,-79.3832 +wot.sebastix.social,51.1792,5.89444 +inbox.scuba323.com,40.8218,-74.45 +ephemeral.snowflare.cc,43.6532,-79.3832 +nostrsgp.notribe.net,1.32123,103.695 +relay.npubhaus.com,43.6532,-79.3832 +armada.sharegap.net,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +eu.nostr.pikachat.org,49.4543,11.0746 relay.bnos.space,43.6532,-79.3832 -adre.su,59.9311,30.3609 -relay.veganostr.com,60.1699,24.9384 -relay.plebeian.market,50.1109,8.68213 +relay.bullishbounty.com,43.6532,-79.3832 +relay.satlantis.io,40.8054,-74.0241 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.nostrhub.fr,48.1045,11.6004 +relay.nostrmap.net,60.1699,24.9384 +nostr.snowbla.de,60.1699,24.9384 +relay.zone667.com,60.1699,24.9384 +wot.nostr.party,36.1659,-86.7844 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +relayrs.notoshi.win,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +nostr.ps1829.com,33.8851,130.883 +rilo.nostria.app,43.6532,-79.3832 +relay.astrolabe.finance,39.0438,-77.4874 +strfry.bonsai.com,39.0438,-77.4874 +nostr.bgbitcoin.club,50.4754,12.3683 +ynostr.yael.at,60.1699,24.9384 +wot.shaving.kiwi,43.6532,-79.3832 +relay.arx-ccn.com,50.4754,12.3683 +relay.degmods.com,50.4754,12.3683 +testrelay.era21.space,43.6532,-79.3832 relay.mostro.network,40.8302,-74.1299 +nostr.azzamo.net,52.2633,21.0283 +relay.nearhood.co.uk,51.5072,-0.127586 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-2.21crypto.ch,47.5356,8.73209 +shu01.shugur.net,21.4902,39.2246 +relay2.ngengine.org,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +relay.satnam.pub,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +relay.binaryrobot.com,43.6532,-79.3832 +relay.typedcypher.com,51.5072,-0.127586 +relay.mostr.pub,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +nostr.spicyz.io,43.6532,-79.3832 +relay.flashapp.me,43.6548,-79.3885 +nos.lol,50.4754,12.3683 +relay.plebeian.market,50.1109,8.68213 +r.0kb.io,32.789,-96.7989 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.robosats.org,64.1476,-21.9392 +wot.makenomistakes.ca,43.7064,-79.3986 +nostrride.io,37.3986,-121.964 +relay.staging.plebeian.market,51.5072,-0.127586 +r.bitcoinhold.net,43.6532,-79.3832 +cs-relay.nostrdev.com,50.4754,12.3683 relayone.soundhsa.com,39.1008,-94.5811 -relay.threenine.services,51.5222,-0.62916 -nostr.blankfors.se,60.1699,24.9384 +relay.klabo.world,47.2343,-119.853 +relay.mccormick.cx,52.3563,4.95714 +bitcoinostr.duckdns.org,41.1976,1.11167 +testnet-relay.samt.st,40.8302,-74.1299 +syb.lol,43.6532,-79.3832 +testr.nymble.world,40.8054,-74.0241 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.dpinkerton.com,-27.4971,152.976 +nostr.bond,50.1109,8.68213 +relay.ru.ac.th,13.7607,100.627 +relay.illuminodes.com,47.6061,-122.333 +relay.gulugulu.moe,43.6532,-79.3832 +relay.nostr-check.me,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 nostr.nodesmap.com,59.3327,18.0656 nostriches.club,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -relay.paulstephenborile.com,49.4543,11.0746 -nostr.dlcdevkit.com,40.0992,-83.1141 -vault.iris.to,43.6532,-79.3832 -test.thedude.cloud,50.1109,8.68213 -dev-relay.nostreon.com,60.1699,24.9384 -relay.nostriot.com,41.5695,-83.9786 -relay-dev.gulugulu.moe,43.6532,-79.3832 -freelay.sovbit.dev,60.1699,24.9384 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.bikel.ink,60.1699,24.9384 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.zone667.com,60.1699,24.9384 relay.olas.app,60.1699,24.9384 -relay.degmods.com,50.4754,12.3683 -nostr.spicyz.io,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -purpura.cloud,43.6532,-79.3832 -ribo.nostria.app,43.6532,-79.3832 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.azzamo.net,52.2633,21.0283 -relay.keykeeper.world,40.7824,-74.0711 -nostrelay.circum.space,52.3676,4.90414 -nostrcheck.me,43.6532,-79.3832 -relay.snotr.nl:49999,52.0195,4.42946 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.nostrverse.net,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -blossom.gnostr.cloud,43.6532,-79.3832 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -relay.nostr-check.me,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -nittom.nostr1.com,40.7057,-74.0136 -nostr.88mph.life,52.1941,-2.21905 -nostrride.io,37.3986,-121.964 -nostr-relay.corb.net,38.8353,-104.822 -relay.fizx.uk,51.9194,19.1451 -ynostr.yael.at,60.1699,24.9384 -relay.liberbitworld.org,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.shadowbip.com,50.1109,8.68213 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.openresist.com,43.6532,-79.3832 -relay.typedcypher.com,51.5072,-0.127586 bendernostur.duckdns.org:8443,50.1109,8.68213 -relay.bitmacro.cloud,43.6532,-79.3832 -relay.nostrdice.com,-33.8688,151.209 -relay02.lnfi.network,35.6764,139.65 -nostr-relay.nextblockvending.com,47.2343,-119.853 -x.kojira.io,43.6532,-79.3832 -nostr-dev.wellorder.net,45.5201,-122.99 -relay.ohstr.com,43.6532,-79.3832 -wot.nostr.party,36.1627,-86.7816 -spookstr2.nostr1.com,40.7057,-74.0136 -wot.dtonon.com,43.6532,-79.3832 -relay-nl.zombi.cloudrodion.com,50.8943,6.06237 -artisanspyramid.libretechsystems.xyz,55.486,9.86577 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -relay.satlantis.io,40.8054,-74.0241 -nostrsgp.notribe.net,1.32123,103.695 -nostr.quali.chat,60.1699,24.9384 -bitchat.nostr1.com,40.7057,-74.0136 -nostr.sathoarder.com,48.5734,7.75211 -relay.cosmicbolt.net,37.3986,-121.964 -nostr.mom,50.4754,12.3683 +vault.iris.to,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 nostr.oxtr.dev,50.4754,12.3683 -strfry.bonsai.com,37.8716,-122.273 -relay.getsafebox.app,43.6532,-79.3832 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay.orangepill.ovh,49.1689,-0.358841 -relay.mostr.pub,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.0xchat.com,43.6532,-79.3832 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.saturnali.net,46.2044,6.14316 -strfry.shock.network,39.0438,-77.4874 -fanfares.nostr1.com,40.7057,-74.0136 -nostrcity-club.fly.dev,48.8566,2.35222 -nostr.ps1829.com,33.8851,130.883 -relay.bao.network,43.6532,-79.3832 -relay.jeffg.fyi,43.6532,-79.3832 -video.czas.plus,50.1109,8.68213 -librepress.libretechsystems.xyz,55.4724,9.87335 -relay.laantungir.net,-19.4692,-42.5315 -relay5.bitransfer.org,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.fountain.fm,43.6532,-79.3832 -relay.wisp.talk,49.4543,11.0746 +relay-dev.gulugulu.moe,43.6532,-79.3832 nostr-relay.zeabur.app,25.0797,121.234 -relay.nostrmap.net,60.1699,24.9384 -relay01.lnfi.network,35.6764,139.65 -relay.satnam.pub,43.6532,-79.3832 -0x-nostr-relay.fly.dev,48.8566,2.35222 -relay.beginningend.com,35.2227,-97.4786 -cs-relay.nostrdev.com,50.4754,12.3683 -relay.cypherflow.ai,48.8575,2.35138 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.ru.ac.th,13.7607,100.627 -nostr.data.haus,50.4754,12.3683 -bucket.coracle.social,37.7775,-122.397 -nostr.bitcoiner.social,47.6743,-117.112 -relay.gorrdy.cz,43.6532,-79.3832 -relay.sovbit.dev,60.1699,24.9384 -wot.codingarena.top,50.4754,12.3683 -relay.directsponsor.net,42.8864,-78.8784 -nostr2.thalheim.io,49.4543,11.0746 -nostr.pbfs.io,50.4754,12.3683 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.bitczat.pl,60.1699,24.9384 -nostr.rtvslawenia.com,49.4543,11.0746 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.lightning.pub,39.0438,-77.4874 -nostr.dpinkerton.com,49.2509,-123.01 -nostr.myshosholoza.co.za,52.3913,4.66545 -relay.solife.me,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -chat-relay.zap-work.com,43.6532,-79.3832 -reraw.pbla2fish.cc,43.6532,-79.3832 -nostr.chaima.info,50.1109,8.68213 -nostr.snowbla.de,60.1699,24.9384 -relay.vrtmrz.net,43.6532,-79.3832 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay2.angor.io,48.1046,11.6002 -nexus.libernet.app,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -inbox.mycelium.social,38.627,-90.1994 -relay.trustr.ing,49.8667,-125.133 -relay1.nostrchat.io,60.1699,24.9384 -dev.relay.edufeed.org,49.4521,11.0767 -satsage.xyz,37.3986,-121.964 -fenrir-s.notoshi.win,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -nostr.thebiglake.org,32.71,-96.6745 -nostr.defucc.me,50.1109,8.68213 -relay.lanavault.space,60.1699,24.9384 -relay.credenso.cafe,43.3601,-80.3127 -relay.seq1.net,43.6532,-79.3832 -social.amanah.eblessing.co,48.1046,11.6002 -relay.devcsu.fr,48.8575,2.35138 -dev-nostr.bityacht.io,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.endfiat.money,59.3327,18.0656 -relay.tapestry.ninja,40.8054,-74.0241 -relay.wavlake.com,41.2619,-95.8608 +relay.wisp.talk,49.4543,11.0746 +nostr.mom,50.4754,12.3683 +relay.edufeed.org,49.4521,11.0767 +nostr.tac.lol,47.4748,-122.273 +relay.inforsupports.com,43.6532,-79.3832 +cache.trustr.ing,43.6548,-79.3885 +kasztanowa.bieda.it,43.6532,-79.3832 +wot.dergigi.com,64.1476,-21.9392 relay.wavefunc.live,41.8781,-87.6298 -nostr.self-determined.de,53.495,10.2542 +ribo.us.nostria.app,43.6532,-79.3832 +relay.bikel.ink,60.1699,24.9384 +relay.islandbitcoin.com,12.8498,77.6545 +no.str.cr,9.27447,-83.5797 +satsage.xyz,37.3986,-121.964 +ribo.nostria.app,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +nostr.night7.space,50.4754,12.3683 +nostr.dlcdevkit.com,40.0992,-83.1141 +nostr.wecsats.io,43.6532,-79.3832 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.dreamith.to,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +relay.nostrzh.org,43.6532,-79.3832 +relay.malxte.de,52.52,13.405 +nostr.chaima.info,50.1109,8.68213 +relay.nostrdice.com,-33.8688,151.209 +relay.0xchat.com,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +nostrelites.org,41.8781,-87.6298 +nostr.bitcoiner.social,47.6743,-117.112 +premium.primal.net,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay.wellorder.net,45.5201,-122.99 kotukonostr.onrender.com,37.7775,-122.397 -relay.mmwaves.de,48.8575,2.35138 -syb.lol,34.0549,-118.243 +relay.bornheimer.app,51.5072,-0.127586 +srtrelay.c-stellar.net,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +relay.dyne.org,49.0291,8.35705 +adre.su,59.9311,30.3609 +nostr.tadryanom.me,43.6532,-79.3832 +relay-rpi.edufeed.org,49.4521,11.0767 +bitcoiner.social,47.6743,-117.112 +nostr-relay.xbytez.io,50.6924,3.20113 +strfry.bitsbytom.com,51.4968,-0.018337 +nostr-relay.amethyst.name,39.0438,-77.4874 +nostr.girino.org,43.6532,-79.3832 +nos4smartnkind.tech,40.2662,44.6393 +nostrbtc.com,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +relay.sigit.io,50.4754,12.3683 +relay.samt.st,40.8302,-74.1299 +relay.layer.systems,49.0291,8.35695 +relay.agora.social,50.7383,15.0648 +shu05.shugur.net,48.8575,2.35138 +relay.libernet.app,43.6532,-79.3832 +garden.zap.cooking,40.8302,-74.1299 +relay.minibolt.info,43.6532,-79.3832 +additions-rev-rainbow-task.trycloudflare.com,43.6532,-79.3832 +relay.bebond.net,43.6532,-79.3832 +bitchat.nostr1.com,40.7057,-74.0136 +relay.internationalright-wing.org,-22.5022,-48.7114 +relay.tapestry.ninja,40.8054,-74.0241 +nostrelay.circum.space,52.3676,4.90414 +wot.nostr.place,43.6532,-79.3832 +video.czas.plus,50.1109,8.68213 +relay.bitmacro.cloud,43.6532,-79.3832 +herbstmeister.com,34.0549,-118.243 +articles.layer3.news,37.3387,-121.885 +relay.primal.net,43.6532,-79.3832 +aeon.libretechsystems.xyz,55.486,9.86577 +relay.orangepill.ovh,49.1689,-0.358841 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +relay.nostrverse.net,43.6532,-79.3832 +nostr.rblb.it,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 +nostr.data.haus,50.4754,12.3683 +nostr-pub.wellorder.net,45.5201,-122.99 +relay.mwaters.net,50.9871,2.12554 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.trustr.ing,43.6548,-79.3885 +relay.threenine.services,51.5222,-0.62916 +relay02.lnfi.network,35.6764,139.65 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.liberbitworld.org,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +relay.mynostr.app,40.788,-74.0546 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.fckstate.net,59.3293,18.0686 +x.kojira.io,43.6532,-79.3832 +chat-relay.zap-work.com,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.nostr.net,43.6532,-79.3832 +relay.nostu.be,40.4167,-3.70329 +nostr.blankfors.se,60.1699,24.9384 +test.thedude.cloud,50.1109,8.68213 +relay.paulstephenborile.com,49.4543,11.0746 prl.plus,55.7628,37.5983 +relay.credenso.cafe,43.3601,-80.3127 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.sovbit.dev,60.1699,24.9384 +relay.agorist.space,52.3734,4.89406 +nostr.myshosholoza.co.za,52.3913,4.66545 +relay.nostrcheck.me,43.6532,-79.3832 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +public.crostr.com,43.6532,-79.3832 +dev-relay.nostreon.com,60.1699,24.9384 +relay.sharegap.net,43.6532,-79.3832 +dev.relay.edufeed.org,49.4521,11.0767 +relay.qstr.app,50.1109,8.68213 +relay.lanavault.space,60.1699,24.9384 +temp.iris.to,43.6532,-79.3832 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr-01.yakihonne.com,1.32123,103.695 +rele.speyhard.fi,50.1109,8.68213 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.nostr.place,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +relay.laantungir.net,-19.4692,-42.5315 +slick.mjex.me,39.0418,-77.4744 +strfry.openhoofd.nl,51.9229,4.40833 +relay.lacompagniemaximus.com,45.3147,-73.8785 +nostr-relay.corb.net,38.8353,-104.822 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +nostr.plantroon.com,50.1013,8.62643 +strfry.ymir.cloud,43.6532,-79.3832 +nostr.bitczat.pl,60.1699,24.9384 +nostr.thalheim.io,60.1699,24.9384 +nostrcheck.me,43.6532,-79.3832 +relay.jeffg.fyi,43.6532,-79.3832 +relay.hostr.network,41.2619,-95.8608 +top.testrelay.top,43.6532,-79.3832 nostr.stakey.net,52.3676,4.90414 +yabu.me,35.6092,139.73 +nostr.hekster.org,37.3986,-121.964 +relay.seq1.net,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.defucc.me,51.5072,-0.127586 +nostr.notribe.net,40.8302,-74.1299 +relay-nl.zombi.cloudrodion.com,50.8943,6.06237 +relay.gorrdy.cz,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +nostr.self-determined.de,53.495,10.2542 +rusty-uat.siberian-albacore.ts.net:8443,35.6764,139.65 +orly-relay.imwald.eu,48.8575,2.35138 +relay.hook.cafe,43.6532,-79.3832 +relay.decentnewsroom.com,50.4754,12.3683 +us-east.nostr.pikachat.org,39.0438,-77.4874 +nostr.twinkle.lol,51.902,7.6657 From 8ddf03d139c48b8887494fd440db6d85f80a1600 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 10 May 2026 06:58:05 +0000 Subject: [PATCH 02/32] Automated update of relay data - Sun May 10 06:58:05 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 563 +++++++++++++-------------- 1 file changed, 277 insertions(+), 286 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 25de11a1..4ba9acdd 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,296 +1,287 @@ Relay URL,Latitude,Longitude spookstr2.nostr1.com,40.7057,-74.0136 -espelho.girino.org,43.6532,-79.3832 -myvoiceourstory.org,37.3598,-121.981 -0x-nostr-relay.fly.dev,37.7648,-122.432 -relay.cypherflow.ai,48.8575,2.35138 -relay2.angor.io,48.1046,11.6002 -fanfares.nostr1.com,40.7057,-74.0136 -relay.trotters.cc,43.6532,-79.3832 -relay.erybody.com,41.4513,-81.7021 -soloco.nl,43.6532,-79.3832 -relay.snotr.nl:49999,52.0195,4.42946 -nostr.vulpem.com,49.4543,11.0746 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.n7ekb.net,36.1527,-95.9902 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -inbox.mycelium.social,38.627,-90.1994 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.getsafebox.app,43.6532,-79.3832 -relay.henryxplace.eu.org:9988,31.2304,121.474 -nexus.libernet.app,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -relay.shadowbip.com,50.1109,8.68213 -nostr.88mph.life,52.1941,-2.21905 -freelay.sovbit.dev,60.1699,24.9384 -social.amanah.eblessing.co,48.1046,11.6002 -nostr.2b9t.xyz,34.0549,-118.243 -nostr.liberty.fans,36.9104,-89.5875 -relay.lightning.pub,39.0438,-77.4874 -relay.openresist.com,43.6532,-79.3832 -nostr.computingcache.com,34.0356,-118.442 -relay.devcsu.fr,48.8575,2.35138 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.ngengine.org,43.6532,-79.3832 -librerelay.aaroniumii.com,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -relay.cosmicbolt.net,37.3986,-121.964 -speakeasy.cellar.social,49.4543,11.0746 -relay.nostx.io,43.6532,-79.3832 -relay.bao.network,43.6532,-79.3832 -offchain.pub,39.1585,-94.5728 -relay.angor.io,48.1046,11.6002 -nostr-dev.wellorder.net,45.5201,-122.99 -relay.nostriches.club,43.6532,-79.3832 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.beginningend.com,35.2227,-97.4786 -nittom.nostr1.com,40.7057,-74.0136 -ms.chinacounty.com,39.9042,116.407 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.mitchelltribe.com,39.0438,-77.4874 -relay-freeharmonypeople.space,41.1542,-8.60964 -nostr.carroarmato0.be,51.0368,3.21186 -relay.mmwaves.de,48.8575,2.35138 -strfry.apps3.slidestr.net,40.4167,-3.70329 -librepress.libretechsystems.xyz,55.4724,9.87335 -wot.codingarena.top,50.4754,12.3683 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay.ditto.pub,43.6532,-79.3832 -wot.sebastix.social,51.1792,5.89444 -inbox.scuba323.com,40.8218,-74.45 -ephemeral.snowflare.cc,43.6532,-79.3832 -nostrsgp.notribe.net,1.32123,103.695 -relay.npubhaus.com,43.6532,-79.3832 -armada.sharegap.net,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -eu.nostr.pikachat.org,49.4543,11.0746 -relay.bnos.space,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -relay.satlantis.io,40.8054,-74.0241 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.nostrhub.fr,48.1045,11.6004 -relay.nostrmap.net,60.1699,24.9384 -nostr.snowbla.de,60.1699,24.9384 -relay.zone667.com,60.1699,24.9384 -wot.nostr.party,36.1659,-86.7844 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -nostr.ps1829.com,33.8851,130.883 -rilo.nostria.app,43.6532,-79.3832 -relay.astrolabe.finance,39.0438,-77.4874 -strfry.bonsai.com,39.0438,-77.4874 -nostr.bgbitcoin.club,50.4754,12.3683 -ynostr.yael.at,60.1699,24.9384 -wot.shaving.kiwi,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -relay.degmods.com,50.4754,12.3683 -testrelay.era21.space,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -nostr.azzamo.net,52.2633,21.0283 -relay.nearhood.co.uk,51.5072,-0.127586 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-2.21crypto.ch,47.5356,8.73209 -shu01.shugur.net,21.4902,39.2246 -relay2.ngengine.org,43.6532,-79.3832 -strfry.shock.network,39.0438,-77.4874 -relay.satnam.pub,43.6532,-79.3832 -nostr.sathoarder.com,48.5734,7.75211 -relay.binaryrobot.com,43.6532,-79.3832 -relay.typedcypher.com,51.5072,-0.127586 -relay.mostr.pub,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -nostr.spicyz.io,43.6532,-79.3832 -relay.flashapp.me,43.6548,-79.3885 -nos.lol,50.4754,12.3683 -relay.plebeian.market,50.1109,8.68213 -r.0kb.io,32.789,-96.7989 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr.robosats.org,64.1476,-21.9392 -wot.makenomistakes.ca,43.7064,-79.3986 -nostrride.io,37.3986,-121.964 -relay.staging.plebeian.market,51.5072,-0.127586 -r.bitcoinhold.net,43.6532,-79.3832 -cs-relay.nostrdev.com,50.4754,12.3683 -relayone.soundhsa.com,39.1008,-94.5811 -relay.klabo.world,47.2343,-119.853 -relay.mccormick.cx,52.3563,4.95714 -bitcoinostr.duckdns.org,41.1976,1.11167 -testnet-relay.samt.st,40.8302,-74.1299 -syb.lol,43.6532,-79.3832 -testr.nymble.world,40.8054,-74.0241 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.dpinkerton.com,-27.4971,152.976 -nostr.bond,50.1109,8.68213 -relay.ru.ac.th,13.7607,100.627 -relay.illuminodes.com,47.6061,-122.333 -relay.gulugulu.moe,43.6532,-79.3832 -relay.nostr-check.me,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -nostr.nodesmap.com,59.3327,18.0656 -nostriches.club,43.6532,-79.3832 -relay.olas.app,60.1699,24.9384 -bendernostur.duckdns.org:8443,50.1109,8.68213 -vault.iris.to,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -relay-dev.gulugulu.moe,43.6532,-79.3832 -nostr-relay.zeabur.app,25.0797,121.234 -relay.wisp.talk,49.4543,11.0746 -nostr.mom,50.4754,12.3683 -relay.edufeed.org,49.4521,11.0767 -nostr.tac.lol,47.4748,-122.273 -relay.inforsupports.com,43.6532,-79.3832 -cache.trustr.ing,43.6548,-79.3885 -kasztanowa.bieda.it,43.6532,-79.3832 -wot.dergigi.com,64.1476,-21.9392 -relay.wavefunc.live,41.8781,-87.6298 -ribo.us.nostria.app,43.6532,-79.3832 -relay.bikel.ink,60.1699,24.9384 -relay.islandbitcoin.com,12.8498,77.6545 -no.str.cr,9.27447,-83.5797 -satsage.xyz,37.3986,-121.964 -ribo.nostria.app,43.6532,-79.3832 -relay.endfiat.money,59.3327,18.0656 -nostr.night7.space,50.4754,12.3683 -nostr.dlcdevkit.com,40.0992,-83.1141 -nostr.wecsats.io,43.6532,-79.3832 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 relay.dreamith.to,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -relay.nostrzh.org,43.6532,-79.3832 -relay.malxte.de,52.52,13.405 -nostr.chaima.info,50.1109,8.68213 -relay.nostrdice.com,-33.8688,151.209 -relay.0xchat.com,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -nostrelites.org,41.8781,-87.6298 -nostr.bitcoiner.social,47.6743,-117.112 -premium.primal.net,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -relay.wellorder.net,45.5201,-122.99 -kotukonostr.onrender.com,37.7775,-122.397 -relay.bornheimer.app,51.5072,-0.127586 -srtrelay.c-stellar.net,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -relay.dyne.org,49.0291,8.35705 -adre.su,59.9311,30.3609 -nostr.tadryanom.me,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 -bitcoiner.social,47.6743,-117.112 -nostr-relay.xbytez.io,50.6924,3.20113 -strfry.bitsbytom.com,51.4968,-0.018337 -nostr-relay.amethyst.name,39.0438,-77.4874 -nostr.girino.org,43.6532,-79.3832 -nos4smartnkind.tech,40.2662,44.6393 -nostrbtc.com,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 +relay.nostriot.com,41.5695,-83.9786 relay.sigit.io,50.4754,12.3683 -relay.samt.st,40.8302,-74.1299 -relay.layer.systems,49.0291,8.35695 -relay.agora.social,50.7383,15.0648 -shu05.shugur.net,48.8575,2.35138 -relay.libernet.app,43.6532,-79.3832 -garden.zap.cooking,40.8302,-74.1299 -relay.minibolt.info,43.6532,-79.3832 -additions-rev-rainbow-task.trycloudflare.com,43.6532,-79.3832 -relay.bebond.net,43.6532,-79.3832 -bitchat.nostr1.com,40.7057,-74.0136 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.tapestry.ninja,40.8054,-74.0241 -nostrelay.circum.space,52.3676,4.90414 -wot.nostr.place,43.6532,-79.3832 -video.czas.plus,50.1109,8.68213 -relay.bitmacro.cloud,43.6532,-79.3832 -herbstmeister.com,34.0549,-118.243 -articles.layer3.news,37.3387,-121.885 -relay.primal.net,43.6532,-79.3832 -aeon.libretechsystems.xyz,55.486,9.86577 -relay.orangepill.ovh,49.1689,-0.358841 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.nostrverse.net,43.6532,-79.3832 -nostr.rblb.it,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -nostr.data.haus,50.4754,12.3683 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.mwaters.net,50.9871,2.12554 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.trustr.ing,43.6548,-79.3885 -relay.threenine.services,51.5222,-0.62916 -relay02.lnfi.network,35.6764,139.65 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.liberbitworld.org,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.mynostr.app,40.788,-74.0546 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.fckstate.net,59.3293,18.0686 -x.kojira.io,43.6532,-79.3832 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -relay.nostr.net,43.6532,-79.3832 -relay.nostu.be,40.4167,-3.70329 -nostr.blankfors.se,60.1699,24.9384 -test.thedude.cloud,50.1109,8.68213 -relay.paulstephenborile.com,49.4543,11.0746 -prl.plus,55.7628,37.5983 -relay.credenso.cafe,43.3601,-80.3127 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -relay.sovbit.dev,60.1699,24.9384 -relay.agorist.space,52.3734,4.89406 -nostr.myshosholoza.co.za,52.3913,4.66545 -relay.nostrcheck.me,43.6532,-79.3832 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.bitcoiner.social,47.6743,-117.112 +relay.keykeeper.world,40.7824,-74.0711 +dev.relay.stream,43.6532,-79.3832 public.crostr.com,43.6532,-79.3832 -dev-relay.nostreon.com,60.1699,24.9384 -relay.sharegap.net,43.6532,-79.3832 -dev.relay.edufeed.org,49.4521,11.0767 -relay.qstr.app,50.1109,8.68213 -relay.lanavault.space,60.1699,24.9384 -temp.iris.to,43.6532,-79.3832 -relay.lanacoin-eternity.com,40.8302,-74.1299 -nostr-01.yakihonne.com,1.32123,103.695 -rele.speyhard.fi,50.1109,8.68213 -blossom.gnostr.cloud,43.6532,-79.3832 -relay.nostr.place,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -relay.laantungir.net,-19.4692,-42.5315 -slick.mjex.me,39.0418,-77.4744 -strfry.openhoofd.nl,51.9229,4.40833 -relay.lacompagniemaximus.com,45.3147,-73.8785 -nostr-relay.corb.net,38.8353,-104.822 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -nostr.plantroon.com,50.1013,8.62643 -strfry.ymir.cloud,43.6532,-79.3832 -nostr.bitczat.pl,60.1699,24.9384 -nostr.thalheim.io,60.1699,24.9384 -nostrcheck.me,43.6532,-79.3832 -relay.jeffg.fyi,43.6532,-79.3832 -relay.hostr.network,41.2619,-95.8608 +relay.bikel.ink,60.1699,24.9384 +ephemeral.snowflare.cc,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay.mwaters.net,50.9871,2.12554 +premium.primal.net,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +relay.mmwaves.de,48.8575,2.35138 +relay1.privkey.io,25.7617,-80.1918 +relay.nostrdice.com,-33.8688,151.209 top.testrelay.top,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -yabu.me,35.6092,139.73 -nostr.hekster.org,37.3986,-121.964 -relay.seq1.net,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.defucc.me,51.5072,-0.127586 -nostr.notribe.net,40.8302,-74.1299 -relay-nl.zombi.cloudrodion.com,50.8943,6.06237 -relay.gorrdy.cz,43.6532,-79.3832 +testr.nymble.world,40.8054,-74.0241 +nostr.islandarea.net,35.4669,-97.6473 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr-dev.wellorder.net,45.5201,-122.99 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.artx.market,43.6548,-79.3885 +nostr.bond,50.1109,8.68213 +nostr.faultables.net,43.6532,-79.3832 +nostr-2.21crypto.ch,47.5356,8.73209 +nostr-relay.psfoundation.info,39.0438,-77.4874 +nostr-relay.xbytez.io,50.6924,3.20113 +aeon.libretechsystems.xyz,55.486,9.86577 +relay.vrtmrz.net,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 relay.agentry.com,42.8864,-78.8784 -nostr.self-determined.de,53.495,10.2542 -rusty-uat.siberian-albacore.ts.net:8443,35.6764,139.65 +ribo.nostria.app,43.6532,-79.3832 +relay.lacompagniemaximus.com,45.3147,-73.8785 +relay.jeffg.fyi,43.6532,-79.3832 +r.bitcoinhold.net,43.6532,-79.3832 +relay-dev.satlantis.io,40.8302,-74.1299 +strfry.shock.network,39.0438,-77.4874 +nostr.myshosholoza.co.za,52.3913,4.66545 +treuzkas.branruz.com,48.8575,2.35138 +relay.cosmicbolt.net,37.3986,-121.964 +adre.su,59.9311,30.3609 +relay.lightning.pub,39.0438,-77.4874 +relay2.angor.io,48.1046,11.6002 +bitchat.nostr1.com,40.7057,-74.0136 +social.amanah.eblessing.co,48.1046,11.6002 +relay.malxte.de,52.52,13.405 +relay.typedcypher.com,51.5072,-0.127586 +relay.nostrhub.fr,48.1045,11.6004 +nostr.thalheim.io,60.1699,24.9384 +nostr-relay.zimage.com,34.0549,-118.243 +nostr.sathoarder.com,48.5734,7.75211 +speakeasy.cellar.social,49.4543,11.0746 +purpura.cloud,43.6532,-79.3832 +r.0kb.io,32.789,-96.7989 +nostr.luisschwab.net,43.6532,-79.3832 +bendernostur.duckdns.org:8443,50.1109,8.68213 +wot.shaving.kiwi,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +relay.homeinhk.xyz,35.694,139.754 +articles.layer3.news,37.3387,-121.885 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.bitmacro.cloud,43.6532,-79.3832 +relay.trustr.ing,43.6548,-79.3885 +portal-relay.pareto.space,49.0291,8.35696 +relay-freeharmonypeople.space,41.1542,-8.60964 +nostr.spicyz.io,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +x.kojira.io,43.6532,-79.3832 +relay.nostriches.club,43.6532,-79.3832 +relay.notoshi.win,14.0644,101.515 +relay.staging.commonshub.brussels,49.4543,11.0746 +relay.cypherflow.ai,48.8575,2.35138 +kotukonostr.onrender.com,37.7775,-122.397 +relay.olas.app,60.1699,24.9384 +relay.nostrian-conquest.com,41.223,-111.974 +relay.trotters.cc,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +nostr.dpinkerton.com,-27.4971,152.976 +wot.codingarena.top,50.4754,12.3683 +relay.wavefunc.live,41.8781,-87.6298 +relay.mccormick.cx,52.3563,4.95714 +relay.nostrcheck.me,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +dev-relay.nostreon.com,60.1699,24.9384 +offchain.pub,39.1585,-94.5728 +relay.veganostr.com,60.1699,24.9384 +nostr.tagomago.me,42.3601,-71.0589 +relay.henryxplace.eu.org:9988,31.2304,121.474 +schnorr.me,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.hostr.network,41.2619,-95.8608 +relay.mulatta.io,37.5665,126.978 orly-relay.imwald.eu,48.8575,2.35138 -relay.hook.cafe,43.6532,-79.3832 +strfry.bitsbytom.com,51.4968,-0.018337 +nostriches.club,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +bucket.coracle.social,37.7775,-122.397 +nostr.computingcache.com,34.0356,-118.442 +nostrja-kari.heguro.com,43.6532,-79.3832 +relay.gulugulu.moe,43.6532,-79.3832 +relay.npubhaus.com,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.blankfors.se,60.1699,24.9384 +relayrs.notoshi.win,43.6532,-79.3832 relay.decentnewsroom.com,50.4754,12.3683 -us-east.nostr.pikachat.org,39.0438,-77.4874 +nostr-relay.corb.net,38.8353,-104.822 +nostr.vulpem.com,49.4543,11.0746 +relay-nl.zombi.cloudrodion.com,50.8943,6.06237 +relay02.lnfi.network,35.6764,139.65 +relay.mostr.pub,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +relay.ru.ac.th,13.7607,100.627 +soloco.nl,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +freelay.sovbit.dev,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +relay.bornheimer.app,51.5072,-0.127586 +nostr.wecsats.io,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +nostr-relay.amethyst.name,39.0438,-77.4874 +relay-rpi.edufeed.org,49.4521,11.0767 +nostr.defucc.me,51.5072,-0.127586 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.erybody.com,41.4513,-81.7021 +vault.iris.to,43.6532,-79.3832 +relay.snotr.nl:49999,52.0195,4.42946 +relay.fckstate.net,59.3293,18.0686 +nostr.easycryptosend.it,43.6532,-79.3832 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +test.thedude.cloud,50.1109,8.68213 +relay.openresist.com,43.6532,-79.3832 +wot.nostr.party,36.1659,-86.7844 +relay.astrolabe.finance,39.0438,-77.4874 +nostr.bitczat.pl,60.1699,24.9384 +relay.klabo.world,47.2343,-119.853 +armada.sharegap.net,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +shu04.shugur.net,25.2048,55.2708 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +nos.lol,50.4754,12.3683 +relay.camelus.app,45.5201,-122.99 +dev.relay.edufeed.org,49.4521,11.0767 +relay.minibolt.info,43.6532,-79.3832 +shu05.shugur.net,48.8575,2.35138 +relay.internationalright-wing.org,-22.5022,-48.7114 +nostr.mikoshi.de,50.1109,8.68213 +relay.dyne.org,49.0291,8.35705 +video.czas.plus,50.1109,8.68213 +relay.tapestry.ninja,40.8054,-74.0241 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +nittom.nostr1.com,40.7057,-74.0136 +ribo.eu.nostria.app,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +strfry.openhoofd.nl,51.9229,4.40833 +bitcoiner.social,47.6743,-117.112 +prl.plus,55.7628,37.5983 +relay.primal.net,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +nostrcity-club.fly.dev,37.7648,-122.432 +relay.bebond.net,43.6532,-79.3832 +inbox.mycelium.social,38.627,-90.1994 +ribo.us.nostria.app,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nostr.azzamo.net,52.2633,21.0283 +relay.inforsupports.com,43.6532,-79.3832 +additions-rev-rainbow-task.trycloudflare.com,43.6532,-79.3832 +strfry.ymir.cloud,43.6532,-79.3832 +relayone.soundhsa.com,39.1008,-94.5811 +relay.wisp.talk,49.4543,11.0746 +relay.ngengine.org,43.6532,-79.3832 +myvoiceourstory.org,37.3598,-121.981 +relay01.lnfi.network,35.6764,139.65 +relay.agora.social,50.7383,15.0648 +relay.nostr-check.me,43.6532,-79.3832 +nostr.dlcdevkit.com,40.0992,-83.1141 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.illuminodes.com,47.6061,-122.333 +relay.shadowbip.com,50.1109,8.68213 +relay.fountain.fm,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +relay.lanacoin-eternity.com,40.8302,-74.1299 +relay.nostx.io,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 nostr.twinkle.lol,51.902,7.6657 +relay.sovbit.dev,60.1699,24.9384 +nostr.self-determined.de,53.495,10.2542 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.staging.hostr.network,41.2619,-95.8608 +strfry.bonsai.com,39.0438,-77.4874 +yabu.me,35.6092,139.73 +relay.samt.st,40.8302,-74.1299 +wot.rejecttheframe.xyz,43.6532,-79.3832 +nostr.carroarmato0.be,51.0368,3.21186 +nostr.openhoofd.nl,51.9229,4.40833 +nostrelay.circum.space,52.3676,4.90414 +nexus.libernet.app,43.6532,-79.3832 +relay.bao.network,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.flashapp.me,43.6548,-79.3885 +eu.nostr.pikachat.org,49.4543,11.0746 +relay.sharegap.net,43.6532,-79.3832 +wot.nostr.place,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.threenine.services,51.5222,-0.62916 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.nostrmap.net,60.1699,24.9384 +nostr.88mph.life,52.1941,-2.21905 +0x-nostr-relay.fly.dev,37.7648,-122.432 +purplerelay.com,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +relay.wavlake.com,41.2619,-95.8608 +shu03.shugur.net,25.2048,55.2708 +relay.bullishbounty.com,43.6532,-79.3832 +relay.beginningend.com,35.2227,-97.4786 +nostr.nodesmap.com,59.3327,18.0656 +relay.libernet.app,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +nostr.chaima.info,50.1109,8.68213 +wot.dergigi.com,64.1476,-21.9392 +nostr.ps1829.com,33.8851,130.883 +relay.lanavault.space,60.1699,24.9384 +relay.openfarmtools.org,60.1699,24.9384 +nostr-01.yakihonne.com,1.32123,103.695 +relay.bnos.space,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +relay.paulstephenborile.com,49.4543,11.0746 +rilo.nostria.app,43.6532,-79.3832 +nostr.hifish.org,47.4244,8.57658 +relay.degmods.com,50.4754,12.3683 +relay.ohstr.com,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostrelites.org,41.8781,-87.6298 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr-relay.zeabur.app,25.0797,121.234 +relay.binaryrobot.com,43.6532,-79.3832 +relay.directsponsor.net,42.8864,-78.8784 +librepress.libretechsystems.xyz,55.4724,9.87335 +nostr.thebiglake.org,32.71,-96.6745 +temp.iris.to,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +relay.arx-ccn.com,50.4754,12.3683 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +inbox.scuba323.com,40.8218,-74.45 +relay5.bitransfer.org,43.6532,-79.3832 +rele.speyhard.fi,50.1109,8.68213 +nostr.stakey.net,52.3676,4.90414 +satsage.xyz,37.3986,-121.964 +relay.qstr.app,50.1109,8.68213 +relay.nostr.place,43.6532,-79.3832 +cs-relay.nostrdev.com,50.4754,12.3683 +relay.staging.plebeian.market,51.5072,-0.127586 +relay-dev.gulugulu.moe,43.6532,-79.3832 +cache.trustr.ing,43.6548,-79.3885 +nostrcheck.me,43.6532,-79.3832 +relay2.ngengine.org,43.6532,-79.3832 +nostr.tadryanom.me,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.gorrdy.cz,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +relay.satlantis.io,40.8054,-74.0241 +us-east.nostr.pikachat.org,39.0438,-77.4874 +relay.islandbitcoin.com,12.8498,77.6545 +nos.xmark.cc,50.6924,3.20113 +syb.lol,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 +relay.plebeian.market,50.1109,8.68213 +relay.nearhood.co.uk,51.5072,-0.127586 +nostr.2b9t.xyz,34.0549,-118.243 +relay.nostr.net,43.6532,-79.3832 +nostr.n7ekb.net,36.1527,-95.9902 +relay.getsafebox.app,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.seq1.net,43.6532,-79.3832 From 67e767237bb7ccb9591eba4e97dc529db9dc623f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 17 May 2026 07:01:09 +0000 Subject: [PATCH 03/32] Automated update of relay data - Sun May 17 07:01:09 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 564 ++++++++++++++------------- 1 file changed, 284 insertions(+), 280 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 4ba9acdd..9cd6f99b 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,287 +1,291 @@ Relay URL,Latitude,Longitude -spookstr2.nostr1.com,40.7057,-74.0136 -relay.dreamith.to,43.6532,-79.3832 -relay.nostriot.com,41.5695,-83.9786 -relay.sigit.io,50.4754,12.3683 -nostr.bitcoiner.social,47.6743,-117.112 -relay.keykeeper.world,40.7824,-74.0711 -dev.relay.stream,43.6532,-79.3832 -public.crostr.com,43.6532,-79.3832 -relay.bikel.ink,60.1699,24.9384 -ephemeral.snowflare.cc,43.6532,-79.3832 -relay.wellorder.net,45.5201,-122.99 -relay.mwaters.net,50.9871,2.12554 -premium.primal.net,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.endfiat.money,59.3327,18.0656 -relay.mmwaves.de,48.8575,2.35138 -relay1.privkey.io,25.7617,-80.1918 -relay.nostrdice.com,-33.8688,151.209 -top.testrelay.top,43.6532,-79.3832 testr.nymble.world,40.8054,-74.0241 -nostr.islandarea.net,35.4669,-97.6473 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr-dev.wellorder.net,45.5201,-122.99 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.artx.market,43.6548,-79.3885 -nostr.bond,50.1109,8.68213 -nostr.faultables.net,43.6532,-79.3832 -nostr-2.21crypto.ch,47.5356,8.73209 -nostr-relay.psfoundation.info,39.0438,-77.4874 -nostr-relay.xbytez.io,50.6924,3.20113 -aeon.libretechsystems.xyz,55.486,9.86577 -relay.vrtmrz.net,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.agentry.com,42.8864,-78.8784 -ribo.nostria.app,43.6532,-79.3832 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.jeffg.fyi,43.6532,-79.3832 -r.bitcoinhold.net,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -strfry.shock.network,39.0438,-77.4874 -nostr.myshosholoza.co.za,52.3913,4.66545 -treuzkas.branruz.com,48.8575,2.35138 -relay.cosmicbolt.net,37.3986,-121.964 -adre.su,59.9311,30.3609 -relay.lightning.pub,39.0438,-77.4874 -relay2.angor.io,48.1046,11.6002 -bitchat.nostr1.com,40.7057,-74.0136 -social.amanah.eblessing.co,48.1046,11.6002 -relay.malxte.de,52.52,13.405 -relay.typedcypher.com,51.5072,-0.127586 -relay.nostrhub.fr,48.1045,11.6004 -nostr.thalheim.io,60.1699,24.9384 -nostr-relay.zimage.com,34.0549,-118.243 -nostr.sathoarder.com,48.5734,7.75211 -speakeasy.cellar.social,49.4543,11.0746 -purpura.cloud,43.6532,-79.3832 -r.0kb.io,32.789,-96.7989 -nostr.luisschwab.net,43.6532,-79.3832 -bendernostur.duckdns.org:8443,50.1109,8.68213 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.plantroon.com,50.1013,8.62643 -relay.homeinhk.xyz,35.694,139.754 -articles.layer3.news,37.3387,-121.885 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.bitmacro.cloud,43.6532,-79.3832 -relay.trustr.ing,43.6548,-79.3885 -portal-relay.pareto.space,49.0291,8.35696 -relay-freeharmonypeople.space,41.1542,-8.60964 -nostr.spicyz.io,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -x.kojira.io,43.6532,-79.3832 -relay.nostriches.club,43.6532,-79.3832 -relay.notoshi.win,14.0644,101.515 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay.cypherflow.ai,48.8575,2.35138 -kotukonostr.onrender.com,37.7775,-122.397 -relay.olas.app,60.1699,24.9384 -relay.nostrian-conquest.com,41.223,-111.974 -relay.trotters.cc,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -nostr.dpinkerton.com,-27.4971,152.976 -wot.codingarena.top,50.4754,12.3683 -relay.wavefunc.live,41.8781,-87.6298 relay.mccormick.cx,52.3563,4.95714 -relay.nostrcheck.me,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -dev-relay.nostreon.com,60.1699,24.9384 -offchain.pub,39.1585,-94.5728 -relay.veganostr.com,60.1699,24.9384 -nostr.tagomago.me,42.3601,-71.0589 -relay.henryxplace.eu.org:9988,31.2304,121.474 -schnorr.me,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.hostr.network,41.2619,-95.8608 -relay.mulatta.io,37.5665,126.978 -orly-relay.imwald.eu,48.8575,2.35138 -strfry.bitsbytom.com,51.4968,-0.018337 -nostriches.club,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -bucket.coracle.social,37.7775,-122.397 -nostr.computingcache.com,34.0356,-118.442 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.gulugulu.moe,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.blankfors.se,60.1699,24.9384 -relayrs.notoshi.win,43.6532,-79.3832 -relay.decentnewsroom.com,50.4754,12.3683 -nostr-relay.corb.net,38.8353,-104.822 -nostr.vulpem.com,49.4543,11.0746 -relay-nl.zombi.cloudrodion.com,50.8943,6.06237 -relay02.lnfi.network,35.6764,139.65 -relay.mostr.pub,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -relay.ru.ac.th,13.7607,100.627 -soloco.nl,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -freelay.sovbit.dev,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -relay.bornheimer.app,51.5072,-0.127586 -nostr.wecsats.io,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -nostr-relay.amethyst.name,39.0438,-77.4874 -relay-rpi.edufeed.org,49.4521,11.0767 -nostr.defucc.me,51.5072,-0.127586 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.erybody.com,41.4513,-81.7021 -vault.iris.to,43.6532,-79.3832 -relay.snotr.nl:49999,52.0195,4.42946 -relay.fckstate.net,59.3293,18.0686 -nostr.easycryptosend.it,43.6532,-79.3832 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -test.thedude.cloud,50.1109,8.68213 -relay.openresist.com,43.6532,-79.3832 -wot.nostr.party,36.1659,-86.7844 -relay.astrolabe.finance,39.0438,-77.4874 -nostr.bitczat.pl,60.1699,24.9384 -relay.klabo.world,47.2343,-119.853 -armada.sharegap.net,43.6532,-79.3832 -relay.zone667.com,60.1699,24.9384 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -shu04.shugur.net,25.2048,55.2708 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -relay.camelus.app,45.5201,-122.99 -dev.relay.edufeed.org,49.4521,11.0767 -relay.minibolt.info,43.6532,-79.3832 -shu05.shugur.net,48.8575,2.35138 -relay.internationalright-wing.org,-22.5022,-48.7114 -nostr.mikoshi.de,50.1109,8.68213 -relay.dyne.org,49.0291,8.35705 -video.czas.plus,50.1109,8.68213 -relay.tapestry.ninja,40.8054,-74.0241 -srtrelay.c-stellar.net,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -nittom.nostr1.com,40.7057,-74.0136 -ribo.eu.nostria.app,43.6532,-79.3832 -bridge.tagomago.me,42.3601,-71.0589 -strfry.openhoofd.nl,51.9229,4.40833 -bitcoiner.social,47.6743,-117.112 -prl.plus,55.7628,37.5983 -relay.primal.net,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -nostrcity-club.fly.dev,37.7648,-122.432 -relay.bebond.net,43.6532,-79.3832 -inbox.mycelium.social,38.627,-90.1994 -ribo.us.nostria.app,43.6532,-79.3832 -relay.agorist.space,52.3734,4.89406 -nostr.azzamo.net,52.2633,21.0283 -relay.inforsupports.com,43.6532,-79.3832 -additions-rev-rainbow-task.trycloudflare.com,43.6532,-79.3832 -strfry.ymir.cloud,43.6532,-79.3832 -relayone.soundhsa.com,39.1008,-94.5811 -relay.wisp.talk,49.4543,11.0746 -relay.ngengine.org,43.6532,-79.3832 -myvoiceourstory.org,37.3598,-121.981 -relay01.lnfi.network,35.6764,139.65 -relay.agora.social,50.7383,15.0648 -relay.nostr-check.me,43.6532,-79.3832 -nostr.dlcdevkit.com,40.0992,-83.1141 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.illuminodes.com,47.6061,-122.333 -relay.shadowbip.com,50.1109,8.68213 -relay.fountain.fm,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +nostr-dev.wellorder.net,45.5201,-122.99 nostr2.girino.org,43.6532,-79.3832 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.nostx.io,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -nostr.twinkle.lol,51.902,7.6657 -relay.sovbit.dev,60.1699,24.9384 -nostr.self-determined.de,53.495,10.2542 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.staging.hostr.network,41.2619,-95.8608 -strfry.bonsai.com,39.0438,-77.4874 -yabu.me,35.6092,139.73 -relay.samt.st,40.8302,-74.1299 -wot.rejecttheframe.xyz,43.6532,-79.3832 -nostr.carroarmato0.be,51.0368,3.21186 -nostr.openhoofd.nl,51.9229,4.40833 -nostrelay.circum.space,52.3676,4.90414 -nexus.libernet.app,43.6532,-79.3832 -relay.bao.network,43.6532,-79.3832 -nostr.snowbla.de,60.1699,24.9384 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.flashapp.me,43.6548,-79.3885 -eu.nostr.pikachat.org,49.4543,11.0746 -relay.sharegap.net,43.6532,-79.3832 -wot.nostr.place,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -librerelay.aaroniumii.com,43.6532,-79.3832 -relay.threenine.services,51.5222,-0.62916 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.nostrmap.net,60.1699,24.9384 -nostr.88mph.life,52.1941,-2.21905 -0x-nostr-relay.fly.dev,37.7648,-122.432 -purplerelay.com,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -relay.wavlake.com,41.2619,-95.8608 -shu03.shugur.net,25.2048,55.2708 -relay.bullishbounty.com,43.6532,-79.3832 -relay.beginningend.com,35.2227,-97.4786 -nostr.nodesmap.com,59.3327,18.0656 -relay.libernet.app,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -nostr.chaima.info,50.1109,8.68213 -wot.dergigi.com,64.1476,-21.9392 -nostr.ps1829.com,33.8851,130.883 -relay.lanavault.space,60.1699,24.9384 -relay.openfarmtools.org,60.1699,24.9384 -nostr-01.yakihonne.com,1.32123,103.695 -relay.bnos.space,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -relay.paulstephenborile.com,49.4543,11.0746 -rilo.nostria.app,43.6532,-79.3832 -nostr.hifish.org,47.4244,8.57658 -relay.degmods.com,50.4754,12.3683 -relay.ohstr.com,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostrelites.org,41.8781,-87.6298 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr-relay.zeabur.app,25.0797,121.234 -relay.binaryrobot.com,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -librepress.libretechsystems.xyz,55.4724,9.87335 -nostr.thebiglake.org,32.71,-96.6745 -temp.iris.to,43.6532,-79.3832 -relay.orangepill.ovh,49.1689,-0.358841 -relay.arx-ccn.com,50.4754,12.3683 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -inbox.scuba323.com,40.8218,-74.45 -relay5.bitransfer.org,43.6532,-79.3832 -rele.speyhard.fi,50.1109,8.68213 -nostr.stakey.net,52.3676,4.90414 -satsage.xyz,37.3986,-121.964 -relay.qstr.app,50.1109,8.68213 -relay.nostr.place,43.6532,-79.3832 -cs-relay.nostrdev.com,50.4754,12.3683 -relay.staging.plebeian.market,51.5072,-0.127586 -relay-dev.gulugulu.moe,43.6532,-79.3832 -cache.trustr.ing,43.6548,-79.3885 -nostrcheck.me,43.6532,-79.3832 -relay2.ngengine.org,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -relay.gorrdy.cz,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -relay.satlantis.io,40.8054,-74.0241 -us-east.nostr.pikachat.org,39.0438,-77.4874 -relay.islandbitcoin.com,12.8498,77.6545 -nos.xmark.cc,50.6924,3.20113 -syb.lol,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -relay.plebeian.market,50.1109,8.68213 -relay.nearhood.co.uk,51.5072,-0.127586 -nostr.2b9t.xyz,34.0549,-118.243 -relay.nostr.net,43.6532,-79.3832 -nostr.n7ekb.net,36.1527,-95.9902 +relay.ditto.pub,43.6532,-79.3832 relay.getsafebox.app,43.6532,-79.3832 +intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 +strfry.bonsai.com,39.0438,-77.4874 +nostr.computingcache.com,34.0356,-118.442 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +nostr-relay.zimage.com,34.0549,-118.243 +relay.openfarmtools.org,60.1699,24.9384 +nostr-relay.corb.net,38.8353,-104.822 +nostr.blankfors.se,60.1699,24.9384 +inbox.scuba323.com,40.8218,-74.45 +relayone.geektank.ai,39.1008,-94.5811 +relay2.angor.io,48.1046,11.6002 +relay.klabo.world,47.2343,-119.853 +nostr.islandarea.net,35.4669,-97.6473 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.gorrdy.cz,43.6532,-79.3832 +nostr.tadryanom.me,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +relay.bullishbounty.com,43.6532,-79.3832 +wot.dergigi.com,64.1476,-21.9392 +relay01.lnfi.network,35.6764,139.65 +nostr.twinkle.lol,51.902,7.6657 +nostr.snowbla.de,60.1699,24.9384 +relay.bnos.space,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +relay.mulatta.io,37.5665,126.978 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.nostrian-conquest.com,41.223,-111.974 +testnet-relay.samt.st,40.8302,-74.1299 +nostr.openhoofd.nl,51.5717,3.70417 +relay.jbnco.co,43.6532,-79.3832 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +relay.paulstephenborile.com,49.4543,11.0746 +strfry.ymir.cloud,43.6532,-79.3832 +spookstr2.nostr1.com,40.7057,-74.0136 +relay.mwaters.net,50.9871,2.12554 +relay.staging.hostr.network,41.2619,-95.8608 +relay.mrmave.work,43.6532,-79.3832 +relay.typedcypher.com,51.5072,-0.127586 +premium.primal.net,43.6532,-79.3832 +nostr.liberty.fans,36.9104,-89.5875 +relay.ru.ac.th,13.7607,100.627 +relay.edufeed.org,49.4521,11.0767 +nostr.vulpem.com,49.4543,11.0746 +relay.lacompagniemaximus.com,45.3147,-73.8785 +relay.trotters.cc,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.internationalright-wing.org,-22.5022,-48.7114 +relay.inforsupports.com,43.6532,-79.3832 +public.crostr.com,43.6532,-79.3832 +nostrue.com,40.8054,-74.0241 +bitcoinostr.duckdns.org,41.1976,1.11167 +relay.plebeian.market,50.1109,8.68213 +nostr.thalheim.io,60.1699,24.9384 +relay-nl.zombi.cloudrodion.com,50.8943,6.06237 +ynostr.yael.at,60.1699,24.9384 +relay.cosmicbolt.net,37.3986,-121.964 +nostr.bond,50.1109,8.68213 +relay.staging.plebeian.market,51.5072,-0.127586 +nostr.mom,50.4754,12.3683 +wot.nostr.party,36.1659,-86.7844 +bbw-nostr.xyz,41.5284,-87.4237 +relay.jeffg.fyi,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +top.testrelay.top,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +relay.minibolt.info,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +adre.su,59.9311,30.3609 +relay.bitmacro.cloud,43.6532,-79.3832 +wot.codingarena.top,50.4754,12.3683 +relay.nostr.net,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +nostr.myshosholoza.co.za,52.3913,4.66545 +eu.nostr.pikachat.org,49.4543,11.0746 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.bao.network,43.6532,-79.3832 +nostr.mikoshi.de,50.1109,8.68213 +nostr2.thalheim.io,49.4543,11.0746 +relay.laantungir.net,-19.4692,-42.5315 +aeon.libretechsystems.xyz,55.486,9.86577 +relay.wisp.talk,49.4543,11.0746 +relay.mmwaves.de,48.8575,2.35138 +relay.snotr.nl:49999,52.0195,4.42946 blossom.gnostr.cloud,43.6532,-79.3832 -relay.mitchelltribe.com,39.0438,-77.4874 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.ohstr.com,43.6532,-79.3832 +relay.agora.social,50.7383,15.0648 +cache.trustr.ing,43.6548,-79.3885 +nostr.bitcoiner.social,47.6743,-117.112 +nostr.nodesmap.com,59.3327,18.0656 +wot.shaving.kiwi,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +purpura.cloud,43.6532,-79.3832 +video.czas.plus,50.1109,8.68213 +relay.decentnewsroom.com,50.4754,12.3683 +relay.toastr.net,40.8054,-74.0241 +speakeasy.cellar.social,49.4543,11.0746 +ribo.nostria.app,43.6532,-79.3832 +relay.notoshi.win,14.0644,101.515 +nostr.chaima.info,50.1109,8.68213 +slick.mjex.me,39.0418,-77.4744 +relay.gulugulu.moe,43.6532,-79.3832 +relay.nostriches.club,43.6532,-79.3832 +nostr.88mph.life,52.1941,-2.21905 +nostrcity-club.fly.dev,37.7648,-122.432 +ribo.us.nostria.app,43.6532,-79.3832 +bitchat.nostr1.com,40.7057,-74.0136 +nostr.debate.report,50.1109,8.68213 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.nostr-check.me,43.6532,-79.3832 +relay.nostrdice.com,-33.8688,151.209 +nostr.sathoarder.com,48.5734,7.75211 +relay.bikel.ink,60.1699,24.9384 +relay.lanavault.space,60.1699,24.9384 +relay.sharegap.net,43.6532,-79.3832 +strfry.atlantislabs.space,43.6532,-79.3832 +relay.fckstate.net,59.3293,18.0686 +relay.trustr.ing,43.6548,-79.3885 +strfry.shock.network,39.0438,-77.4874 +fanfares.nostr1.com,40.7057,-74.0136 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.vrtmrz.net,43.6532,-79.3832 +nostr.chvaja.cz,33.4484,-112.074 +nostr.self-determined.de,53.495,10.2542 +nostr-01.yakihonne.com,1.32123,103.695 +relay.sovbit.dev,60.1699,24.9384 +nostr.pbfs.io,50.4754,12.3683 +relay.keykeeper.world,40.7824,-74.0711 +relay.primal.net,43.6532,-79.3832 +nostr-relay.amethyst.name,39.0438,-77.4874 +nostr.ps1829.com,33.8851,130.883 +yabu.me,35.6092,139.73 +temp.iris.to,43.6532,-79.3832 +nostr.n7ekb.net,36.1527,-95.9902 +purplerelay.com,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +nostr.dlcdevkit.com,40.0992,-83.1141 +satsage.xyz,37.3986,-121.964 +relay.0xchat.com,43.6532,-79.3832 +relay.erybody.com,41.4513,-81.7021 +freelay.sovbit.dev,60.1699,24.9384 +nostr.stakey.net,52.3676,4.90414 +nostrcheck.me,43.6532,-79.3832 +nostr-relay.psfoundation.info,39.0438,-77.4874 +nostr.quali.chat,60.1699,24.9384 +syb.lol,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr.easycryptosend.it,43.6532,-79.3832 +relay.tapestry.ninja,40.8054,-74.0241 +nostr.girino.org,43.6532,-79.3832 +nostr.wecsats.io,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +cs-relay.nostrdev.com,50.4754,12.3683 +nostr.luisschwab.net,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +nostr.tagomago.me,42.3601,-71.0589 +relay.satlantis.io,40.8054,-74.0241 +nostr-relay.online,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.fundstr.me,42.3601,-71.0589 +relay.veganostr.com,60.1699,24.9384 +relay.threenine.services,51.5222,-0.62916 +relay.angor.io,48.1046,11.6002 +relay.aidatanorge.no,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.satmaxt.xyz,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.arx-ccn.com,50.4754,12.3683 +relay.hostr.network,41.2619,-95.8608 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +us-east.nostr.pikachat.org,39.0438,-77.4874 +dev.relay.edufeed.org,49.4521,11.0767 +nexus.libernet.app,43.6532,-79.3832 +relay.illuminodes.com,47.6061,-122.333 +nostr.data.haus,50.4754,12.3683 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relay-dev.gulugulu.moe,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nos.lol,50.4754,12.3683 +nos4smartnkind.tech,40.2662,44.6393 +r.0kb.io,32.789,-96.7989 +offchain.pub,39.1585,-94.5728 +prl.plus,55.7628,37.5983 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.nadajnik.org,50.1109,8.68213 +nostrja-kari.heguro.com,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +relayrs.notoshi.win,43.6532,-79.3832 +relayone.soundhsa.com,39.1008,-94.5811 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +rilo.nostria.app,43.6532,-79.3832 +openrelay.ziomc.com,50.0755,14.4378 +reraw.pbla2fish.cc,43.6532,-79.3832 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.wellorder.net,45.5201,-122.99 +relay.nostriot.com,41.5695,-83.9786 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 relay.seq1.net,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +relay.wavlake.com,41.2619,-95.8608 +strfry.openhoofd.nl,51.5717,3.70417 +relay.nostrhub.fr,48.1045,11.6004 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.rikmeijer.nl,51.6992,5.30417 +relay.zone667.com,60.1699,24.9384 +relay.earthly.city,34.1749,-118.54 +nostr.bitczat.pl,60.1699,24.9384 +relay.islandbitcoin.com,12.8498,77.6545 +relay.nostar.org,43.6532,-79.3832 +relay.staging.commonshub.brussels,49.4543,11.0746 +myvoiceourstory.org,37.3598,-121.981 +bendernostur.duckdns.org:8443,50.1109,8.68213 +relay.nearhood.co.uk,51.5072,-0.127586 +relay.nostrmap.net,60.1699,24.9384 +0x-nostr-relay.fly.dev,37.7648,-122.432 +nostr.hekster.org,37.3986,-121.964 +rele.speyhard.fi,50.1109,8.68213 +nostr.oxtr.dev,50.4754,12.3683 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.flashapp.me,43.6548,-79.3885 +relay.mostr.pub,43.6532,-79.3832 +relay.artx.market,43.6548,-79.3885 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.directsponsor.net,42.8864,-78.8784 +bitcoiner.social,47.6743,-117.112 +relay.damus.io,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay.binaryrobot.com,43.6532,-79.3832 +relay2.veganostr.com,60.1699,24.9384 +offchain.bostr.online,43.6532,-79.3832 +relay.openresist.com,43.6532,-79.3832 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr.4rs.nl,49.0291,8.35696 +nittom.nostr1.com,40.7057,-74.0136 +nostr.21crypto.ch,47.5356,8.73209 +wot.nostr.place,43.6532,-79.3832 +nostr.azzamo.net,52.2633,21.0283 +dev-relay.nostreon.com,60.1699,24.9384 +relay.camelus.app,45.5201,-122.99 +relay.beginningend.com,35.2227,-97.4786 +relay.cypherflow.ai,48.8575,2.35138 +test.thedude.cloud,50.1109,8.68213 +armada.sharegap.net,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +relay1.privkey.io,25.7617,-80.1918 +bucket.coracle.social,37.7775,-122.397 +nostr.red5d.dev,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 +relay.sigit.io,50.4754,12.3683 +ephemeral.snowflare.cc,43.6532,-79.3832 +nostrelay.circum.space,52.3676,4.90414 +vault.iris.to,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +relay.mitchelltribe.com,39.0438,-77.4874 +nostr.carroarmato0.be,51.0368,3.21186 +relay.ngengine.org,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +relay.bornheimer.app,51.5072,-0.127586 +relay.dyne.org,49.0291,8.35705 +nostr-pub.wellorder.net,45.5201,-122.99 +relay.bebond.net,43.6532,-79.3832 +relay.opmaat.org,60.1699,24.9384 +relay.degmods.com,50.4754,12.3683 +nrs-02.darkcloudarcade.com,39.9526,-75.1652 +relay.samt.st,40.8302,-74.1299 +relay.lightning.pub,39.0438,-77.4874 +relay.shadowbip.com,50.1109,8.68213 +kasztanowa.bieda.it,43.6532,-79.3832 +x.kojira.io,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +relay.qstr.app,50.1109,8.68213 +relay02.lnfi.network,35.6764,139.65 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.dreamith.to,43.6532,-79.3832 +dev-nostr.bityacht.io,43.6532,-79.3832 +relay.olas.app,60.1699,24.9384 From 2a8dbac89d52f8647682856b1c7c74894d1ab7e5 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 24 May 2026 07:09:35 +0000 Subject: [PATCH 04/32] Automated update of relay data - Sun May 24 07:09:35 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 568 ++++++++++++++------------- 1 file changed, 292 insertions(+), 276 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 9cd6f99b..546dcc20 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,291 +1,307 @@ Relay URL,Latitude,Longitude -testr.nymble.world,40.8054,-74.0241 -relay.mccormick.cx,52.3563,4.95714 -treuzkas.branruz.com,48.8575,2.35138 -nostr-dev.wellorder.net,45.5201,-122.99 -nostr2.girino.org,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.getsafebox.app,43.6532,-79.3832 -intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 -strfry.bonsai.com,39.0438,-77.4874 -nostr.computingcache.com,34.0356,-118.442 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -nostr-relay.zimage.com,34.0549,-118.243 -relay.openfarmtools.org,60.1699,24.9384 -nostr-relay.corb.net,38.8353,-104.822 -nostr.blankfors.se,60.1699,24.9384 -inbox.scuba323.com,40.8218,-74.45 -relayone.geektank.ai,39.1008,-94.5811 -relay2.angor.io,48.1046,11.6002 -relay.klabo.world,47.2343,-119.853 -nostr.islandarea.net,35.4669,-97.6473 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.gorrdy.cz,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -relay.bullishbounty.com,43.6532,-79.3832 -wot.dergigi.com,64.1476,-21.9392 -relay01.lnfi.network,35.6764,139.65 -nostr.twinkle.lol,51.902,7.6657 -nostr.snowbla.de,60.1699,24.9384 -relay.bnos.space,43.6532,-79.3832 -bridge.tagomago.me,42.3601,-71.0589 -relay.mulatta.io,37.5665,126.978 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.nostrian-conquest.com,41.223,-111.974 -testnet-relay.samt.st,40.8302,-74.1299 -nostr.openhoofd.nl,51.5717,3.70417 +schnorr.me,43.6532,-79.3832 relay.jbnco.co,43.6532,-79.3832 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.libernet.app,43.6532,-79.3832 -relay.paulstephenborile.com,49.4543,11.0746 -strfry.ymir.cloud,43.6532,-79.3832 -spookstr2.nostr1.com,40.7057,-74.0136 -relay.mwaters.net,50.9871,2.12554 -relay.staging.hostr.network,41.2619,-95.8608 -relay.mrmave.work,43.6532,-79.3832 -relay.typedcypher.com,51.5072,-0.127586 -premium.primal.net,43.6532,-79.3832 -nostr.liberty.fans,36.9104,-89.5875 -relay.ru.ac.th,13.7607,100.627 -relay.edufeed.org,49.4521,11.0767 -nostr.vulpem.com,49.4543,11.0746 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.trotters.cc,43.6532,-79.3832 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.inforsupports.com,43.6532,-79.3832 -public.crostr.com,43.6532,-79.3832 -nostrue.com,40.8054,-74.0241 -bitcoinostr.duckdns.org,41.1976,1.11167 -relay.plebeian.market,50.1109,8.68213 +relay.layer.systems,49.0291,8.35695 +relay.satlantis.io,40.8054,-74.0241 +relay.plebeian.market:443,50.1109,8.68213 +nittom.nostr1.com:443,40.7057,-74.0136 +relay.lab.rytswd.com,49.4543,11.0746 +relay.bornheimer.app,51.5072,-0.127586 +nostrelites.org,41.8781,-87.6298 +dev.relay.edufeed.org,49.4521,11.0767 +relay.trustr.ing,43.6548,-79.3885 +nostr.aruku.ovh,1.27994,103.849 +relay.vrtmrz.net,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +nostr.hybner.org,46.9895,28.8602 +test.thedude.cloud,50.1109,8.68213 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.artx.market,43.6548,-79.3885 nostr.thalheim.io,60.1699,24.9384 -relay-nl.zombi.cloudrodion.com,50.8943,6.06237 -ynostr.yael.at,60.1699,24.9384 -relay.cosmicbolt.net,37.3986,-121.964 -nostr.bond,50.1109,8.68213 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +relay.thecryptosquid.com,50.4754,12.3683 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.binaryrobot.com,43.6532,-79.3832 +relay.threenine.services,51.5222,-0.62916 +relay2.veganostr.com,60.1699,24.9384 +nostr-pub.wellorder.net,45.5201,-122.99 +mostro-p2p.tech,50.1109,8.68213 +relay.mwaters.net,50.9871,2.12554 +nexus.libernet.app,43.6532,-79.3832 +testnet.samt.st,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +soloco.nl,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +nostr.rtvslawenia.com:443,49.4543,11.0746 +nostr.dlcdevkit.com,40.0992,-83.1141 +relay.mostro.network,40.8302,-74.1299 +relay.mccormick.cx:443,52.3563,4.95714 +nrs-02.darkcloudarcade.com,39.9526,-75.1652 +nostr.sathoarder.com,48.5734,7.75211 +myvoiceourstory.org,37.3598,-121.981 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.bikel.ink,60.1699,24.9384 +nostr.tac.lol,47.4748,-122.273 +adre.su,59.9311,30.3609 +relay.ru.ac.th,13.7607,100.627 +0x-nostr-relay.fly.dev,37.7648,-122.432 +nostr.easycryptosend.it,43.6532,-79.3832 +relay.wavlake.com:443,41.2619,-95.8608 +nostr-relay.online,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +video.czas.plus,50.1109,8.68213 +relay.gorrdy.cz,43.6532,-79.3832 +relay.shadowbip.com,50.1109,8.68213 +ribo.us.nostria.app,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 relay.staging.plebeian.market,51.5072,-0.127586 -nostr.mom,50.4754,12.3683 wot.nostr.party,36.1659,-86.7844 +relay.beginningend.com,35.2227,-97.4786 +blossom.gnostr.cloud,43.6532,-79.3832 +relay01.lnfi.network,35.6764,139.65 +satsage.xyz,37.3986,-121.964 +x.kojira.io,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.homeinhk.xyz,35.694,139.754 +relay.olas.app,60.1699,24.9384 +relay.lacompagniemaximus.com,45.3147,-73.8785 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr.chaima.info,50.1109,8.68213 +nostr.tadryanom.me,43.6532,-79.3832 +relay.notoshi.win,13.966,101.513 +relay.damus.io,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +relay.btcforplebs.com,43.6532,-79.3832 +prl.plus,55.7628,37.5983 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +ribo.nostria.app,43.6532,-79.3832 +dk.6yv.cn,39.9042,116.407 +relay.nearhood.co.uk,51.5072,-0.127586 +wot.dergigi.com,64.1476,-21.9392 +relay.fundstr.me,42.3601,-71.0589 +nostr.nodesmap.com,59.3327,18.0656 +nostr.bgbitcoin.club,50.4754,12.3683 +relay.libernet.app,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.erybody.com,41.4513,-81.7021 +nostr-01.yakihonne.com,1.32123,103.695 +nostr.bond,50.1109,8.68213 +offchain.pub,39.1585,-94.5728 +relay.klabo.world,47.2343,-119.853 +nostrelay.circum.space,52.3676,4.90414 +kotukonostr.onrender.com,37.7775,-122.397 +relay.comcomponent.com,43.6532,-79.3832 +nostr.infero.net,35.6764,139.65 +purplerelay.com,43.6532,-79.3832 +relay.nostr.blockhenge.com,39.0438,-77.4874 +nos.lol,50.4754,12.3683 +bridge.tagomago.me,42.3601,-71.0589 +nostr.spicyz.io,43.6532,-79.3832 +relay.samt.st,40.8302,-74.1299 +nostr.ps1829.com,33.8851,130.883 +nostr.tagomago.me,42.3601,-71.0589 +nostr.88mph.life,52.1941,-2.21905 +no.str.cr,9.27447,-83.5797 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.self-determined.de,53.495,10.2542 +yabu.me,35.6092,139.73 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +quantum-relay.3nostr.com,42.8864,-78.8784 +nostr-relay.amethyst.name,39.0438,-77.4874 +relay.mccormick.cx,52.3563,4.95714 +nostr.nadajnik.org,50.1109,8.68213 +nostr.azzamo.net,52.2633,21.0283 +relay.nostu.be,40.4167,-3.70329 +relay.decentnewsroom.com,50.4754,12.3683 +relay.bebond.net,43.6532,-79.3832 +relay.nostr-check.me,43.6532,-79.3832 +nostr.hifish.org,47.4244,8.57658 +eu.nostr.pikachat.org,49.4543,11.0746 +strfry.ymir.cloud,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +nostr.computingcache.com,34.0356,-118.442 +nostr.twinkle.lol,51.902,7.6657 +relay.aidatanorge.no,43.6532,-79.3832 +relay.mulatta.io,37.5665,126.978 +ynostr.yael.at,60.1699,24.9384 +relay.mrmave.work,43.6532,-79.3832 +damus.bostr.online,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +herbstmeister.com,34.0549,-118.243 +antiprimal.net,43.6532,-79.3832 +relay.endfiat.money,59.3327,18.0656 +relay.sigit.io,50.4754,12.3683 +kasztanowa.bieda.it,43.6532,-79.3832 +nostr.purpura.cloud,43.6532,-79.3832 +relay.dreamith.to,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +portal-relay.pareto.space,49.0291,8.35696 +relay.agorist.space,52.3734,4.89406 +nostr.debate.report,50.1109,8.68213 +relay.lightning.pub,39.0438,-77.4874 +relay.lanacoin-eternity.com,40.8302,-74.1299 +wot.makenomistakes.ca,43.7064,-79.3986 +nostr.carroarmato0.be,51.0368,3.21186 +relay.trotters.cc,43.6532,-79.3832 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +us-east.nostr.pikachat.org,39.0438,-77.4874 +rilo.nostria.app,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nostr.snowbla.de,60.1699,24.9384 +relay.illuminodes.com,47.6061,-122.333 +intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 +testr.nymble.world,40.8054,-74.0241 +nostr.blankfors.se,60.1699,24.9384 +nostr.mom,50.4754,12.3683 +r.0kb.io,32.789,-96.7989 +relay.nostr.place,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +relay.wisp.talk,49.4543,11.0746 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +nostriches.club,43.6532,-79.3832 +relay.npubhaus.com,43.6532,-79.3832 +relay02.lnfi.network,35.6764,139.65 +armada.sharegap.net,43.6532,-79.3832 +dev-relay.nostreon.com,60.1699,24.9384 +relay1.privkey.io,25.7617,-80.1918 +nostr.wecsats.io,43.6532,-79.3832 +relay.mitchelltribe.com,39.0438,-77.4874 +conduitl2.fly.dev,37.7648,-122.432 +relay.nostx.io,43.6532,-79.3832 +rele.speyhard.fi,50.1109,8.68213 +relay.getsafebox.app,43.6532,-79.3832 +relay.cosmicbolt.net,37.3986,-121.964 +relayone.soundhsa.com,39.1008,-94.5811 +relay.openresist.com,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +chat-relay.zap-work.com,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +ribo.eu.nostria.app,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.chorus.community,48.5333,10.7 +vault.iris.to,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +nostrcheck.me,43.6532,-79.3832 +relay-dev.gulugulu.moe,43.6532,-79.3832 +wot.codingarena.top,50.4754,12.3683 +relay.snotr.nl:49999,52.0195,4.42946 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.sharegap.net,43.6532,-79.3832 +nas01xanthosnet.synology.me:7778,47.1285,8.74735 +relay.flashapp.me,43.6548,-79.3885 +syb.lol,43.6532,-79.3832 +relay.lanavault.space,60.1699,24.9384 +nostr2.thalheim.io,49.4543,11.0746 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr2.girino.org,43.6532,-79.3832 +relay.typedcypher.com,51.5072,-0.127586 +relay.underorion.se,50.1109,8.68213 +nostr.bitcoiner.social,47.6743,-117.112 +relay.dyne.org,49.0291,8.35705 +nostr-relay.corb.net,38.8353,-104.822 +relay.nostrmap.net,60.1699,24.9384 +relay.wavefunc.live,41.8781,-87.6298 +nostr.night7.space,50.4754,12.3683 +nostr-2.21crypto.ch,47.5356,8.73209 +wot.nostr.place,43.6532,-79.3832 +relay.sovbit.dev,60.1699,24.9384 +spookstr2.nostr1.com,40.7057,-74.0136 +relay.arx-ccn.com,50.4754,12.3683 +offchain.bostr.online,43.6532,-79.3832 +nittom.nostr1.com,40.7057,-74.0136 +relay.veganostr.com,60.1699,24.9384 +relay.tapestry.ninja,40.8054,-74.0241 +relay.nostar.org,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +nostr.2b9t.xyz,34.0549,-118.243 +relayrs.notoshi.win,43.6532,-79.3832 +aeon.libretechsystems.xyz,55.486,9.86577 +nostr.21crypto.ch,47.5356,8.73209 +nostr.n7ekb.net,36.1527,-95.9902 +nostr-relay.zimage.com,34.0549,-118.243 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +relay.camelus.app,45.5201,-122.99 +relay.openfarmtools.org,60.1699,24.9384 +relay.agentry.com,42.8864,-78.8784 +nostr.4rs.nl,49.0291,8.35696 +relay.0xchat.com,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +nostr.stakey.net,52.3676,4.90414 +relay.mmwaves.de,48.8575,2.35138 +relay.gulugulu.moe,43.6532,-79.3832 +relay.nostrdice.com,-33.8688,151.209 +relay.plebeian.market,50.1109,8.68213 +relay2.angor.io,48.1046,11.6002 +relay.bullishbounty.com:443,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 bbw-nostr.xyz,41.5284,-87.4237 relay.jeffg.fyi,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -top.testrelay.top,43.6532,-79.3832 -relay.wavefunc.live,41.8781,-87.6298 -relay.minibolt.info,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -adre.su,59.9311,30.3609 -relay.bitmacro.cloud,43.6532,-79.3832 -wot.codingarena.top,50.4754,12.3683 -relay.nostr.net,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -relay.orangepill.ovh,49.1689,-0.358841 -nostr.myshosholoza.co.za,52.3913,4.66545 -eu.nostr.pikachat.org,49.4543,11.0746 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.bao.network,43.6532,-79.3832 -nostr.mikoshi.de,50.1109,8.68213 -nostr2.thalheim.io,49.4543,11.0746 -relay.laantungir.net,-19.4692,-42.5315 -aeon.libretechsystems.xyz,55.486,9.86577 -relay.wisp.talk,49.4543,11.0746 -relay.mmwaves.de,48.8575,2.35138 -relay.snotr.nl:49999,52.0195,4.42946 -blossom.gnostr.cloud,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -relay.agora.social,50.7383,15.0648 -cache.trustr.ing,43.6548,-79.3885 -nostr.bitcoiner.social,47.6743,-117.112 -nostr.nodesmap.com,59.3327,18.0656 -wot.shaving.kiwi,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -purpura.cloud,43.6532,-79.3832 -video.czas.plus,50.1109,8.68213 -relay.decentnewsroom.com,50.4754,12.3683 relay.toastr.net,40.8054,-74.0241 -speakeasy.cellar.social,49.4543,11.0746 -ribo.nostria.app,43.6532,-79.3832 -relay.notoshi.win,14.0644,101.515 -nostr.chaima.info,50.1109,8.68213 -slick.mjex.me,39.0418,-77.4744 -relay.gulugulu.moe,43.6532,-79.3832 -relay.nostriches.club,43.6532,-79.3832 -nostr.88mph.life,52.1941,-2.21905 -nostrcity-club.fly.dev,37.7648,-122.432 -ribo.us.nostria.app,43.6532,-79.3832 -bitchat.nostr1.com,40.7057,-74.0136 -nostr.debate.report,50.1109,8.68213 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.nostr-check.me,43.6532,-79.3832 -relay.nostrdice.com,-33.8688,151.209 -nostr.sathoarder.com,48.5734,7.75211 -relay.bikel.ink,60.1699,24.9384 -relay.lanavault.space,60.1699,24.9384 -relay.sharegap.net,43.6532,-79.3832 -strfry.atlantislabs.space,43.6532,-79.3832 -relay.fckstate.net,59.3293,18.0686 -relay.trustr.ing,43.6548,-79.3885 -strfry.shock.network,39.0438,-77.4874 -fanfares.nostr1.com,40.7057,-74.0136 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.vrtmrz.net,43.6532,-79.3832 -nostr.chvaja.cz,33.4484,-112.074 -nostr.self-determined.de,53.495,10.2542 -nostr-01.yakihonne.com,1.32123,103.695 -relay.sovbit.dev,60.1699,24.9384 -nostr.pbfs.io,50.4754,12.3683 -relay.keykeeper.world,40.7824,-74.0711 -relay.primal.net,43.6532,-79.3832 -nostr-relay.amethyst.name,39.0438,-77.4874 -nostr.ps1829.com,33.8851,130.883 -yabu.me,35.6092,139.73 +premium.primal.net,43.6532,-79.3832 temp.iris.to,43.6532,-79.3832 -nostr.n7ekb.net,36.1527,-95.9902 -purplerelay.com,43.6532,-79.3832 -nostr.robosats.org,64.1476,-21.9392 -nostr.dlcdevkit.com,40.0992,-83.1141 -satsage.xyz,37.3986,-121.964 -relay.0xchat.com,43.6532,-79.3832 -relay.erybody.com,41.4513,-81.7021 -freelay.sovbit.dev,60.1699,24.9384 -nostr.stakey.net,52.3676,4.90414 -nostrcheck.me,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -nostr.quali.chat,60.1699,24.9384 -syb.lol,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -nostr.easycryptosend.it,43.6532,-79.3832 -relay.tapestry.ninja,40.8054,-74.0241 -nostr.girino.org,43.6532,-79.3832 -nostr.wecsats.io,43.6532,-79.3832 -nostr.plantroon.com,50.1013,8.62643 -cs-relay.nostrdev.com,50.4754,12.3683 -nostr.luisschwab.net,43.6532,-79.3832 -relay.endfiat.money,59.3327,18.0656 -nostr.tagomago.me,42.3601,-71.0589 -relay.satlantis.io,40.8054,-74.0241 -nostr-relay.online,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.fundstr.me,42.3601,-71.0589 -relay.veganostr.com,60.1699,24.9384 -relay.threenine.services,51.5222,-0.62916 -relay.angor.io,48.1046,11.6002 -relay.aidatanorge.no,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -relay.satmaxt.xyz,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.arx-ccn.com,50.4754,12.3683 -relay.hostr.network,41.2619,-95.8608 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -us-east.nostr.pikachat.org,39.0438,-77.4874 -dev.relay.edufeed.org,49.4521,11.0767 -nexus.libernet.app,43.6532,-79.3832 -relay.illuminodes.com,47.6061,-122.333 -nostr.data.haus,50.4754,12.3683 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay-dev.gulugulu.moe,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -nos4smartnkind.tech,40.2662,44.6393 -r.0kb.io,32.789,-96.7989 -offchain.pub,39.1585,-94.5728 -prl.plus,55.7628,37.5983 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.nadajnik.org,50.1109,8.68213 +relay.internationalright-wing.org,-22.5022,-48.7114 +relay.inforsupports.com,43.6532,-79.3832 +bitchat.nostr1.com,40.7057,-74.0136 +relay.getsafebox.app:443,43.6532,-79.3832 nostrja-kari.heguro.com,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -relayone.soundhsa.com,39.1008,-94.5811 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -rilo.nostria.app,43.6532,-79.3832 -openrelay.ziomc.com,50.0755,14.4378 -reraw.pbla2fish.cc,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 +relay.guggero.org,46.5971,9.59652 +freelay.sovbit.dev,60.1699,24.9384 +speakeasy.cellar.social,49.4543,11.0746 +relay.paulstephenborile.com,49.4543,11.0746 relay.wellorder.net,45.5201,-122.99 -relay.nostriot.com,41.5695,-83.9786 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -relay.seq1.net,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -relay.wavlake.com,41.2619,-95.8608 -strfry.openhoofd.nl,51.5717,3.70417 -relay.nostrhub.fr,48.1045,11.6004 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.rikmeijer.nl,51.6992,5.30417 -relay.zone667.com,60.1699,24.9384 -relay.earthly.city,34.1749,-118.54 +slick.mjex.me,39.0418,-77.4744 +strfry.shock.network,39.0438,-77.4874 +nostr-relay.psfoundation.info,39.0438,-77.4874 +nostr.islandarea.net,35.4669,-97.6473 +nostr-relay.nextblockvending.com,47.2343,-119.853 nostr.bitczat.pl,60.1699,24.9384 -relay.islandbitcoin.com,12.8498,77.6545 -relay.nostar.org,43.6532,-79.3832 -relay.staging.commonshub.brussels,49.4543,11.0746 -myvoiceourstory.org,37.3598,-121.981 -bendernostur.duckdns.org:8443,50.1109,8.68213 -relay.nearhood.co.uk,51.5072,-0.127586 -relay.nostrmap.net,60.1699,24.9384 -0x-nostr-relay.fly.dev,37.7648,-122.432 -nostr.hekster.org,37.3986,-121.964 -rele.speyhard.fi,50.1109,8.68213 -nostr.oxtr.dev,50.4754,12.3683 +wot.utxo.one,43.6532,-79.3832 +cs-relay.nostrdev.com,50.4754,12.3683 +nostr.notribe.net,40.8302,-74.1299 +nostr.quali.chat,60.1699,24.9384 +nostr.ac,38.958,-77.3592 +relay.fountain.fm,43.6532,-79.3832 nostr-relay.xbytez.io,50.6924,3.20113 -relay.flashapp.me,43.6548,-79.3885 -relay.mostr.pub,43.6532,-79.3832 -relay.artx.market,43.6548,-79.3885 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.directsponsor.net,42.8864,-78.8784 -bitcoiner.social,47.6743,-117.112 -relay.damus.io,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -relay.binaryrobot.com,43.6532,-79.3832 -relay2.veganostr.com,60.1699,24.9384 -offchain.bostr.online,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -relay.lanacoin-eternity.com,40.8302,-74.1299 -nostr.4rs.nl,49.0291,8.35696 -nittom.nostr1.com,40.7057,-74.0136 -nostr.21crypto.ch,47.5356,8.73209 -wot.nostr.place,43.6532,-79.3832 -nostr.azzamo.net,52.2633,21.0283 -dev-relay.nostreon.com,60.1699,24.9384 -relay.camelus.app,45.5201,-122.99 -relay.beginningend.com,35.2227,-97.4786 -relay.cypherflow.ai,48.8575,2.35138 -test.thedude.cloud,50.1109,8.68213 -armada.sharegap.net,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -relay1.privkey.io,25.7617,-80.1918 -bucket.coracle.social,37.7775,-122.397 -nostr.red5d.dev,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -relay.sigit.io,50.4754,12.3683 +relay.minibolt.info,43.6532,-79.3832 ephemeral.snowflare.cc,43.6532,-79.3832 -nostrelay.circum.space,52.3676,4.90414 -vault.iris.to,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -relay.mitchelltribe.com,39.0438,-77.4874 -nostr.carroarmato0.be,51.0368,3.21186 -relay.ngengine.org,43.6532,-79.3832 -nostr.2b9t.xyz,34.0549,-118.243 -relay.bornheimer.app,51.5072,-0.127586 -relay.dyne.org,49.0291,8.35705 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.bebond.net,43.6532,-79.3832 -relay.opmaat.org,60.1699,24.9384 -relay.degmods.com,50.4754,12.3683 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -relay.samt.st,40.8302,-74.1299 -relay.lightning.pub,39.0438,-77.4874 -relay.shadowbip.com,50.1109,8.68213 -kasztanowa.bieda.it,43.6532,-79.3832 -x.kojira.io,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -relay.qstr.app,50.1109,8.68213 -relay02.lnfi.network,35.6764,139.65 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.dreamith.to,43.6532,-79.3832 +relay.ohstr.com,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +schnorr.me:443,43.6532,-79.3832 +relay.nostriot.com,41.5695,-83.9786 +relay.mostr.pub,43.6532,-79.3832 +relaisnostr.trivaco.fr,48.5734,7.75211 +relay.cypherflow.ai,48.8575,2.35138 +nostr.hekster.org,37.3986,-121.964 +cache.trustr.ing,43.6548,-79.3885 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +strfry.bonsai.com,39.0438,-77.4874 +relay.edufeed.org,49.4521,11.0767 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.wavlake.com,41.2619,-95.8608 +relay.nostrmap.net:443,60.1699,24.9384 +nostr.robosats.org,64.1476,-21.9392 +relay.primal.net,43.6532,-79.3832 +wot.rejecttheframe.xyz,43.6532,-79.3832 dev-nostr.bityacht.io,43.6532,-79.3832 -relay.olas.app,60.1699,24.9384 +wot.shaving.kiwi,43.6532,-79.3832 +relay.bitmacro.cloud,43.6532,-79.3832 +relay.bnos.space,43.6532,-79.3832 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +relay.islandbitcoin.com,12.8498,77.6545 +relay.fckstate.net,59.3293,18.0686 +relay-nl.zombi.cloudrodion.com,50.8943,6.06237 +nostr.thebiglake.org,32.71,-96.6745 +relay.staging.commonshub.brussels,49.4543,11.0746 +articles.layer3.news,37.3387,-121.885 +relay.directsponsor.net,42.8864,-78.8784 +relay.zone667.com,60.1699,24.9384 From 1a99064fc72646bd744f0bc017bcd7bc3172a482 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 31 May 2026 07:22:04 +0000 Subject: [PATCH 05/32] Automated update of relay data - Sun May 31 07:22:04 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 692 +++++++++++++++------------ 1 file changed, 392 insertions(+), 300 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 546dcc20..2be682e0 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,307 +1,399 @@ Relay URL,Latitude,Longitude -schnorr.me,43.6532,-79.3832 -relay.jbnco.co,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -relay.satlantis.io,40.8054,-74.0241 -relay.plebeian.market:443,50.1109,8.68213 -nittom.nostr1.com:443,40.7057,-74.0136 -relay.lab.rytswd.com,49.4543,11.0746 -relay.bornheimer.app,51.5072,-0.127586 -nostrelites.org,41.8781,-87.6298 -dev.relay.edufeed.org,49.4521,11.0767 -relay.trustr.ing,43.6548,-79.3885 -nostr.aruku.ovh,1.27994,103.849 -relay.vrtmrz.net,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -nostr.hybner.org,46.9895,28.8602 -test.thedude.cloud,50.1109,8.68213 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.artx.market,43.6548,-79.3885 -nostr.thalheim.io,60.1699,24.9384 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -relay.thecryptosquid.com,50.4754,12.3683 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr-dev.wellorder.net,45.5201,-122.99 -relay.binaryrobot.com,43.6532,-79.3832 -relay.threenine.services,51.5222,-0.62916 -relay2.veganostr.com,60.1699,24.9384 -nostr-pub.wellorder.net,45.5201,-122.99 -mostro-p2p.tech,50.1109,8.68213 -relay.mwaters.net,50.9871,2.12554 -nexus.libernet.app,43.6532,-79.3832 -testnet.samt.st,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -relay.orangepill.ovh,49.1689,-0.358841 -soloco.nl,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -nostr.rtvslawenia.com:443,49.4543,11.0746 -nostr.dlcdevkit.com,40.0992,-83.1141 -relay.mostro.network,40.8302,-74.1299 -relay.mccormick.cx:443,52.3563,4.95714 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -nostr.sathoarder.com,48.5734,7.75211 -myvoiceourstory.org,37.3598,-121.981 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.bikel.ink,60.1699,24.9384 nostr.tac.lol,47.4748,-122.273 -adre.su,59.9311,30.3609 -relay.ru.ac.th,13.7607,100.627 -0x-nostr-relay.fly.dev,37.7648,-122.432 -nostr.easycryptosend.it,43.6532,-79.3832 -relay.wavlake.com:443,41.2619,-95.8608 -nostr-relay.online,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -video.czas.plus,50.1109,8.68213 -relay.gorrdy.cz,43.6532,-79.3832 -relay.shadowbip.com,50.1109,8.68213 -ribo.us.nostria.app,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.staging.plebeian.market,51.5072,-0.127586 -wot.nostr.party,36.1659,-86.7844 -relay.beginningend.com,35.2227,-97.4786 -blossom.gnostr.cloud,43.6532,-79.3832 -relay01.lnfi.network,35.6764,139.65 -satsage.xyz,37.3986,-121.964 -x.kojira.io,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.homeinhk.xyz,35.694,139.754 -relay.olas.app,60.1699,24.9384 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr.chaima.info,50.1109,8.68213 -nostr.tadryanom.me,43.6532,-79.3832 -relay.notoshi.win,13.966,101.513 -relay.damus.io,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -relay.btcforplebs.com,43.6532,-79.3832 -prl.plus,55.7628,37.5983 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -ribo.nostria.app,43.6532,-79.3832 -dk.6yv.cn,39.9042,116.407 -relay.nearhood.co.uk,51.5072,-0.127586 -wot.dergigi.com,64.1476,-21.9392 -relay.fundstr.me,42.3601,-71.0589 -nostr.nodesmap.com,59.3327,18.0656 -nostr.bgbitcoin.club,50.4754,12.3683 -relay.libernet.app,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.erybody.com,41.4513,-81.7021 -nostr-01.yakihonne.com,1.32123,103.695 -nostr.bond,50.1109,8.68213 -offchain.pub,39.1585,-94.5728 -relay.klabo.world,47.2343,-119.853 -nostrelay.circum.space,52.3676,4.90414 -kotukonostr.onrender.com,37.7775,-122.397 -relay.comcomponent.com,43.6532,-79.3832 -nostr.infero.net,35.6764,139.65 -purplerelay.com,43.6532,-79.3832 -relay.nostr.blockhenge.com,39.0438,-77.4874 -nos.lol,50.4754,12.3683 -bridge.tagomago.me,42.3601,-71.0589 -nostr.spicyz.io,43.6532,-79.3832 -relay.samt.st,40.8302,-74.1299 -nostr.ps1829.com,33.8851,130.883 -nostr.tagomago.me,42.3601,-71.0589 -nostr.88mph.life,52.1941,-2.21905 -no.str.cr,9.27447,-83.5797 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.self-determined.de,53.495,10.2542 -yabu.me,35.6092,139.73 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -quantum-relay.3nostr.com,42.8864,-78.8784 -nostr-relay.amethyst.name,39.0438,-77.4874 -relay.mccormick.cx,52.3563,4.95714 -nostr.nadajnik.org,50.1109,8.68213 -nostr.azzamo.net,52.2633,21.0283 -relay.nostu.be,40.4167,-3.70329 -relay.decentnewsroom.com,50.4754,12.3683 -relay.bebond.net,43.6532,-79.3832 -relay.nostr-check.me,43.6532,-79.3832 -nostr.hifish.org,47.4244,8.57658 -eu.nostr.pikachat.org,49.4543,11.0746 -strfry.ymir.cloud,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -nostr.computingcache.com,34.0356,-118.442 -nostr.twinkle.lol,51.902,7.6657 -relay.aidatanorge.no,43.6532,-79.3832 -relay.mulatta.io,37.5665,126.978 -ynostr.yael.at,60.1699,24.9384 -relay.mrmave.work,43.6532,-79.3832 -damus.bostr.online,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -herbstmeister.com,34.0549,-118.243 -antiprimal.net,43.6532,-79.3832 -relay.endfiat.money,59.3327,18.0656 -relay.sigit.io,50.4754,12.3683 -kasztanowa.bieda.it,43.6532,-79.3832 -nostr.purpura.cloud,43.6532,-79.3832 -relay.dreamith.to,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -portal-relay.pareto.space,49.0291,8.35696 -relay.agorist.space,52.3734,4.89406 -nostr.debate.report,50.1109,8.68213 -relay.lightning.pub,39.0438,-77.4874 -relay.lanacoin-eternity.com,40.8302,-74.1299 -wot.makenomistakes.ca,43.7064,-79.3986 -nostr.carroarmato0.be,51.0368,3.21186 -relay.trotters.cc,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -us-east.nostr.pikachat.org,39.0438,-77.4874 -rilo.nostria.app,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -nostr.snowbla.de,60.1699,24.9384 -relay.illuminodes.com,47.6061,-122.333 -intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 -testr.nymble.world,40.8054,-74.0241 -nostr.blankfors.se,60.1699,24.9384 -nostr.mom,50.4754,12.3683 -r.0kb.io,32.789,-96.7989 -relay.nostr.place,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -relay.wisp.talk,49.4543,11.0746 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -nostriches.club,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -relay02.lnfi.network,35.6764,139.65 -armada.sharegap.net,43.6532,-79.3832 -dev-relay.nostreon.com,60.1699,24.9384 -relay1.privkey.io,25.7617,-80.1918 -nostr.wecsats.io,43.6532,-79.3832 -relay.mitchelltribe.com,39.0438,-77.4874 -conduitl2.fly.dev,37.7648,-122.432 -relay.nostx.io,43.6532,-79.3832 -rele.speyhard.fi,50.1109,8.68213 -relay.getsafebox.app,43.6532,-79.3832 -relay.cosmicbolt.net,37.3986,-121.964 -relayone.soundhsa.com,39.1008,-94.5811 -relay.openresist.com,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -chat-relay.zap-work.com,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -ribo.eu.nostria.app,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.chorus.community,48.5333,10.7 -vault.iris.to,43.6532,-79.3832 -nostr.vulpem.com,49.4543,11.0746 -nostrcheck.me,43.6532,-79.3832 -relay-dev.gulugulu.moe,43.6532,-79.3832 -wot.codingarena.top,50.4754,12.3683 -relay.snotr.nl:49999,52.0195,4.42946 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.sharegap.net,43.6532,-79.3832 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -relay.flashapp.me,43.6548,-79.3885 -syb.lol,43.6532,-79.3832 -relay.lanavault.space,60.1699,24.9384 -nostr2.thalheim.io,49.4543,11.0746 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr2.girino.org,43.6532,-79.3832 -relay.typedcypher.com,51.5072,-0.127586 -relay.underorion.se,50.1109,8.68213 -nostr.bitcoiner.social,47.6743,-117.112 -relay.dyne.org,49.0291,8.35705 -nostr-relay.corb.net,38.8353,-104.822 -relay.nostrmap.net,60.1699,24.9384 -relay.wavefunc.live,41.8781,-87.6298 -nostr.night7.space,50.4754,12.3683 -nostr-2.21crypto.ch,47.5356,8.73209 -wot.nostr.place,43.6532,-79.3832 -relay.sovbit.dev,60.1699,24.9384 -spookstr2.nostr1.com,40.7057,-74.0136 -relay.arx-ccn.com,50.4754,12.3683 -offchain.bostr.online,43.6532,-79.3832 -nittom.nostr1.com,40.7057,-74.0136 -relay.veganostr.com,60.1699,24.9384 -relay.tapestry.ninja,40.8054,-74.0241 -relay.nostar.org,43.6532,-79.3832 -relay.aarpia.com,37.3986,-121.964 -nostr.2b9t.xyz,34.0549,-118.243 -relayrs.notoshi.win,43.6532,-79.3832 -aeon.libretechsystems.xyz,55.486,9.86577 -nostr.21crypto.ch,47.5356,8.73209 -nostr.n7ekb.net,36.1527,-95.9902 -nostr-relay.zimage.com,34.0549,-118.243 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -relay.camelus.app,45.5201,-122.99 -relay.openfarmtools.org,60.1699,24.9384 -relay.agentry.com,42.8864,-78.8784 -nostr.4rs.nl,49.0291,8.35696 -relay.0xchat.com,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -nostr.stakey.net,52.3676,4.90414 -relay.mmwaves.de,48.8575,2.35138 -relay.gulugulu.moe,43.6532,-79.3832 -relay.nostrdice.com,-33.8688,151.209 -relay.plebeian.market,50.1109,8.68213 -relay2.angor.io,48.1046,11.6002 -relay.bullishbounty.com:443,43.6532,-79.3832 -relay.nostrcheck.me,43.6532,-79.3832 -bbw-nostr.xyz,41.5284,-87.4237 -relay.jeffg.fyi,43.6532,-79.3832 -relay.toastr.net,40.8054,-74.0241 -premium.primal.net,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.inforsupports.com,43.6532,-79.3832 -bitchat.nostr1.com,40.7057,-74.0136 -relay.getsafebox.app:443,43.6532,-79.3832 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.guggero.org,46.5971,9.59652 +relay.nostr.net,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 freelay.sovbit.dev,60.1699,24.9384 -speakeasy.cellar.social,49.4543,11.0746 -relay.paulstephenborile.com,49.4543,11.0746 -relay.wellorder.net,45.5201,-122.99 -slick.mjex.me,39.0418,-77.4744 -strfry.shock.network,39.0438,-77.4874 -nostr-relay.psfoundation.info,39.0438,-77.4874 -nostr.islandarea.net,35.4669,-97.6473 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr.bitczat.pl,60.1699,24.9384 -wot.utxo.one,43.6532,-79.3832 -cs-relay.nostrdev.com,50.4754,12.3683 -nostr.notribe.net,40.8302,-74.1299 -nostr.quali.chat,60.1699,24.9384 -nostr.ac,38.958,-77.3592 -relay.fountain.fm,43.6532,-79.3832 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.minibolt.info,43.6532,-79.3832 -ephemeral.snowflare.cc,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -schnorr.me:443,43.6532,-79.3832 -relay.nostriot.com,41.5695,-83.9786 -relay.mostr.pub,43.6532,-79.3832 -relaisnostr.trivaco.fr,48.5734,7.75211 -relay.cypherflow.ai,48.8575,2.35138 -nostr.hekster.org,37.3986,-121.964 -cache.trustr.ing,43.6548,-79.3885 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -strfry.bonsai.com,39.0438,-77.4874 -relay.edufeed.org,49.4521,11.0767 -strfry.apps3.slidestr.net,40.4167,-3.70329 -relay.wavlake.com,41.2619,-95.8608 -relay.nostrmap.net:443,60.1699,24.9384 -nostr.robosats.org,64.1476,-21.9392 -relay.primal.net,43.6532,-79.3832 -wot.rejecttheframe.xyz,43.6532,-79.3832 -dev-nostr.bityacht.io,43.6532,-79.3832 -wot.shaving.kiwi,43.6532,-79.3832 -relay.bitmacro.cloud,43.6532,-79.3832 -relay.bnos.space,43.6532,-79.3832 +relay2.veganostr.com,60.1699,24.9384 +relay.jeffg.fyi,43.6532,-79.3832 srtrelay.c-stellar.net,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 +relay.typedcypher.com,51.5072,-0.127586 +nostr-relay.nextblockvending.com,47.2343,-119.853 +spookstr2.nostr1.com,40.7057,-74.0136 +relay.bitmacro.cloud,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nostr-relay.amethyst.name,39.0438,-77.4874 +intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 +nostr.azzamo.net,52.2633,21.0283 +relay.mmwaves.de,48.8575,2.35138 +cs-relay.nostrdev.com:443,50.4754,12.3683 +myvoiceourstory.org,37.3598,-121.981 +nostr.easycryptosend.it,43.6532,-79.3832 +relay.0xchat.com,43.6532,-79.3832 +nostr.thalheim.io,60.1699,24.9384 +relay.cosmicbolt.net,37.3986,-121.964 +relay.mitchelltribe.com,39.0438,-77.4874 +nostrelites.org,41.8781,-87.6298 +nostr-2.21crypto.ch,47.5356,8.73209 +relayrs.notoshi.win,43.6532,-79.3832 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay02.lnfi.network,35.6764,139.65 +prl.plus,55.7628,37.5983 +relay.snotr.nl:49999,52.0195,4.42946 +relay.plebeian.market,50.1109,8.68213 +bitcoiner.social:443,47.6743,-117.112 +relay.beginningend.com:443,35.2227,-97.4786 +damus.bostr.online,43.6532,-79.3832 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +relay.bullishbounty.com:443,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +relay.mostro.network:443,40.8302,-74.1299 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.trustr.ing:443,43.6548,-79.3885 +relay.layer.systems,49.0291,8.35695 +relay.angor.io,48.1046,11.6002 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 relay.islandbitcoin.com,12.8498,77.6545 -relay.fckstate.net,59.3293,18.0686 -relay-nl.zombi.cloudrodion.com,50.8943,6.06237 -nostr.thebiglake.org,32.71,-96.6745 -relay.staging.commonshub.brussels,49.4543,11.0746 -articles.layer3.news,37.3387,-121.885 +relay.binaryrobot.com,43.6532,-79.3832 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.plebeian.market:443,50.1109,8.68213 +nostr.n7ekb.net,36.1527,-95.9902 +nostr.snowbla.de:443,60.1699,24.9384 +nostr.debate.report,50.1109,8.68213 +nostr.snowbla.de,60.1699,24.9384 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +relay2.angor.io:443,48.1046,11.6002 +relay.vrtmrz.net,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.agorist.space:443,52.3734,4.89406 +strfry.bonsai.com,39.0438,-77.4874 +dev.relay.stream,43.6532,-79.3832 +relay.btcforplebs.com,43.6532,-79.3832 +relay.lightning.pub:443,39.0438,-77.4874 +purplerelay.com,43.6532,-79.3832 +relay.libernet.app:443,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +relay.wavlake.com,41.2619,-95.8608 +nostr.bgbitcoin.club:443,50.4754,12.3683 +relay.fundstr.me:443,42.3601,-71.0589 +nostr.sathoarder.com,48.5734,7.75211 +relay.cypherflow.ai:443,48.8575,2.35138 +relay.minibolt.info:443,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +relay.ru.ac.th,13.7607,100.627 +relay.lightning.pub,39.0438,-77.4874 +relay.bebond.net:443,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +relay.beginningend.com,35.2227,-97.4786 +relay.nostrdice.com,-33.8688,151.209 +relay.paulstephenborile.com,49.4543,11.0746 +relay.nostr.place,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 +ribo.us.nostria.app,43.6532,-79.3832 +relay.damus.io,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +nostr.overmind.lol:443,43.6532,-79.3832 +relay.mulatta.io,37.5665,126.978 +relay.homeinhk.xyz,35.694,139.754 +wot.nostr.party,36.1659,-86.7844 +nostr.chaima.info,50.1109,8.68213 +nostr-01.yakihonne.com,1.32123,103.695 +relay.laantungir.net,-19.4692,-42.5315 +nostr.wecsats.io,43.6532,-79.3832 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-2.21crypto.ch:443,47.5356,8.73209 +strfry.openhoofd.nl,51.5717,3.70417 +nostr.2b9t.xyz,34.0549,-118.243 +nostr.spaceshell.xyz:443,43.6532,-79.3832 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.olas.app,60.1699,24.9384 +speakeasy.cellar.social:443,49.4543,11.0746 +relay01.lnfi.network,35.6764,139.65 +nostr-02.yakihonne.com:443,1.32123,103.695 +nostr.carroarmato0.be,51.0368,3.21186 +strfry.shock.network,39.0438,-77.4874 +wot.codingarena.top,50.4754,12.3683 +nostr.mom,50.4754,12.3683 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +nostr2.thalheim.io,49.4543,11.0746 +syb.lol:443,43.6532,-79.3832 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.myshosholoza.co.za,52.3913,4.66545 +wot.brightbolt.net,47.6735,-116.781 +speakeasy.cellar.social,49.4543,11.0746 +relay.nostar.org,43.6532,-79.3832 relay.directsponsor.net,42.8864,-78.8784 +relay.bebond.net,43.6532,-79.3832 +nostr.hekster.org,37.3986,-121.964 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.binaryrobot.com:443,43.6532,-79.3832 +nostr.88mph.life,52.1941,-2.21905 +nostr.self-determined.de,53.495,10.2542 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +public.crostr.com:443,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +relay.wellorder.net,45.5201,-122.99 +nostr.vulpem.com,49.4543,11.0746 +relay.inforsupports.com,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +nostrbtc.com,43.6532,-79.3832 +nostr-relay.psfoundation.info:443,39.0438,-77.4874 +nostr-relay.zimage.com,34.0549,-118.243 +articles.layer3.news,37.3387,-121.885 +nostr.computingcache.com,34.0356,-118.442 +nostr.quali.chat,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +bendernostur.duckdns.org:8443,50.1109,8.68213 +syb.lol,43.6532,-79.3832 +nexus.libernet.app,43.6532,-79.3832 +memlay.v0l.io,53.3498,-6.26031 +nostr.blankfors.se,60.1699,24.9384 +relaisnostr.trivaco.fr,48.5734,7.75211 +eu.nostr.pikachat.org:443,49.4543,11.0746 +relay.chorus.community:443,48.5333,10.7 +relay.nostu.be,40.4167,-3.70329 +relay.fountain.fm:443,43.6532,-79.3832 +adre.su,59.9311,30.3609 +nostr2.girino.org:443,43.6532,-79.3832 +nostr.azzamo.net:443,52.2633,21.0283 +relay.samt.st,40.8302,-74.1299 +relay.bullishbounty.com,43.6532,-79.3832 +relay.nostrian-conquest.com:443,41.223,-111.974 +relay.lotek-distro.com,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +relay.0xchat.com:443,43.6532,-79.3832 +relay.lab.rytswd.com:443,49.4543,11.0746 +relay.fckstate.net,59.3293,18.0686 +conduitl2.fly.dev,37.7648,-122.432 relay.zone667.com,60.1699,24.9384 +relay.comcomponent.com,43.6532,-79.3832 +relay.damus.io:443,43.6532,-79.3832 +relay.klabo.world,47.2343,-119.853 +nostr.data.haus,50.4754,12.3683 +relay.nostr.blockhenge.com,39.0438,-77.4874 +relay-dev.gulugulu.moe,43.6532,-79.3832 +nostr.bgbitcoin.club,50.4754,12.3683 +relay-rpi.edufeed.org:443,49.4521,11.0767 +relay.layer.systems:443,49.0291,8.35695 +relay.fountain.fm,43.6532,-79.3832 +armada.sharegap.net,43.6532,-79.3832 +nostr.quali.chat:443,60.1699,24.9384 +relay.mrmave.work,43.6532,-79.3832 +r.0kb.io:443,32.789,-96.7989 +nostr.plantroon.com,50.1013,8.62643 +treuzkas.branruz.com,48.8575,2.35138 +nostr.night7.space:443,50.4754,12.3683 +nos.lol,50.4754,12.3683 +yabu.me,35.6092,139.73 +bucket.coracle.social,37.7775,-122.397 +relay.chorus.community,48.5333,10.7 +nostr-relay.corb.net,38.8353,-104.822 +relay.bao.network:443,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +mostro-p2p.tech,50.1109,8.68213 +relay.bornheimer.app,51.5072,-0.127586 +wot.rejecttheframe.xyz,43.6532,-79.3832 +bitcoinostr.duckdns.org,38.9393,-0.15411 +relay.nostr.net:443,43.6532,-79.3832 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.satlantis.io,40.8054,-74.0241 +relay.tapestry.ninja,40.8054,-74.0241 +relay.mitchelltribe.com:443,39.0438,-77.4874 +relay.veganostr.com:443,60.1699,24.9384 +nostr.stakey.net,52.3676,4.90414 +chat-relay.zap-work.com:443,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +relay.cosmicbolt.net:443,37.3986,-121.964 +cs-relay.nostrdev.com,50.4754,12.3683 +r.0kb.io,32.789,-96.7989 +relay.libernet.app,43.6532,-79.3832 +rele.speyhard.fi,50.1109,8.68213 +relay.mmwaves.de:443,48.8575,2.35138 +top.testrelay.top,43.6532,-79.3832 +soloco.nl,43.6532,-79.3832 +nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832 +relay.thecryptosquid.com,50.4754,12.3683 +bitchat.nostr1.com,40.7057,-74.0136 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +nostr.spicyz.io:443,43.6532,-79.3832 +nittom.nostr1.com:443,40.7057,-74.0136 +relay.aarpia.com,37.3986,-121.964 +relay.ohstr.com,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +schnorr.me:443,43.6532,-79.3832 +relay.veganostr.com,60.1699,24.9384 +relay.islandbitcoin.com:443,12.8498,77.6545 +wot.nostr.place,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +relay.decentnewsroom.com,50.4754,12.3683 +relay.flashapp.me:443,43.6548,-79.3885 +eu.nostr.pikachat.org,49.4543,11.0746 +wot.sudocarlos.com,43.6532,-79.3832 +strfry.openhoofd.nl:443,51.5717,3.70417 +no.str.cr:443,9.27447,-83.5797 +relay.lacompagniemaximus.com:443,45.3147,-73.8785 +us-east.nostr.pikachat.org,39.0438,-77.4874 +nostrcity-club.fly.dev:443,37.7648,-122.432 +relay.trustr.ing,43.6548,-79.3885 +relay.jbnco.co,43.6532,-79.3832 +nostr.dlcdevkit.com,40.0992,-83.1141 +nostr.aruku.ovh,1.27994,103.849 +nostr.night7.space,50.4754,12.3683 +wot.shaving.kiwi,43.6532,-79.3832 +relay.snort.social,53.3498,-6.26031 +nostrelay.circum.space:443,52.3676,4.90414 +relay.endfiat.money,59.3327,18.0656 +nostr.overmind.lol,43.6532,-79.3832 +relay.mostr.pub,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nos.lol:443,50.4754,12.3683 +nostr.bitcoiner.social,47.6743,-117.112 +relay.agorist.space,52.3734,4.89406 +herbstmeister.com,34.0549,-118.243 +relay.artx.market,43.6548,-79.3885 +nostr2.girino.org,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +testr.nymble.world,40.8054,-74.0241 +wot.utxo.one,43.6532,-79.3832 +relay.gulugulu.moe:443,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.hifish.org,47.4244,8.57658 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.openresist.com,43.6532,-79.3832 +x.kojira.io,43.6532,-79.3832 +relay.sovbit.dev,60.1699,24.9384 +relay.agentry.com:443,42.8864,-78.8784 +relay.vrtmrz.net:443,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +testnet-relay.samt.st:443,40.8302,-74.1299 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.guggero.org,46.5971,9.59652 +nostr.ps1829.com:443,33.8851,130.883 +relay.sigit.io:443,50.4754,12.3683 +relay.openfarmtools.org,60.1699,24.9384 +wot.makenomistakes.ca,43.7064,-79.3986 +relay.paulstephenborile.com:443,49.4543,11.0746 +freelay.sovbit.dev:443,60.1699,24.9384 +relay.arx-ccn.com,50.4754,12.3683 +relayone.soundhsa.com,39.1008,-94.5811 +relay.olas.app:443,60.1699,24.9384 +nostr.chaima.info:443,50.1109,8.68213 +relay.getsafebox.app:443,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +nostr-relay.amethyst.name:443,39.0438,-77.4874 +test.thedude.cloud,50.1109,8.68213 +relay.laantungir.net:443,-19.4692,-42.5315 +nrs-02.darkcloudarcade.com:443,39.9526,-75.1652 +relay.nostrhub.fr,48.1045,11.6004 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostrelay.circum.space,52.3676,4.90414 +slick.mjex.me,39.0418,-77.4744 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +bbw-nostr.xyz,41.5284,-87.4237 +relay.underorion.se,50.1109,8.68213 +relay.minibolt.info,43.6532,-79.3832 +relay.mccormick.cx:443,52.3563,4.95714 +relay.toastr.net,40.8054,-74.0241 +relay.mostro.network,40.8302,-74.1299 +nas01xanthosnet.synology.me:7778,47.1285,8.74735 +relay.getsafebox.app,43.6532,-79.3832 +strfry.apps3.slidestr.net,40.4167,-3.70329 +nostr.bitczat.pl:443,60.1699,24.9384 +dev.relay.edufeed.org,49.4521,11.0767 +relay.cypherflow.ai,48.8575,2.35138 +blossom.gnostr.cloud:443,43.6532,-79.3832 +nostr.bitczat.pl,60.1699,24.9384 +relay.sigit.io,50.4754,12.3683 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +relay.nostriot.com,41.5695,-83.9786 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.nostx.io,43.6532,-79.3832 +nittom.nostr1.com,40.7057,-74.0136 +nrs-02.darkcloudarcade.com,39.9526,-75.1652 +offchain.pub,39.1585,-94.5728 +relay.sharegap.net,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +relay.endfiat.money:443,59.3327,18.0656 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.dyne.org,49.0291,8.35705 +relay.edufeed.org:443,49.4521,11.0767 +nostr.twinkle.lol,51.902,7.6657 +relay.illuminodes.com,43.6532,-79.3832 +ribo.nostria.app,43.6532,-79.3832 +relay.nostrian-conquest.com,41.223,-111.974 +nostr.islandarea.net:443,35.4669,-97.6473 +relay.threenine.services,51.5222,-0.62916 +relay.bao.network,43.6532,-79.3832 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +x.kojira.io:443,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com:443,1.35208,103.82 +wot.dergigi.com,64.1476,-21.9392 +relay.mostr.pub:443,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +nostr.ps1829.com,33.8851,130.883 +relay.nostr.place:443,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +openrelay.ziomc.com,50.0755,14.4378 +nostr.robosats.org:443,64.1476,-21.9392 +relay.wavlake.com:443,41.2619,-95.8608 +relay.nostrmap.net,60.1699,24.9384 +offchain.bostr.online,43.6532,-79.3832 +nostr.stakey.net:443,52.3676,4.90414 +relay1.nostrchat.io,60.1699,24.9384 +rilo.nostria.app,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +nostrja-kari.heguro.com,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5022,-48.7114 +vault.iris.to,43.6532,-79.3832 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.notribe.net,40.8302,-74.1299 +nostr.purpura.cloud,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +temp.iris.to,43.6532,-79.3832 +us-east.nostr.pikachat.org:443,39.0438,-77.4874 +nostr.unkn0wn.world,46.8499,9.53287 +nexus.libernet.app:443,43.6532,-79.3832 +articles.layer3.news:443,37.3387,-121.885 +nostr.tadryanom.me,43.6532,-79.3832 +fanfares.nostr1.com:443,40.7057,-74.0136 +relay5.bitransfer.org,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +nostr.janx.com,43.6532,-79.3832 +relay.gulugulu.moe,43.6532,-79.3832 +relay.staging.commonshub.brussels,49.4543,11.0746 +social.amanah.eblessing.co,48.1046,11.6002 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +relay.lacompagniemaximus.com,45.3147,-73.8785 +relay.trotters.cc,43.6532,-79.3832 +nostr.2b9t.xyz:443,34.0549,-118.243 +nostr.bitcoiner.social:443,47.6743,-117.112 +nostr.nodesmap.com,59.3327,18.0656 +nostr.0x7e.xyz,47.4857,8.72929 +relay2.angor.io,48.1046,11.6002 +relay.jeffg.fyi:443,43.6532,-79.3832 +relay.routstr.com,59.4016,17.9455 +nostr.0x7e.xyz:443,47.4857,8.72929 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.nearhood.co.uk,51.5072,-0.127586 +relay.trotters.cc:443,43.6532,-79.3832 +nostr.myshosholoza.co.za:443,52.3913,4.66545 +nostr.bond,50.1109,8.68213 +nostr.faultables.net,43.6532,-79.3832 +aeon.libretechsystems.xyz,55.486,9.86577 +cache.trustr.ing,43.6548,-79.3885 +relay.flashapp.me,43.6548,-79.3885 +dev-relay.nostreon.com,60.1699,24.9384 +0x-nostr-relay.fly.dev,37.7648,-122.432 +relay.ditto.pub,43.6532,-79.3832 +nostr.wecsats.io:443,43.6532,-79.3832 +premium.primal.net,43.6532,-79.3832 +relay.notoshi.win,13.966,101.513 +no.str.cr,9.27447,-83.5797 +nostr-pub.wellorder.net,45.5201,-122.99 +satsage.xyz,37.3986,-121.964 +relay.nostrmap.net:443,60.1699,24.9384 +testnet.samt.st,43.6532,-79.3832 +nostr.plantroon.com:443,50.1013,8.62643 +relay.mwaters.net,50.9871,2.12554 +relay.wisp.talk,49.4543,11.0746 +nostr.computingcache.com:443,34.0356,-118.442 +public.crostr.com,43.6532,-79.3832 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.notoshi.win:443,13.966,101.513 From f7ce1f4be652e63b592d1dcfd41844a2c63e0a6f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 7 Jun 2026 07:27:22 +0000 Subject: [PATCH 06/32] Automated update of relay data - Sun Jun 7 07:27:22 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 812 ++++++++++++++------------- 1 file changed, 427 insertions(+), 385 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 2be682e0..97652073 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,399 +1,441 @@ Relay URL,Latitude,Longitude -nostr.tac.lol,47.4748,-122.273 -relay.nostr.net,43.6532,-79.3832 -relay.lab.rytswd.com,49.4543,11.0746 -freelay.sovbit.dev,60.1699,24.9384 -relay2.veganostr.com,60.1699,24.9384 -relay.jeffg.fyi,43.6532,-79.3832 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.typedcypher.com,51.5072,-0.127586 -nostr-relay.nextblockvending.com,47.2343,-119.853 -spookstr2.nostr1.com,40.7057,-74.0136 -relay.bitmacro.cloud,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -nostr-relay.amethyst.name,39.0438,-77.4874 -intl-rocks-microphone-did.trycloudflare.com,43.6532,-79.3832 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -nostr.azzamo.net,52.2633,21.0283 -relay.mmwaves.de,48.8575,2.35138 -cs-relay.nostrdev.com:443,50.4754,12.3683 -myvoiceourstory.org,37.3598,-121.981 -nostr.easycryptosend.it,43.6532,-79.3832 -relay.0xchat.com,43.6532,-79.3832 -nostr.thalheim.io,60.1699,24.9384 -relay.cosmicbolt.net,37.3986,-121.964 -relay.mitchelltribe.com,39.0438,-77.4874 -nostrelites.org,41.8781,-87.6298 -nostr-2.21crypto.ch,47.5356,8.73209 -relayrs.notoshi.win,43.6532,-79.3832 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay02.lnfi.network,35.6764,139.65 -prl.plus,55.7628,37.5983 -relay.snotr.nl:49999,52.0195,4.42946 -relay.plebeian.market,50.1109,8.68213 -bitcoiner.social:443,47.6743,-117.112 -relay.beginningend.com:443,35.2227,-97.4786 -damus.bostr.online,43.6532,-79.3832 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -relay.bullishbounty.com:443,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -relay.mostro.network:443,40.8302,-74.1299 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -kasztanowa.bieda.it,43.6532,-79.3832 -relay.trustr.ing:443,43.6548,-79.3885 -relay.layer.systems,49.0291,8.35695 -relay.angor.io,48.1046,11.6002 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.islandbitcoin.com,12.8498,77.6545 -relay.binaryrobot.com,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.plebeian.market:443,50.1109,8.68213 -nostr.n7ekb.net,36.1527,-95.9902 -nostr.snowbla.de:443,60.1699,24.9384 -nostr.debate.report,50.1109,8.68213 -nostr.snowbla.de,60.1699,24.9384 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -relay2.angor.io:443,48.1046,11.6002 -relay.vrtmrz.net,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.agorist.space:443,52.3734,4.89406 -strfry.bonsai.com,39.0438,-77.4874 -dev.relay.stream,43.6532,-79.3832 -relay.btcforplebs.com,43.6532,-79.3832 +nostr.bitcoiner.social:443,47.6743,-117.112 +relay-dev.satlantis.io:443,40.8302,-74.1299 relay.lightning.pub:443,39.0438,-77.4874 -purplerelay.com,43.6532,-79.3832 -relay.libernet.app:443,43.6532,-79.3832 -nostr.robosats.org,64.1476,-21.9392 -relay.wavlake.com,41.2619,-95.8608 -nostr.bgbitcoin.club:443,50.4754,12.3683 -relay.fundstr.me:443,42.3601,-71.0589 -nostr.sathoarder.com,48.5734,7.75211 -relay.cypherflow.ai:443,48.8575,2.35138 -relay.minibolt.info:443,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.ru.ac.th,13.7607,100.627 -relay.lightning.pub,39.0438,-77.4874 -relay.bebond.net:443,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -relay.beginningend.com,35.2227,-97.4786 -relay.nostrdice.com,-33.8688,151.209 -relay.paulstephenborile.com,49.4543,11.0746 -relay.nostr.place,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -ribo.us.nostria.app,43.6532,-79.3832 -relay.damus.io,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -nostr.overmind.lol:443,43.6532,-79.3832 -relay.mulatta.io,37.5665,126.978 -relay.homeinhk.xyz,35.694,139.754 -wot.nostr.party,36.1659,-86.7844 -nostr.chaima.info,50.1109,8.68213 -nostr-01.yakihonne.com,1.32123,103.695 -relay.laantungir.net,-19.4692,-42.5315 -nostr.wecsats.io,43.6532,-79.3832 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-2.21crypto.ch:443,47.5356,8.73209 -strfry.openhoofd.nl,51.5717,3.70417 -nostr.2b9t.xyz,34.0549,-118.243 -nostr.spaceshell.xyz:443,43.6532,-79.3832 -relay.staging.plebeian.market,51.5072,-0.127586 -relay.olas.app,60.1699,24.9384 -speakeasy.cellar.social:443,49.4543,11.0746 -relay01.lnfi.network,35.6764,139.65 -nostr-02.yakihonne.com:443,1.32123,103.695 -nostr.carroarmato0.be,51.0368,3.21186 -strfry.shock.network,39.0438,-77.4874 -wot.codingarena.top,50.4754,12.3683 -nostr.mom,50.4754,12.3683 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -nostr2.thalheim.io,49.4543,11.0746 -syb.lol:443,43.6532,-79.3832 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr.myshosholoza.co.za,52.3913,4.66545 -wot.brightbolt.net,47.6735,-116.781 -speakeasy.cellar.social,49.4543,11.0746 -relay.nostar.org,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -relay.bebond.net,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.binaryrobot.com:443,43.6532,-79.3832 -nostr.88mph.life,52.1941,-2.21905 -nostr.self-determined.de,53.495,10.2542 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -public.crostr.com:443,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -relay.wellorder.net,45.5201,-122.99 -nostr.vulpem.com,49.4543,11.0746 -relay.inforsupports.com,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -nostrbtc.com,43.6532,-79.3832 -nostr-relay.psfoundation.info:443,39.0438,-77.4874 -nostr-relay.zimage.com,34.0549,-118.243 -articles.layer3.news,37.3387,-121.885 -nostr.computingcache.com,34.0356,-118.442 -nostr.quali.chat,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -bendernostur.duckdns.org:8443,50.1109,8.68213 -syb.lol,43.6532,-79.3832 -nexus.libernet.app,43.6532,-79.3832 -memlay.v0l.io,53.3498,-6.26031 -nostr.blankfors.se,60.1699,24.9384 -relaisnostr.trivaco.fr,48.5734,7.75211 -eu.nostr.pikachat.org:443,49.4543,11.0746 -relay.chorus.community:443,48.5333,10.7 -relay.nostu.be,40.4167,-3.70329 -relay.fountain.fm:443,43.6532,-79.3832 -adre.su,59.9311,30.3609 -nostr2.girino.org:443,43.6532,-79.3832 -nostr.azzamo.net:443,52.2633,21.0283 -relay.samt.st,40.8302,-74.1299 -relay.bullishbounty.com,43.6532,-79.3832 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.lotek-distro.com,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -bridge.tagomago.me,42.3601,-71.0589 -relay.0xchat.com:443,43.6532,-79.3832 -relay.lab.rytswd.com:443,49.4543,11.0746 -relay.fckstate.net,59.3293,18.0686 -conduitl2.fly.dev,37.7648,-122.432 -relay.zone667.com,60.1699,24.9384 -relay.comcomponent.com,43.6532,-79.3832 -relay.damus.io:443,43.6532,-79.3832 -relay.klabo.world,47.2343,-119.853 -nostr.data.haus,50.4754,12.3683 -relay.nostr.blockhenge.com,39.0438,-77.4874 -relay-dev.gulugulu.moe,43.6532,-79.3832 -nostr.bgbitcoin.club,50.4754,12.3683 -relay-rpi.edufeed.org:443,49.4521,11.0767 -relay.layer.systems:443,49.0291,8.35695 -relay.fountain.fm,43.6532,-79.3832 -armada.sharegap.net,43.6532,-79.3832 -nostr.quali.chat:443,60.1699,24.9384 -relay.mrmave.work,43.6532,-79.3832 -r.0kb.io:443,32.789,-96.7989 -nostr.plantroon.com,50.1013,8.62643 -treuzkas.branruz.com,48.8575,2.35138 -nostr.night7.space:443,50.4754,12.3683 -nos.lol,50.4754,12.3683 -yabu.me,35.6092,139.73 -bucket.coracle.social,37.7775,-122.397 -relay.chorus.community,48.5333,10.7 -nostr-relay.corb.net,38.8353,-104.822 -relay.bao.network:443,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -mostro-p2p.tech,50.1109,8.68213 -relay.bornheimer.app,51.5072,-0.127586 -wot.rejecttheframe.xyz,43.6532,-79.3832 -bitcoinostr.duckdns.org,38.9393,-0.15411 -relay.nostr.net:443,43.6532,-79.3832 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.satlantis.io,40.8054,-74.0241 -relay.tapestry.ninja,40.8054,-74.0241 -relay.mitchelltribe.com:443,39.0438,-77.4874 -relay.veganostr.com:443,60.1699,24.9384 -nostr.stakey.net,52.3676,4.90414 -chat-relay.zap-work.com:443,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -relay.cosmicbolt.net:443,37.3986,-121.964 -cs-relay.nostrdev.com,50.4754,12.3683 -r.0kb.io,32.789,-96.7989 -relay.libernet.app,43.6532,-79.3832 -rele.speyhard.fi,50.1109,8.68213 -relay.mmwaves.de:443,48.8575,2.35138 -top.testrelay.top,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832 -relay.thecryptosquid.com,50.4754,12.3683 -bitchat.nostr1.com,40.7057,-74.0136 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -nostr.spicyz.io:443,43.6532,-79.3832 -nittom.nostr1.com:443,40.7057,-74.0136 -relay.aarpia.com,37.3986,-121.964 -relay.ohstr.com,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -schnorr.me:443,43.6532,-79.3832 -relay.veganostr.com,60.1699,24.9384 -relay.islandbitcoin.com:443,12.8498,77.6545 -wot.nostr.place,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -relay.decentnewsroom.com,50.4754,12.3683 -relay.flashapp.me:443,43.6548,-79.3885 -eu.nostr.pikachat.org,49.4543,11.0746 -wot.sudocarlos.com,43.6532,-79.3832 -strfry.openhoofd.nl:443,51.5717,3.70417 -no.str.cr:443,9.27447,-83.5797 -relay.lacompagniemaximus.com:443,45.3147,-73.8785 -us-east.nostr.pikachat.org,39.0438,-77.4874 -nostrcity-club.fly.dev:443,37.7648,-122.432 -relay.trustr.ing,43.6548,-79.3885 -relay.jbnco.co,43.6532,-79.3832 -nostr.dlcdevkit.com,40.0992,-83.1141 -nostr.aruku.ovh,1.27994,103.849 -nostr.night7.space,50.4754,12.3683 -wot.shaving.kiwi,43.6532,-79.3832 -relay.snort.social,53.3498,-6.26031 -nostrelay.circum.space:443,52.3676,4.90414 -relay.endfiat.money,59.3327,18.0656 -nostr.overmind.lol,43.6532,-79.3832 -relay.mostr.pub,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nos.lol:443,50.4754,12.3683 -nostr.bitcoiner.social,47.6743,-117.112 -relay.agorist.space,52.3734,4.89406 -herbstmeister.com,34.0549,-118.243 -relay.artx.market,43.6548,-79.3885 -nostr2.girino.org,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -testr.nymble.world,40.8054,-74.0241 -wot.utxo.one,43.6532,-79.3832 -relay.gulugulu.moe:443,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr.hifish.org,47.4244,8.57658 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.openresist.com,43.6532,-79.3832 -x.kojira.io,43.6532,-79.3832 -relay.sovbit.dev,60.1699,24.9384 +ribo.us.nostria.app:443,43.6532,-79.3832 +relay.notoshi.win,13.3622,100.983 +openrelay.ziomc.com,50.0755,14.4378 relay.agentry.com:443,42.8864,-78.8784 -relay.vrtmrz.net:443,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -testnet-relay.samt.st:443,40.8302,-74.1299 -nostr-dev.wellorder.net,45.5201,-122.99 -relay.guggero.org,46.5971,9.59652 -nostr.ps1829.com:443,33.8851,130.883 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.gulugulu.moe,43.6532,-79.3832 +nostr.thebiglake.org:443,32.71,-96.6745 +relay.fountain.fm,43.6532,-79.3832 +freelay.sovbit.host,60.1699,24.9384 +nostr.girino.org:443,43.6532,-79.3832 +relay.openresist.com,43.6532,-79.3832 +nas01xanthosnet.synology.me:7778,47.1285,8.74735 +relay.ohstr.com:443,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +relay.nostrdice.com,-33.8688,151.209 +bbw-nostr.xyz,41.5284,-87.4237 +cs-relay.nostrdev.com:443,50.4754,12.3683 +top.testrelay.top,43.6532,-79.3832 +relay.trotters.cc,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +ribo.eu.nostria.app:443,43.6532,-79.3832 +public.crostr.com,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +strfry.bonsai.com:443,39.0438,-77.4874 +nostr.carroarmato0.be,50.914,3.21378 +relay.endfiat.money,59.3327,18.0656 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.myshosholoza.co.za:443,52.3913,4.66545 +relay.wellorder.net,45.5201,-122.99 +nostr.tagomago.me,42.3601,-71.0589 +relay.internationalright-wing.org,-22.5022,-48.7114 +relay.fckstate.net,59.3293,18.0686 +nostr.azzamo.net,52.2633,21.0283 +relay.0xchat.com,43.6532,-79.3832 +relayone.geektank.ai,39.1008,-94.5811 +relay.homeinhk.xyz,35.694,139.754 +nostr.nodesmap.com,59.3327,18.0656 +relay.islandbitcoin.com:443,12.8498,77.6545 +relay.mrmave.work,43.6532,-79.3832 +relay.arx-ccn.com,50.4754,12.3683 +fanfares.nostr1.com:443,40.7057,-74.0136 +wot.shaving.kiwi,43.6532,-79.3832 +relay.nearhood.co.uk,51.5072,-0.127586 +relay.fundstr.me,42.3601,-71.0589 +syb.lol:443,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +relay.nostx.io,43.6532,-79.3832 +no.str.cr,10.6352,-85.4378 +relay.jbnco.co,43.6532,-79.3832 +ribo.nostria.app:443,43.6532,-79.3832 +us-east.nostr.pikachat.org,39.0438,-77.4874 +nexus.libernet.app,43.6532,-79.3832 +nostr.dlcdevkit.com,40.0992,-83.1141 +nostr.debate.report,50.1109,8.68213 +str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +nostr.spicyz.io:443,43.6532,-79.3832 +relay.layer.systems:443,49.0291,8.35695 +nostr-relay.amethyst.name,39.0067,-77.4291 +slick.mjex.me,39.0418,-77.4744 +nostr.girino.org,43.6532,-79.3832 +nostr.quali.chat:443,60.1699,24.9384 +purplerelay.com:443,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +relay.plebeian.market:443,50.1109,8.68213 +relay.samt.st,40.8302,-74.1299 +relay.mitchelltribe.com:443,39.0438,-77.4874 +nostr.0x7e.xyz,47.4949,8.71954 +relayone.soundhsa.com:443,39.1008,-94.5811 +nostr.thalheim.io:443,60.1699,24.9384 +relay.getsafebox.app:443,43.6532,-79.3832 +nostr-relay.psfoundation.info,39.0438,-77.4874 +bucket.coracle.social,37.7775,-122.397 +nostr.carroarmato0.be:443,50.914,3.21378 +relay.staging.commonshub.brussels,49.4543,11.0746 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.lacompagniemaximus.com:443,45.3147,-73.8785 +relay-rpi.edufeed.org,49.4521,11.0767 +nostr.computingcache.com:443,34.0356,-118.442 +relay.lanavault.space:443,60.1699,24.9384 +relay.getsafebox.app,43.6532,-79.3832 +nrs-02.darkcloudarcade.com:443,39.9526,-75.1652 +nostr.hekster.org,37.3986,-121.964 +node.kommonzenze.de,49.4521,11.0767 +0x-nostr-relay.fly.dev,37.7648,-122.432 +speakeasy.cellar.social,49.4543,11.0746 +nostr.0x7e.xyz:443,47.4949,8.71954 +nostr.vulpem.com,49.4543,11.0746 +relay5.bitransfer.org,43.6532,-79.3832 +relay.inforsupports.com,43.6532,-79.3832 +relay.plebeian.market,50.1109,8.68213 +shu02.shugur.net,21.4902,39.2246 +nostr.easycryptosend.it,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.minibolt.info,43.6532,-79.3832 +nostr.pbfs.io:443,50.4754,12.3683 +nos.lol:443,50.4754,12.3683 +nostr.thebiglake.org,32.71,-96.6745 +relay.nostriot.com,41.5695,-83.9786 +strfry.shock.network:443,39.0438,-77.4874 +relay01.lnfi.network,35.6764,139.65 +r.0kb.io:443,32.789,-96.7989 +bcast.girino.org,43.6532,-79.3832 +nostr-relay.psfoundation.info:443,39.0438,-77.4874 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +testnet.samt.st,43.6532,-79.3832 +relay.bornheimer.app,51.5072,-0.127586 +nrs-02.darkcloudarcade.com,39.9526,-75.1652 +relay.binaryrobot.com:443,43.6532,-79.3832 +nostr.computingcache.com,34.0356,-118.442 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +schnorr.me,43.6532,-79.3832 +nostr.twinkle.lol,51.902,7.6657 +nostr.overmind.lol,43.6532,-79.3832 +relay.mmwaves.de:443,48.8575,2.35138 +relay2.veganostr.com,60.1699,24.9384 +nostr.mom,50.4754,12.3683 +nostr2.girino.org:443,43.6532,-79.3832 +wot.sudocarlos.com,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +nostr.thalheim.io,60.1699,24.9384 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +conduitl2.fly.dev,37.7648,-122.432 +relay.bullishbounty.com,43.6532,-79.3832 +myvoiceourstory.org,37.3598,-121.981 +relay.angor.io,48.1046,11.6002 +r.0kb.io,32.789,-96.7989 +nexus.libernet.app:443,43.6532,-79.3832 +us-east.nostr.pikachat.org:443,39.0438,-77.4874 +nostr.bitcoiner.social,47.6743,-117.112 +nostrelay.circum.space:443,52.6907,4.8181 +relayone.soundhsa.com,39.1008,-94.5811 +nostr.snowbla.de,60.1699,24.9384 +relay1.orangesync.tech,44.7839,-106.941 +nostr-2.21crypto.ch:443,47.5356,8.73209 +espelho.girino.org,43.6532,-79.3832 +nostr.blankfors.se,60.1699,24.9384 relay.sigit.io:443,50.4754,12.3683 +relay.vrtmrz.net:443,43.6532,-79.3832 +nostr.wecsats.io:443,43.6532,-79.3832 +nostrcity-club.fly.dev,37.7648,-122.432 +nostrelay.circum.space,52.6907,4.8181 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +x.kojira.io,43.6532,-79.3832 +relay.binaryrobot.com,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +nostr.sathoarder.com,48.5734,7.75211 +prl.plus,55.7628,37.5983 +relay.cosmicbolt.net:443,37.3986,-121.964 +relay.nostu.be:443,40.4167,-3.70329 +cs-relay.nostrdev.com,50.4754,12.3683 +satsage.xyz,37.3986,-121.964 +relay.lab.rytswd.com:443,49.4543,11.0746 +rilo.nostria.app:443,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +relay.wavlake.com:443,41.2619,-95.8608 +relay.beginningend.com,35.2227,-97.4786 +relay.openresist.com:443,43.6532,-79.3832 +bitcoiner.social:443,47.6743,-117.112 +relay.notoshi.win:443,13.3622,100.983 +dev.relay.edufeed.org:443,49.4521,11.0767 +relay.cypherflow.ai:443,48.8575,2.35138 +relay.ru.ac.th,13.7607,100.627 +shu03.shugur.net,25.2048,55.2708 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.agorist.space:443,52.3734,4.89406 +relay02.lnfi.network,35.6764,139.65 +relay-testnet.k8s.layer3.news:443,37.3387,-121.885 +nostr.notribe.net:443,40.8302,-74.1299 +relay.ditto.pub,43.6532,-79.3832 +nostr.rikmeijer.nl,51.7111,5.36809 +blossom.gnostr.cloud:443,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +cache.trustr.ing,43.6548,-79.3885 +nostr.bitczat.pl,60.1699,24.9384 +relay.nostrverse.net,43.6532,-79.3832 +shu04.shugur.net,25.2048,55.2708 +eu.nostr.pikachat.org:443,49.4543,11.0746 +ribo.us.nostria.app,43.6532,-79.3832 +nostr-relay.cbrx.io,43.6532,-79.3832 +nostr.bitczat.pl:443,60.1699,24.9384 +nostr-relay.corb.net:443,38.8353,-104.822 +relay.libernet.app,43.6532,-79.3832 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.nostu.be,40.4167,-3.70329 +rele.speyhard.fi,51.5072,-0.127586 +relay.staging.plebeian.market,51.5072,-0.127586 +nostr.spicyz.io,43.6532,-79.3832 +nostrride.io,37.3986,-121.964 +x.kojira.io:443,43.6532,-79.3832 +relay.staging.plebeian.market:443,51.5072,-0.127586 +relay.sigit.io,50.4754,12.3683 +nostr.stakey.net:443,52.3676,4.90414 +relay.nostr.blockhenge.com,39.0438,-77.4874 +relay.comcomponent.com,43.6532,-79.3832 +wot.nostr.place,43.6532,-79.3832 +relay.mostr.pub:443,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +no.str.cr:443,10.6352,-85.4378 +relay.chorus.community:443,48.5333,10.7 +relay.aarpia.com,37.3986,-121.964 +relay.nostrmap.net,60.1699,24.9384 +relay.snotr.nl:49999,52.0195,4.42946 +relay.bebond.net,43.6532,-79.3832 +relay.illuminodes.com,43.6532,-79.3832 +chat-relay.zap-work.com:443,43.6532,-79.3832 +testr.nymble.world,40.8054,-74.0241 +relay.underorion.se,50.1109,8.68213 +fanfares.nostr1.com,40.7057,-74.0136 +relay.damus.io,43.6532,-79.3832 +relay.nostriches.club,43.6532,-79.3832 +nostr-dev.wellorder.net,45.5201,-122.99 +relay2.angor.io,48.1046,11.6002 relay.openfarmtools.org,60.1699,24.9384 wot.makenomistakes.ca,43.7064,-79.3986 -relay.paulstephenborile.com:443,49.4543,11.0746 -freelay.sovbit.dev:443,60.1699,24.9384 -relay.arx-ccn.com,50.4754,12.3683 -relayone.soundhsa.com,39.1008,-94.5811 -relay.olas.app:443,60.1699,24.9384 -nostr.chaima.info:443,50.1109,8.68213 -relay.getsafebox.app:443,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -nostr-relay.amethyst.name:443,39.0438,-77.4874 +relay.thecryptosquid.com,50.4754,12.3683 test.thedude.cloud,50.1109,8.68213 +nostr.rtvslawenia.com,49.4543,11.0746 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.olas.app:443,60.1699,24.9384 +strfry.bonsai.com,39.0438,-77.4874 +relayrs.notoshi.win,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +wot.utxo.one,43.6532,-79.3832 +relay.angor.io:443,48.1046,11.6002 +relay.dwadziesciajeden.pl,52.2297,21.0122 +soloco.nl,43.6532,-79.3832 +armada.sharegap.net,43.6532,-79.3832 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.beginningend.com:443,35.2227,-97.4786 +relay.nostr.place,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +adre.su,59.9311,30.3609 +relay.0xchat.com:443,43.6532,-79.3832 +nostr.infero.net,35.6764,139.65 +relay.typedcypher.com:443,51.5072,-0.127586 +relay.mostro.network:443,40.8302,-74.1299 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.mostr.pub,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +nostr.tac.lol:443,47.4748,-122.273 +nostr.hekster.org:443,37.3986,-121.964 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.cypherflow.ai,48.8575,2.35138 +relay.zone667.com:443,60.1699,24.9384 +relay.jeffg.fyi:443,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nostr.spaceshell.xyz:443,43.6532,-79.3832 +top.testrelay.top:443,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +relay.nostrian-conquest.com:443,41.223,-111.974 relay.laantungir.net:443,-19.4692,-42.5315 -nrs-02.darkcloudarcade.com:443,39.9526,-75.1652 -relay.nostrhub.fr,48.1045,11.6004 +relay.islandbitcoin.com,12.8498,77.6545 +purplerelay.com,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +nostr.wecsats.io,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +nostr.quali.chat,60.1699,24.9384 +relay.dreamith.to,43.6532,-79.3832 +nostr.islandarea.net:443,35.4669,-97.6473 +relay.primal.net,43.6532,-79.3832 +eu.nostr.pikachat.org,49.4543,11.0746 +relay.nostr.net,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.mulatta.io,37.5665,126.978 +nittom.nostr1.com,40.7057,-74.0136 +relay2.orangesync.tech,40.7128,-74.006 +relay.artx.market,43.6548,-79.3885 +relay.wavefunc.live,41.8781,-87.6298 +bitchat.nostr1.com,40.7057,-74.0136 +relay.ditto.pub:443,43.6532,-79.3832 +relay.wisp.talk,49.4543,11.0746 +nostrelites.org,41.8781,-87.6298 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.chorus.community,48.5333,10.7 +relay.plebchain.club,43.6532,-79.3832 +nostr-relay.amethyst.name:443,39.0067,-77.4291 relay.lanacoin-eternity.com,40.8302,-74.1299 -nostrelay.circum.space,52.3676,4.90414 -slick.mjex.me,39.0418,-77.4744 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -bbw-nostr.xyz,41.5284,-87.4237 -relay.underorion.se,50.1109,8.68213 -relay.minibolt.info,43.6532,-79.3832 +relay.edufeed.org:443,49.4521,11.0767 +relay.lacompagniemaximus.com,45.3147,-73.8785 +relay.dreamith.to:443,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +nostr.n7ekb.net:443,47.4941,-122.294 +relay.dyne.org,49.0291,8.35705 +nostr.janx.com,43.6532,-79.3832 +relay.trustr.ing,43.6548,-79.3885 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.wisp.talk:443,49.4543,11.0746 +yabu.me,35.6092,139.73 +bcast.seutoba.com.br,43.6532,-79.3832 +nos.xmark.cc,50.6924,3.20113 +nos.lol,50.4754,12.3683 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.endfiat.money:443,59.3327,18.0656 +relay.tapestry.ninja,40.8054,-74.0241 +relay.lab.rytswd.com,49.4543,11.0746 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.damus.io:443,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +nostr-01.yakihonne.com,1.32123,103.695 +relay.nostriot.com:443,41.5695,-83.9786 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr.aruku.ovh,1.27994,103.849 +nostr.chaima.info,50.1109,8.68213 +ynostr.yael.at,60.1699,24.9384 +ynostr.yael.at:443,60.1699,24.9384 +nostr-01.yakihonne.com:443,1.32123,103.695 +spookstr2.nostr1.com,40.7057,-74.0136 +relay.trustr.ing:443,43.6548,-79.3885 +dev.relay.edufeed.org,49.4521,11.0767 +relay2.angor.io:443,48.1046,11.6002 +nostr.azzamo.net:443,52.2633,21.0283 +offchain.bostr.online,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +relay.veganostr.com,60.1699,24.9384 +vault.iris.to:443,43.6532,-79.3832 +relay.artx.market:443,43.6548,-79.3885 +wot.rejecttheframe.xyz,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +relay.mostro.network,40.8302,-74.1299 +strfry.shock.network,39.0438,-77.4874 +relay.klabo.world,47.2343,-119.853 +relay.fountain.fm:443,43.6532,-79.3832 +nostrja-kari.heguro.com,43.6532,-79.3832 +relaisnostr.trivaco.fr,48.5734,7.75211 +offchain.pub,39.1585,-94.5728 +relay.degmods.com,50.4754,12.3683 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.paulstephenborile.com,49.4543,11.0746 +nittom.nostr1.com:443,40.7057,-74.0136 +dev-relay.nostreon.com,60.1699,24.9384 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +relay.jeffg.fyi,43.6532,-79.3832 +relay.laantungir.net,-19.4692,-42.5315 +nostr.data.haus:443,50.4754,12.3683 +wot.codingarena.top,50.4754,12.3683 +nostr.2b9t.xyz:443,34.0549,-118.243 +relay.libernet.app:443,43.6532,-79.3832 +nostr.ps1829.com,33.8851,130.883 +wot.dergigi.com,64.1476,-21.9392 +relay.olas.app,60.1699,24.9384 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +nostr.data.haus,50.4754,12.3683 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.ohstr.com,43.6532,-79.3832 +relay.lightning.pub,39.0438,-77.4874 +relay.guggero.org,46.5971,9.59652 +testnet-relay.samt.st:443,40.8302,-74.1299 +thecitadel.nostr1.com,40.7057,-74.0136 +nostr.ps1829.com:443,33.8851,130.883 +nostr-relay.corb.net,38.8353,-104.822 +relay.npubhaus.com,43.6532,-79.3832 +nostr.21crypto.ch,47.5356,8.73209 +nostr.tadryanom.me,43.6532,-79.3832 +nostr.myshosholoza.co.za,52.3913,4.66545 +relay.bitmacro.cloud,43.6532,-79.3832 +ribo.nostria.app,43.6532,-79.3832 +relay.vrtmrz.net,43.6532,-79.3832 +syb.lol,43.6532,-79.3832 +relay.bebond.net:443,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +nostr-2.21crypto.ch,47.5356,8.73209 +nostrcity-club.fly.dev:443,37.7648,-122.432 +articles.layer3.news:443,37.3387,-121.885 +relay.internationalright-wing.org:443,-22.5022,-48.7114 +nostr-relay.zimage.com,34.0549,-118.243 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.mwaters.net,50.9871,2.12554 +nostr.liberty.fans,36.9104,-89.5875 +spookstr2.nostr1.com:443,40.7057,-74.0136 +nostr.chaima.info:443,50.1109,8.68213 +schnorr.me:443,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +wot.nostr.party,36.1659,-86.7844 +temp.iris.to,43.6532,-79.3832 +relay.lotek-distro.com,43.6532,-79.3832 +relay.minibolt.info:443,43.6532,-79.3832 +relay.lanavault.space,60.1699,24.9384 +relay.mmwaves.de,48.8575,2.35138 +social.amanah.eblessing.co,48.1046,11.6002 +nostr2.thalheim.io,49.4543,11.0746 +relay.typedcypher.com,51.5072,-0.127586 +relay.cosmicbolt.net,37.3986,-121.964 +relayrs.notoshi.win:443,43.6532,-79.3832 +nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +relay.nostrmap.net:443,60.1699,24.9384 +relay.bullishbounty.com:443,43.6532,-79.3832 +nostr.oxtr.dev:443,50.4754,12.3683 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +vault.iris.to,43.6532,-79.3832 relay.mccormick.cx:443,52.3563,4.95714 relay.toastr.net,40.8054,-74.0241 -relay.mostro.network,40.8302,-74.1299 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -relay.getsafebox.app,43.6532,-79.3832 -strfry.apps3.slidestr.net,40.4167,-3.70329 -nostr.bitczat.pl:443,60.1699,24.9384 -dev.relay.edufeed.org,49.4521,11.0767 -relay.cypherflow.ai,48.8575,2.35138 -blossom.gnostr.cloud:443,43.6532,-79.3832 -nostr.bitczat.pl,60.1699,24.9384 -relay.sigit.io,50.4754,12.3683 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -relay.nostriot.com,41.5695,-83.9786 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.nostx.io,43.6532,-79.3832 -nittom.nostr1.com,40.7057,-74.0136 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -offchain.pub,39.1585,-94.5728 -relay.sharegap.net,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -relay.endfiat.money:443,59.3327,18.0656 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.dyne.org,49.0291,8.35705 -relay.edufeed.org:443,49.4521,11.0767 -nostr.twinkle.lol,51.902,7.6657 -relay.illuminodes.com,43.6532,-79.3832 -ribo.nostria.app,43.6532,-79.3832 -relay.nostrian-conquest.com,41.223,-111.974 -nostr.islandarea.net:443,35.4669,-97.6473 -relay.threenine.services,51.5222,-0.62916 -relay.bao.network,43.6532,-79.3832 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -relay.wavefunc.live,41.8781,-87.6298 -x.kojira.io:443,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com:443,1.35208,103.82 -wot.dergigi.com,64.1476,-21.9392 -relay.mostr.pub:443,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.ps1829.com,33.8851,130.883 -relay.nostr.place:443,43.6532,-79.3832 -blossom.gnostr.cloud,43.6532,-79.3832 -openrelay.ziomc.com,50.0755,14.4378 -nostr.robosats.org:443,64.1476,-21.9392 -relay.wavlake.com:443,41.2619,-95.8608 -relay.nostrmap.net,60.1699,24.9384 -offchain.bostr.online,43.6532,-79.3832 -nostr.stakey.net:443,52.3676,4.90414 -relay1.nostrchat.io,60.1699,24.9384 -rilo.nostria.app,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -vault.iris.to,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.notribe.net,40.8302,-74.1299 -nostr.purpura.cloud,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -us-east.nostr.pikachat.org:443,39.0438,-77.4874 -nostr.unkn0wn.world,46.8499,9.53287 -nexus.libernet.app:443,43.6532,-79.3832 -articles.layer3.news:443,37.3387,-121.885 -nostr.tadryanom.me,43.6532,-79.3832 -fanfares.nostr1.com:443,40.7057,-74.0136 -relay5.bitransfer.org,43.6532,-79.3832 +nostr.hifish.org,47.4244,8.57658 +speakeasy.cellar.social:443,49.4543,11.0746 +relay.wavlake.com,41.2619,-95.8608 testnet-relay.samt.st,40.8302,-74.1299 -nostr.janx.com,43.6532,-79.3832 -relay.gulugulu.moe,43.6532,-79.3832 -relay.staging.commonshub.brussels,49.4543,11.0746 -social.amanah.eblessing.co,48.1046,11.6002 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.trotters.cc,43.6532,-79.3832 -nostr.2b9t.xyz:443,34.0549,-118.243 -nostr.bitcoiner.social:443,47.6743,-117.112 -nostr.nodesmap.com,59.3327,18.0656 -nostr.0x7e.xyz,47.4857,8.72929 -relay2.angor.io,48.1046,11.6002 -relay.jeffg.fyi:443,43.6532,-79.3832 -relay.routstr.com,59.4016,17.9455 -nostr.0x7e.xyz:443,47.4857,8.72929 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.nearhood.co.uk,51.5072,-0.127586 -relay.trotters.cc:443,43.6532,-79.3832 -nostr.myshosholoza.co.za:443,52.3913,4.66545 -nostr.bond,50.1109,8.68213 -nostr.faultables.net,43.6532,-79.3832 aeon.libretechsystems.xyz,55.486,9.86577 -cache.trustr.ing,43.6548,-79.3885 -relay.flashapp.me,43.6548,-79.3885 -dev-relay.nostreon.com,60.1699,24.9384 -0x-nostr-relay.fly.dev,37.7648,-122.432 -relay.ditto.pub,43.6532,-79.3832 -nostr.wecsats.io:443,43.6532,-79.3832 +mostro-p2p.tech,50.1109,8.68213 +nostr.wild-vibes.ts.net,48.8566,2.35222 +nostr.88mph.life,52.1941,-2.21905 +relay.nostrcheck.me,43.6532,-79.3832 +rilo.nostria.app,43.6532,-79.3832 +relay.bowlafterbowl.com,32.9483,-96.7299 +relay.nostr.place:443,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +relay.nostrian-conquest.com,41.223,-111.974 +herbstmeister.com,34.0549,-118.243 +nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 +nostr.red5d.dev,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.sharegap.net,43.6532,-79.3832 +reraw.pbla2fish.cc,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.sathoarder.com:443,48.5734,7.75211 +relay.veganostr.com:443,60.1699,24.9384 +relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 premium.primal.net,43.6532,-79.3832 -relay.notoshi.win,13.966,101.513 -no.str.cr,9.27447,-83.5797 -nostr-pub.wellorder.net,45.5201,-122.99 -satsage.xyz,37.3986,-121.964 -relay.nostrmap.net:443,60.1699,24.9384 -testnet.samt.st,43.6532,-79.3832 -nostr.plantroon.com:443,50.1013,8.62643 -relay.mwaters.net,50.9871,2.12554 -relay.wisp.talk,49.4543,11.0746 -nostr.computingcache.com:443,34.0356,-118.442 -public.crostr.com,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.notoshi.win:443,13.966,101.513 +relay.wavefunc.live:443,41.8781,-87.6298 +relay-rpi.edufeed.org:443,49.4521,11.0767 +kotukonostr.onrender.com,37.7775,-122.397 +bridge.tagomago.me,42.3601,-71.0589 +nostr.tadryanom.me:443,43.6532,-79.3832 +relay.gulugulu.moe:443,43.6532,-79.3832 +offchain.pub:443,39.1585,-94.5728 +relay.directsponsor.net,42.8864,-78.8784 +nostr.snowbla.de:443,60.1699,24.9384 From 054887bad8f111fa5ac1ee0e05c146e1c9b27f90 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:41:11 +0200 Subject: [PATCH 07/32] Fix request sync duplicate public messages (#707) Co-authored-by: CC --- .../bitchat/android/mesh/MessageHandler.kt | 4 +- .../android/protocol/BinaryProtocol.kt | 6 +- .../bitchat/android/services/AppStateStore.kt | 17 +++++- .../android/services/AppStateStoreTest.kt | 58 +++++++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt 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 63081d74..ecac572b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -7,6 +7,7 @@ import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.sync.PacketIdUtil import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* @@ -400,7 +401,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( - id = java.util.UUID.randomUUID().toString().uppercase(), + id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), @@ -416,6 +417,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Fallback: plain text val message = BitchatMessage( + id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 882c9a75..db17ba1c 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -36,7 +36,7 @@ object SpecialRecipients { /** * Binary packet format - 100% backward compatible with iOS version * - * Header (13 bytes for v1, 15 bytes for v2): + * Header (14 bytes for v1, 16 bytes for v2): * - Version: 1 byte * - Type: 1 byte * - TTL: 1 byte @@ -178,8 +178,8 @@ data class BitchatPacket( * Binary Protocol implementation - supports v1 and v2, backward compatible */ object BinaryProtocol { - private const val HEADER_SIZE_V1 = 13 - private const val HEADER_SIZE_V2 = 15 + private const val HEADER_SIZE_V1 = 14 + private const val HEADER_SIZE_V2 = 16 private const val SENDER_ID_SIZE = 8 private const val RECIPIENT_ID_SIZE = 8 private const val SIGNATURE_SIZE = 64 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 07f146bd..0997beff 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() + private val seenPublicMessageKeys = mutableSetOf() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -35,8 +36,10 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { - if (seenMessageIds.contains(msg.id)) return + val publicKey = publicMessageKey(msg) + if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return seenMessageIds.add(msg.id) + seenPublicMessageKeys.add(publicKey) _publicMessages.value = _publicMessages.value + msg } } @@ -100,10 +103,22 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() + seenPublicMessageKeys.clear() _peers.value = emptyList() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() } } + + private fun publicMessageKey(msg: BitchatMessage): String { + val sender = msg.senderPeerID ?: msg.sender + return listOf( + sender, + msg.timestamp.time.toString(), + msg.type.name, + msg.channel ?: "", + msg.content + ).joinToString("\u001F") + } } diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt new file mode 100644 index 00000000..3c680777 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -0,0 +1,58 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class AppStateStoreTest { + @Before + fun setUp() { + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.clear() + } + + @Test + fun `public timeline collapses request sync replay even when android message ids differ`() { + val timestamp = Date(1_700_000_000_000L) + val originalDelivery = BitchatMessage( + id = "random-id-from-first-delivery", + sender = "alice", + content = "hello from sync", + timestamp = timestamp, + senderPeerID = "1122334455667788" + ) + val requestSyncReplay = originalDelivery.copy(id = "different-random-id-from-replay") + + AppStateStore.addPublicMessage(originalDelivery) + AppStateStore.addPublicMessage(requestSyncReplay) + + assertEquals(listOf(originalDelivery), AppStateStore.publicMessages.value) + } + + @Test + fun `public timeline still keeps same content sent at different packet timestamps`() { + val first = BitchatMessage( + id = "first-packet-id", + sender = "alice", + content = "same text", + timestamp = Date(1_700_000_000_000L), + senderPeerID = "1122334455667788" + ) + val second = first.copy( + id = "second-packet-id", + timestamp = Date(first.timestamp.time + 1_000L) + ) + + AppStateStore.addPublicMessage(first) + AppStateStore.addPublicMessage(second) + + assertEquals(listOf(first, second), AppStateStore.publicMessages.value) + } +} From b079ae87423645f2da0636713e4afec9e62a64ea Mon Sep 17 00:00:00 2001 From: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:00:03 +0200 Subject: [PATCH 08/32] fix(sync): resolve hash alignment and early break bugs on GCS filter trimming (#708) * fix(sync): resolve hash alignment and early break bugs on GCS filter trimming * fix(sync): filter out zero bucket and deduplicate buckets before encoding * fix(sync): preserve zero-bucket mapping by clamping 0 to 1 during encoding and decoding --- .../com/bitchat/android/sync/GCSFilter.kt | 30 ++++--- .../bitchat/android/sync/GossipSyncManager.kt | 11 +-- .../com/bitchat/android/sync/GCSFilterTest.kt | 86 +++++++++++++++++++ 3 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt diff --git a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt index 3cc64c58..212def6a 100644 --- a/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt +++ b/app/src/main/java/com/bitchat/android/sync/GCSFilter.kt @@ -43,21 +43,29 @@ object GCSFilter { targetFpr: Double ): Params { val p = deriveP(targetFpr) - var nCap = estimateMaxElementsForSize(maxBytes, p) - val n = ids.size.coerceAtMost(nCap) - val selected = ids.take(n) - // Map to [0, M) - val m = (n.toLong() shl p) - val mapped = selected.map { id -> (h64(id) % m) }.sorted() + val nCap = estimateMaxElementsForSize(maxBytes, p) + var trimmedN = ids.size.coerceAtMost(nCap) + + var finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + var selected = ids.take(trimmedN) + var mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() var encoded = encode(mapped, p) + // If estimate was too optimistic, trim until it fits - var trimmedN = n while (encoded.size > maxBytes && trimmedN > 0) { trimmedN = (trimmedN * 9) / 10 // drop 10% - val mapped2 = mapped.take(trimmedN) - encoded = encode(mapped2, p) + finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L) + selected = ids.take(trimmedN) + mapped = selected.map { id -> + val v = h64(id) % finalM + if (v == 0L) 1L else v + }.distinct().sorted() + encoded = encode(mapped, p) } - val finalM = (trimmedN.toLong() shl p) + return Params(p = p, m = finalM, data = encoded) } @@ -96,7 +104,7 @@ object GCSFilter { return false } - private fun h64(id16: ByteArray): Long { + internal fun h64(id16: ByteArray): Long { val md = MessageDigest.getInstance("SHA-256") md.update(id16) val d = md.digest() diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 6e29aa79..5c786365 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -169,14 +169,9 @@ class GossipSyncManager( // Decode GCS into sorted set for membership checks val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) fun mightContain(id: ByteArray): Boolean { - val v = (GCSFilter.run { - // reuse hashing method from GCSFilter - val md = java.security.MessageDigest.getInstance("SHA-256"); - md.update(id); val d = md.digest(); - var x = 0L; for (i in 0 until 8) { x = (x shl 8) or (d[i].toLong() and 0xFF) } - (x and 0x7fff_ffff_ffff_ffffL) % request.m - }) - return GCSFilter.contains(sorted, v) + val v = GCSFilter.h64(id) % request.m + val nonZeroV = if (v == 0L) 1L else v + return GCSFilter.contains(sorted, nonZeroV) } // 1) Announcements: send latest per peerID if remote doesn't have them diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt new file mode 100644 index 00000000..3a9bfb62 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GCSFilterTest.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +class GCSFilterTest { + + @Test + fun testGCSFilterBasic() { + val random = Random(42) + val ids = List(20) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build filter with plenty of bytes (no trimming) + val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Filter should contain all encoded IDs", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterWithTrimming() { + val random = Random(42) + // 50 IDs + val ids = List(50) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Force trimming by setting maxBytes to a very small value (e.g., 20 bytes) + val maxBytes = 20 + val params = GCSFilter.buildFilter(ids, maxBytes = maxBytes, targetFpr = 0.01) + + // Ensure some trimming actually happened + assertTrue("Params data size should be <= maxBytes", params.data.size <= maxBytes) + + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Let's verify that the first trimmedN elements in ids are all matched + val trimmedN = (params.m ushr params.p).toInt() + assertTrue("At least some elements should have been encoded", trimmedN > 0) + + val retainedIds = ids.take(trimmedN) + for (id in retainedIds) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + assertTrue("Retained ID should be found in filter", GCSFilter.contains(sorted, nonZeroV)) + } + } + + @Test + fun testGCSFilterHandlesCollisionsAndZeroBucket() { + val random = Random(42) + // Generate a large number of IDs to guarantee collisions (mapping to the same bucket) and some zero-bucket mapping + val ids = List(200) { + val bytes = ByteArray(16) + random.nextBytes(bytes) + bytes + } + + // Build GCS filter - this should complete successfully without throwing repeat count exceptions or negative-count crashes + val params = GCSFilter.buildFilter(ids, maxBytes = 100, targetFpr = 0.05) + val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) + + // Verify elements are successfully stored and found (including those mapping to 0) + var foundCount = 0 + for (id in ids) { + val v = GCSFilter.h64(id) % params.m + val nonZeroV = if (v == 0L) 1L else v + if (GCSFilter.contains(sorted, nonZeroV)) { + foundCount++ + } + } + assertTrue("Should successfully decode and find elements after deduplication", foundCount > 0) + } +} From 13585a9a9caf1687dec66535f78e0d918e690585 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Jun 2026 07:39:17 +0000 Subject: [PATCH 09/32] Automated update of relay data - Sun Jun 14 07:39:17 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 843 +++++++++++++-------------- 1 file changed, 408 insertions(+), 435 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 97652073..d2af4c45 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,441 +1,414 @@ Relay URL,Latitude,Longitude -nostr.bitcoiner.social:443,47.6743,-117.112 -relay-dev.satlantis.io:443,40.8302,-74.1299 -relay.lightning.pub:443,39.0438,-77.4874 -ribo.us.nostria.app:443,43.6532,-79.3832 -relay.notoshi.win,13.3622,100.983 -openrelay.ziomc.com,50.0755,14.4378 -relay.agentry.com:443,42.8864,-78.8784 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.gulugulu.moe,43.6532,-79.3832 -nostr.thebiglake.org:443,32.71,-96.6745 -relay.fountain.fm,43.6532,-79.3832 -freelay.sovbit.host,60.1699,24.9384 -nostr.girino.org:443,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -relay.ohstr.com:443,43.6532,-79.3832 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.nostrdice.com,-33.8688,151.209 -bbw-nostr.xyz,41.5284,-87.4237 -cs-relay.nostrdev.com:443,50.4754,12.3683 -top.testrelay.top,43.6532,-79.3832 -relay.trotters.cc,43.6532,-79.3832 -blossom.gnostr.cloud,43.6532,-79.3832 -ribo.eu.nostria.app:443,43.6532,-79.3832 -public.crostr.com,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -strfry.bonsai.com:443,39.0438,-77.4874 -nostr.carroarmato0.be,50.914,3.21378 -relay.endfiat.money,59.3327,18.0656 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.myshosholoza.co.za:443,52.3913,4.66545 -relay.wellorder.net,45.5201,-122.99 -nostr.tagomago.me,42.3601,-71.0589 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.fckstate.net,59.3293,18.0686 -nostr.azzamo.net,52.2633,21.0283 -relay.0xchat.com,43.6532,-79.3832 -relayone.geektank.ai,39.1008,-94.5811 -relay.homeinhk.xyz,35.694,139.754 -nostr.nodesmap.com,59.3327,18.0656 -relay.islandbitcoin.com:443,12.8498,77.6545 -relay.mrmave.work,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -fanfares.nostr1.com:443,40.7057,-74.0136 -wot.shaving.kiwi,43.6532,-79.3832 -relay.nearhood.co.uk,51.5072,-0.127586 -relay.fundstr.me,42.3601,-71.0589 -syb.lol:443,43.6532,-79.3832 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -relay.nostx.io,43.6532,-79.3832 -no.str.cr,10.6352,-85.4378 -relay.jbnco.co,43.6532,-79.3832 -ribo.nostria.app:443,43.6532,-79.3832 -us-east.nostr.pikachat.org,39.0438,-77.4874 -nexus.libernet.app,43.6532,-79.3832 -nostr.dlcdevkit.com,40.0992,-83.1141 -nostr.debate.report,50.1109,8.68213 -str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -nostr.unkn0wn.world,46.8499,9.53287 -nostr.spicyz.io:443,43.6532,-79.3832 -relay.layer.systems:443,49.0291,8.35695 -nostr-relay.amethyst.name,39.0067,-77.4291 -slick.mjex.me,39.0418,-77.4744 -nostr.girino.org,43.6532,-79.3832 -nostr.quali.chat:443,60.1699,24.9384 -purplerelay.com:443,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.plebeian.market:443,50.1109,8.68213 -relay.samt.st,40.8302,-74.1299 -relay.mitchelltribe.com:443,39.0438,-77.4874 -nostr.0x7e.xyz,47.4949,8.71954 -relayone.soundhsa.com:443,39.1008,-94.5811 -nostr.thalheim.io:443,60.1699,24.9384 -relay.getsafebox.app:443,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -bucket.coracle.social,37.7775,-122.397 -nostr.carroarmato0.be:443,50.914,3.21378 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.lacompagniemaximus.com:443,45.3147,-73.8785 -relay-rpi.edufeed.org,49.4521,11.0767 -nostr.computingcache.com:443,34.0356,-118.442 -relay.lanavault.space:443,60.1699,24.9384 -relay.getsafebox.app,43.6532,-79.3832 -nrs-02.darkcloudarcade.com:443,39.9526,-75.1652 -nostr.hekster.org,37.3986,-121.964 -node.kommonzenze.de,49.4521,11.0767 -0x-nostr-relay.fly.dev,37.7648,-122.432 -speakeasy.cellar.social,49.4543,11.0746 -nostr.0x7e.xyz:443,47.4949,8.71954 -nostr.vulpem.com,49.4543,11.0746 -relay5.bitransfer.org,43.6532,-79.3832 -relay.inforsupports.com,43.6532,-79.3832 -relay.plebeian.market,50.1109,8.68213 -shu02.shugur.net,21.4902,39.2246 -nostr.easycryptosend.it,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.minibolt.info,43.6532,-79.3832 -nostr.pbfs.io:443,50.4754,12.3683 -nos.lol:443,50.4754,12.3683 -nostr.thebiglake.org,32.71,-96.6745 -relay.nostriot.com,41.5695,-83.9786 -strfry.shock.network:443,39.0438,-77.4874 -relay01.lnfi.network,35.6764,139.65 -r.0kb.io:443,32.789,-96.7989 -bcast.girino.org,43.6532,-79.3832 -nostr-relay.psfoundation.info:443,39.0438,-77.4874 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -testnet.samt.st,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -relay.binaryrobot.com:443,43.6532,-79.3832 -nostr.computingcache.com,34.0356,-118.442 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -schnorr.me,43.6532,-79.3832 -nostr.twinkle.lol,51.902,7.6657 -nostr.overmind.lol,43.6532,-79.3832 -relay.mmwaves.de:443,48.8575,2.35138 -relay2.veganostr.com,60.1699,24.9384 -nostr.mom,50.4754,12.3683 -nostr2.girino.org:443,43.6532,-79.3832 -wot.sudocarlos.com,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -nostr.thalheim.io,60.1699,24.9384 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -conduitl2.fly.dev,37.7648,-122.432 -relay.bullishbounty.com,43.6532,-79.3832 -myvoiceourstory.org,37.3598,-121.981 -relay.angor.io,48.1046,11.6002 -r.0kb.io,32.789,-96.7989 -nexus.libernet.app:443,43.6532,-79.3832 -us-east.nostr.pikachat.org:443,39.0438,-77.4874 -nostr.bitcoiner.social,47.6743,-117.112 -nostrelay.circum.space:443,52.6907,4.8181 -relayone.soundhsa.com,39.1008,-94.5811 -nostr.snowbla.de,60.1699,24.9384 -relay1.orangesync.tech,44.7839,-106.941 -nostr-2.21crypto.ch:443,47.5356,8.73209 -espelho.girino.org,43.6532,-79.3832 -nostr.blankfors.se,60.1699,24.9384 -relay.sigit.io:443,50.4754,12.3683 -relay.vrtmrz.net:443,43.6532,-79.3832 -nostr.wecsats.io:443,43.6532,-79.3832 -nostrcity-club.fly.dev,37.7648,-122.432 -nostrelay.circum.space,52.6907,4.8181 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -x.kojira.io,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -nostr.sathoarder.com,48.5734,7.75211 -prl.plus,55.7628,37.5983 -relay.cosmicbolt.net:443,37.3986,-121.964 -relay.nostu.be:443,40.4167,-3.70329 -cs-relay.nostrdev.com,50.4754,12.3683 -satsage.xyz,37.3986,-121.964 -relay.lab.rytswd.com:443,49.4543,11.0746 -rilo.nostria.app:443,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -relay.wavlake.com:443,41.2619,-95.8608 -relay.beginningend.com,35.2227,-97.4786 -relay.openresist.com:443,43.6532,-79.3832 -bitcoiner.social:443,47.6743,-117.112 -relay.notoshi.win:443,13.3622,100.983 -dev.relay.edufeed.org:443,49.4521,11.0767 -relay.cypherflow.ai:443,48.8575,2.35138 -relay.ru.ac.th,13.7607,100.627 -shu03.shugur.net,25.2048,55.2708 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.agorist.space:443,52.3734,4.89406 -relay02.lnfi.network,35.6764,139.65 -relay-testnet.k8s.layer3.news:443,37.3387,-121.885 -nostr.notribe.net:443,40.8302,-74.1299 -relay.ditto.pub,43.6532,-79.3832 -nostr.rikmeijer.nl,51.7111,5.36809 -blossom.gnostr.cloud:443,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -cache.trustr.ing,43.6548,-79.3885 -nostr.bitczat.pl,60.1699,24.9384 -relay.nostrverse.net,43.6532,-79.3832 -shu04.shugur.net,25.2048,55.2708 -eu.nostr.pikachat.org:443,49.4543,11.0746 -ribo.us.nostria.app,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -nostr.bitczat.pl:443,60.1699,24.9384 -nostr-relay.corb.net:443,38.8353,-104.822 -relay.libernet.app,43.6532,-79.3832 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.nostu.be,40.4167,-3.70329 -rele.speyhard.fi,51.5072,-0.127586 -relay.staging.plebeian.market,51.5072,-0.127586 -nostr.spicyz.io,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -x.kojira.io:443,43.6532,-79.3832 -relay.staging.plebeian.market:443,51.5072,-0.127586 -relay.sigit.io,50.4754,12.3683 -nostr.stakey.net:443,52.3676,4.90414 relay.nostr.blockhenge.com,39.0438,-77.4874 -relay.comcomponent.com,43.6532,-79.3832 -wot.nostr.place,43.6532,-79.3832 -relay.mostr.pub:443,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -no.str.cr:443,10.6352,-85.4378 -relay.chorus.community:443,48.5333,10.7 -relay.aarpia.com,37.3986,-121.964 -relay.nostrmap.net,60.1699,24.9384 -relay.snotr.nl:49999,52.0195,4.42946 -relay.bebond.net,43.6532,-79.3832 -relay.illuminodes.com,43.6532,-79.3832 -chat-relay.zap-work.com:443,43.6532,-79.3832 -testr.nymble.world,40.8054,-74.0241 -relay.underorion.se,50.1109,8.68213 -fanfares.nostr1.com,40.7057,-74.0136 -relay.damus.io,43.6532,-79.3832 -relay.nostriches.club,43.6532,-79.3832 -nostr-dev.wellorder.net,45.5201,-122.99 -relay2.angor.io,48.1046,11.6002 -relay.openfarmtools.org,60.1699,24.9384 -wot.makenomistakes.ca,43.7064,-79.3986 -relay.thecryptosquid.com,50.4754,12.3683 -test.thedude.cloud,50.1109,8.68213 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.olas.app:443,60.1699,24.9384 -strfry.bonsai.com,39.0438,-77.4874 -relayrs.notoshi.win,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -wot.utxo.one,43.6532,-79.3832 -relay.angor.io:443,48.1046,11.6002 -relay.dwadziesciajeden.pl,52.2297,21.0122 -soloco.nl,43.6532,-79.3832 -armada.sharegap.net,43.6532,-79.3832 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.beginningend.com:443,35.2227,-97.4786 -relay.nostr.place,43.6532,-79.3832 -relay.layer.systems,49.0291,8.35695 -adre.su,59.9311,30.3609 -relay.0xchat.com:443,43.6532,-79.3832 -nostr.infero.net,35.6764,139.65 -relay.typedcypher.com:443,51.5072,-0.127586 -relay.mostro.network:443,40.8302,-74.1299 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -relay.mitchelltribe.com,39.0438,-77.4874 -relay.mostr.pub,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -nostr.tac.lol:443,47.4748,-122.273 -nostr.hekster.org:443,37.3986,-121.964 -relay.satmaxt.xyz:443,43.6532,-79.3832 -relay.cypherflow.ai,48.8575,2.35138 -relay.zone667.com:443,60.1699,24.9384 -relay.jeffg.fyi:443,43.6532,-79.3832 -relay.agorist.space,52.3734,4.89406 -nostr.spaceshell.xyz:443,43.6532,-79.3832 -top.testrelay.top:443,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.laantungir.net:443,-19.4692,-42.5315 -relay.islandbitcoin.com,12.8498,77.6545 -purplerelay.com,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -nostr.wecsats.io,43.6532,-79.3832 -nostr.2b9t.xyz,34.0549,-118.243 -nostr.quali.chat,60.1699,24.9384 -relay.dreamith.to,43.6532,-79.3832 -nostr.islandarea.net:443,35.4669,-97.6473 -relay.primal.net,43.6532,-79.3832 -eu.nostr.pikachat.org,49.4543,11.0746 -relay.nostr.net,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.mulatta.io,37.5665,126.978 -nittom.nostr1.com,40.7057,-74.0136 -relay2.orangesync.tech,40.7128,-74.006 -relay.artx.market,43.6548,-79.3885 -relay.wavefunc.live,41.8781,-87.6298 -bitchat.nostr1.com,40.7057,-74.0136 -relay.ditto.pub:443,43.6532,-79.3832 -relay.wisp.talk,49.4543,11.0746 -nostrelites.org,41.8781,-87.6298 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.chorus.community,48.5333,10.7 -relay.plebchain.club,43.6532,-79.3832 -nostr-relay.amethyst.name:443,39.0067,-77.4291 -relay.lanacoin-eternity.com,40.8302,-74.1299 -relay.edufeed.org:443,49.4521,11.0767 -relay.lacompagniemaximus.com,45.3147,-73.8785 -relay.dreamith.to:443,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.n7ekb.net:443,47.4941,-122.294 -relay.dyne.org,49.0291,8.35705 -nostr.janx.com,43.6532,-79.3832 -relay.trustr.ing,43.6548,-79.3885 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.wisp.talk:443,49.4543,11.0746 -yabu.me,35.6092,139.73 -bcast.seutoba.com.br,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -nos.lol,50.4754,12.3683 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.endfiat.money:443,59.3327,18.0656 -relay.tapestry.ninja,40.8054,-74.0241 -relay.lab.rytswd.com,49.4543,11.0746 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.damus.io:443,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -nostr-01.yakihonne.com,1.32123,103.695 -relay.nostriot.com:443,41.5695,-83.9786 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr.aruku.ovh,1.27994,103.849 -nostr.chaima.info,50.1109,8.68213 -ynostr.yael.at,60.1699,24.9384 -ynostr.yael.at:443,60.1699,24.9384 -nostr-01.yakihonne.com:443,1.32123,103.695 -spookstr2.nostr1.com,40.7057,-74.0136 -relay.trustr.ing:443,43.6548,-79.3885 -dev.relay.edufeed.org,49.4521,11.0767 -relay2.angor.io:443,48.1046,11.6002 -nostr.azzamo.net:443,52.2633,21.0283 -offchain.bostr.online,43.6532,-79.3832 +relay.agorist.space:443,52.3734,4.89406 relay.zone667.com,60.1699,24.9384 -relay.veganostr.com,60.1699,24.9384 -vault.iris.to:443,43.6532,-79.3832 -relay.artx.market:443,43.6548,-79.3885 -wot.rejecttheframe.xyz,43.6532,-79.3832 -nostr.pbfs.io,50.4754,12.3683 -relay.mostro.network,40.8302,-74.1299 -strfry.shock.network,39.0438,-77.4874 -relay.klabo.world,47.2343,-119.853 -relay.fountain.fm:443,43.6532,-79.3832 -nostrja-kari.heguro.com,43.6532,-79.3832 -relaisnostr.trivaco.fr,48.5734,7.75211 -offchain.pub,39.1585,-94.5728 -relay.degmods.com,50.4754,12.3683 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.paulstephenborile.com,49.4543,11.0746 -nittom.nostr1.com:443,40.7057,-74.0136 -dev-relay.nostreon.com,60.1699,24.9384 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -relay.jeffg.fyi,43.6532,-79.3832 -relay.laantungir.net,-19.4692,-42.5315 -nostr.data.haus:443,50.4754,12.3683 -wot.codingarena.top,50.4754,12.3683 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.libernet.app:443,43.6532,-79.3832 -nostr.ps1829.com,33.8851,130.883 -wot.dergigi.com,64.1476,-21.9392 -relay.olas.app,60.1699,24.9384 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -nostr.data.haus,50.4754,12.3683 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -relay.guggero.org,46.5971,9.59652 -testnet-relay.samt.st:443,40.8302,-74.1299 -thecitadel.nostr1.com,40.7057,-74.0136 -nostr.ps1829.com:443,33.8851,130.883 -nostr-relay.corb.net,38.8353,-104.822 -relay.npubhaus.com,43.6532,-79.3832 -nostr.21crypto.ch,47.5356,8.73209 -nostr.tadryanom.me,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3913,4.66545 -relay.bitmacro.cloud,43.6532,-79.3832 -ribo.nostria.app,43.6532,-79.3832 -relay.vrtmrz.net,43.6532,-79.3832 -syb.lol,43.6532,-79.3832 -relay.bebond.net:443,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -nostr-2.21crypto.ch,47.5356,8.73209 -nostrcity-club.fly.dev:443,37.7648,-122.432 -articles.layer3.news:443,37.3387,-121.885 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -nostr-relay.zimage.com,34.0549,-118.243 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.mwaters.net,50.9871,2.12554 -nostr.liberty.fans,36.9104,-89.5875 -spookstr2.nostr1.com:443,40.7057,-74.0136 -nostr.chaima.info:443,50.1109,8.68213 -schnorr.me:443,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -nostr.bond,50.1109,8.68213 -wot.nostr.party,36.1659,-86.7844 -temp.iris.to,43.6532,-79.3832 -relay.lotek-distro.com,43.6532,-79.3832 -relay.minibolt.info:443,43.6532,-79.3832 -relay.lanavault.space,60.1699,24.9384 -relay.mmwaves.de,48.8575,2.35138 -social.amanah.eblessing.co,48.1046,11.6002 -nostr2.thalheim.io,49.4543,11.0746 relay.typedcypher.com,51.5072,-0.127586 -relay.cosmicbolt.net,37.3986,-121.964 -relayrs.notoshi.win:443,43.6532,-79.3832 -nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -relay.nostrmap.net:443,60.1699,24.9384 -relay.bullishbounty.com:443,43.6532,-79.3832 -nostr.oxtr.dev:443,50.4754,12.3683 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -vault.iris.to,43.6532,-79.3832 -relay.mccormick.cx:443,52.3563,4.95714 -relay.toastr.net,40.8054,-74.0241 -nostr.hifish.org,47.4244,8.57658 -speakeasy.cellar.social:443,49.4543,11.0746 -relay.wavlake.com,41.2619,-95.8608 -testnet-relay.samt.st,40.8302,-74.1299 -aeon.libretechsystems.xyz,55.486,9.86577 -mostro-p2p.tech,50.1109,8.68213 -nostr.wild-vibes.ts.net,48.8566,2.35222 -nostr.88mph.life,52.1941,-2.21905 -relay.nostrcheck.me,43.6532,-79.3832 -rilo.nostria.app,43.6532,-79.3832 -relay.bowlafterbowl.com,32.9483,-96.7299 -relay.nostr.place:443,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -relay.nostrian-conquest.com,41.223,-111.974 -herbstmeister.com,34.0549,-118.243 -nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 -nostr.red5d.dev,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -strfry.apps3.slidestr.net,40.4167,-3.70329 -relay.sharegap.net,43.6532,-79.3832 -reraw.pbla2fish.cc,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.sathoarder.com:443,48.5734,7.75211 -relay.veganostr.com:443,60.1699,24.9384 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -premium.primal.net,43.6532,-79.3832 -relay.wavefunc.live:443,41.8781,-87.6298 -relay-rpi.edufeed.org:443,49.4521,11.0767 -kotukonostr.onrender.com,37.7775,-122.397 -bridge.tagomago.me,42.3601,-71.0589 -nostr.tadryanom.me:443,43.6532,-79.3832 -relay.gulugulu.moe:443,43.6532,-79.3832 +wot.nostr.place,43.6532,-79.3832 +nostr.myshosholoza.co.za:443,52.3913,4.66545 +relay.homeinhk.xyz,35.694,139.754 +nostr.ps1829.com:443,33.8851,130.883 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.underorion.se,50.1109,8.68213 +nostr-relay.amethyst.name:443,39.0067,-77.4291 +relay.mitchelltribe.com:443,39.0438,-77.4874 +bitchat.nostr1.com,40.7057,-74.0136 +0x-nostr-relay.fly.dev,37.7648,-122.432 +nostr.blankfors.se,60.1699,24.9384 +relay.solife.me,43.6532,-79.3832 +relay.snotr.nl:49999,52.0195,4.42946 +nostr-relay.corb.net:443,38.8353,-104.822 +relay.klabo.world,47.2343,-119.853 +relay02.lnfi.network,35.6764,139.65 +ribo.us.nostria.app,43.6532,-79.3832 +nostr.quali.chat:443,60.1699,24.9384 offchain.pub:443,39.1585,-94.5728 -relay.directsponsor.net,42.8864,-78.8784 +pool.libernet.app,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +soloco.nl,43.6532,-79.3832 +relay.ru.ac.th,13.7607,100.627 +schnorr.me,43.6532,-79.3832 +ec2.f7z.io,60.1699,24.9384 +relay.gulugulu.moe:443,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +rilo.nostria.app,43.6532,-79.3832 +adre.su,59.9311,30.3609 +ribo.eu.nostria.app:443,43.6532,-79.3832 +vault.iris.to,43.6532,-79.3832 +strfry.shock.network:443,39.0438,-77.4874 +x.kojira.io,43.6532,-79.3832 +ribo.nostria.app:443,43.6532,-79.3832 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.chorus.community:443,48.5333,10.7 +nostr.aruku.ovh,1.27994,103.849 +relay.internationalright-wing.org:443,-22.5022,-48.7114 +relay.getsafebox.app,43.6532,-79.3832 +relay-dev.satlantis.io:443,40.8302,-74.1299 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.bullishbounty.com:443,43.6532,-79.3832 +relay.nostrian-conquest.com:443,41.223,-111.974 +relay.kilombino.com,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relay.erybody.com,41.4513,-81.7021 +nostr.na.social,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +nostr.tagomago.me,42.3601,-71.0589 +relay.routstr.com,59.4016,17.9455 +relay.wellorder.net,45.5201,-122.99 +node.kommonzenze.de,49.4521,11.0767 +nostr.pbfs.io,50.4754,12.3683 +fanfares.nostr1.com:443,40.7057,-74.0136 +relay.btcforplebs.com,43.6532,-79.3832 +relay.nostrian-conquest.com,41.223,-111.974 +strfry.shock.network,39.0438,-77.4874 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.fckstate.net,59.3293,18.0686 +relayone.geektank.ai,39.1008,-94.5811 +relayrs.notoshi.win:443,43.6532,-79.3832 +wot.nostr.party,36.1659,-86.7844 +testnet.samt.st,43.6532,-79.3832 +nostr.stakey.net:443,52.3676,4.90414 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.snort.social,53.3498,-6.26031 +social.amanah.eblessing.co,48.1046,11.6002 +relay.veganostr.com:443,60.1699,24.9384 +relay2.angor.io:443,48.1046,11.6002 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.ps1829.com,33.8851,130.883 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +wot.shaving.kiwi,43.6532,-79.3832 +relay.typedcypher.com:443,51.5072,-0.127586 +relay.openfarmtools.org,60.1699,24.9384 +wot.brightbolt.net,47.6735,-116.781 +relay.libernet.app,43.6532,-79.3832 +slick.mjex.me,39.0418,-77.4744 +relay.nostr.net,43.6532,-79.3832 +nostr.chaima.info,50.1109,8.68213 +relay.wavlake.com:443,41.2619,-95.8608 +relay.cosmicbolt.net,37.3986,-121.964 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.nostr.place:443,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +testnet-relay.samt.st:443,40.8302,-74.1299 +relay.islandbitcoin.com,12.8498,77.6545 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +relay.ohstr.com:443,43.6532,-79.3832 +nostr.88mph.life,52.1941,-2.21905 +relay.layer.systems:443,49.0291,8.35695 +schnorr.me:443,43.6532,-79.3832 +nostr-relay.psfoundation.info:443,39.0438,-77.4874 nostr.snowbla.de:443,60.1699,24.9384 +relay.olas.app,60.1699,24.9384 +espelho.girino.org,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +relayone.geektank.ai:443,39.1008,-94.5811 +nostr.easycryptosend.it,43.6532,-79.3832 +thecitadel.nostr1.com,40.7057,-74.0136 +relay.gulugulu.moe,43.6532,-79.3832 +relay.lab.rytswd.com:443,49.4543,11.0746 +nos.lol,50.4754,12.3683 +relay.plebeian.market:443,50.1109,8.68213 +relay.getsafebox.app:443,43.6532,-79.3832 +relay.mitchelltribe.com,39.0438,-77.4874 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.trotters.cc,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.ditto.pub:443,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +herbstmeister.com,34.0549,-118.243 +dev.relay.edufeed.org:443,49.4521,11.0767 +relay2.veganostr.com,60.1699,24.9384 +nostr.purpura.cloud,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.paulstephenborile.com,49.4543,11.0746 +relay.0xchat.com,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +nostrcity-club.fly.dev:443,37.7648,-122.432 +relay.endfiat.money:443,59.3327,18.0656 +relay.samt.st,40.8302,-74.1299 +relay.mostr.pub,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostrride.io,37.3986,-121.964 +mostro-p2p.tech,50.1109,8.68213 +relay.toastr.net,40.8054,-74.0241 +reraw.pbla2fish.cc,43.6532,-79.3832 +nostr.self-determined.de,53.495,10.2542 +temp.iris.to,43.6532,-79.3832 +relay.openresist.com:443,43.6532,-79.3832 +relay.mostro.network:443,40.8302,-74.1299 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +nostr.chaima.info:443,50.1109,8.68213 +dev.relay.edufeed.org,49.4521,11.0767 +nostr.computingcache.com:443,34.0356,-118.442 +relay.sigit.io,50.4754,12.3683 +nostr.oxtr.dev:443,50.4754,12.3683 +relay.angor.io,48.1046,11.6002 +kotukonostr.onrender.com,37.7775,-122.397 +zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832 +shu01.shugur.net,21.4902,39.2246 +relayone.soundhsa.com:443,39.1008,-94.5811 +relay2.angor.io,48.1046,11.6002 +nittom.nostr1.com,40.7057,-74.0136 +nostr.girino.org:443,43.6532,-79.3832 +nexus.libernet.app,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +nostr2.girino.org,43.6532,-79.3832 +relaisnostr.trivaco.fr,49.4282,10.9796 +relay.edufeed.org:443,49.4521,11.0767 +relay.binaryrobot.com,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +relay.endfiat.money,59.3327,18.0656 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 +nostr.liberty.fans,36.9104,-89.5875 +relay.mmwaves.de,48.8575,2.35138 +relay.inforsupports.com,43.6532,-79.3832 +dev-nostr.bityacht.io,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.0xchat.com:443,43.6532,-79.3832 +relay.agentry.com,42.8864,-78.8784 +relay.mrmave.work,43.6532,-79.3832 +nostr.carroarmato0.be,50.914,3.21378 +nostr.myshosholoza.co.za,52.3913,4.66545 +nostr.thebiglake.org,32.71,-96.6745 +relay.kalcafe.xyz,37.3986,-121.964 +nostr.openhoofd.nl,51.5717,3.70417 +relayone.soundhsa.com,39.1008,-94.5811 +relay.bullishbounty.com,43.6532,-79.3832 +satsage.xyz,37.3986,-121.964 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +bridge.tagomago.me,42.3601,-71.0589 +relay.sigit.io:443,50.4754,12.3683 +relay.mypathtofire.de,42.8864,-78.8784 +relay.dreamith.to,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay.lanacoin-eternity.com,40.8302,-74.1299 +nostr.2b9t.xyz:443,34.0549,-118.243 +nostr.notribe.net,40.8302,-74.1299 +relay.nostrdice.com,-33.8688,151.209 +nostr-relay.amethyst.name,39.0067,-77.4291 +relay.mccormick.cx:443,52.3563,4.95714 +nostr.4rs.nl,49.0291,8.35696 +relay.dwadziesciajeden.pl,52.2297,21.0122 +spookstr2.nostr1.com:443,40.7057,-74.0136 +relay.vrtmrz.net:443,43.6532,-79.3832 +relay.minibolt.info:443,43.6532,-79.3832 +nostrelites.org,41.8781,-87.6298 +relay.nostar.org,43.6532,-79.3832 +nostriches.club,43.6532,-79.3832 +rele.speyhard.fi,51.5072,-0.127586 +wot.utxo.one,43.6532,-79.3832 +relay1.orangesync.tech,44.7839,-106.941 +damus.bostr.online,43.6532,-79.3832 +relay.islandbitcoin.com:443,12.8498,77.6545 +relay.mulatta.io,37.5665,126.978 +strfry.openhoofd.nl,51.5717,3.70417 +cs-relay.nostrdev.com,50.4754,12.3683 +nostr.bitcoiner.social,47.6743,-117.112 +relay.thecryptosquid.com,50.4754,12.3683 +nostr.computingcache.com,34.0356,-118.442 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay5.bitransfer.org,43.6532,-79.3832 +relay.damus.io,43.6532,-79.3832 +strfry.ymir.cloud,43.6532,-79.3832 +dev.relay.stream,43.6532,-79.3832 +relay.vrtmrz.net,43.6532,-79.3832 +myvoiceourstory.org,37.3598,-121.981 +strfry.openhoofd.nl:443,51.5717,3.70417 +spookstr2.nostr1.com,40.7057,-74.0136 +bitcoiner.social:443,47.6743,-117.112 +aeon.libretechsystems.xyz,55.486,9.86577 +relayrs.notoshi.win,43.6532,-79.3832 +nostr.pbfs.io:443,50.4754,12.3683 +public.crostr.com,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +rilo.nostria.app:443,43.6532,-79.3832 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +relay.arx-ccn.com,50.4754,12.3683 +conduitl2.fly.dev,37.7648,-122.432 +relay.nostrmap.net,60.1699,24.9384 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +nostrelay.circum.space,52.6907,4.8181 +syb.lol:443,43.6532,-79.3832 +relay.artx.market,43.6548,-79.3885 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +blossom.gnostr.cloud:443,43.6532,-79.3832 +x.kojira.io:443,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay-rpi.edufeed.org,49.4521,11.0767 +testr.nymble.world,40.8054,-74.0241 +relay.beginningend.com:443,35.2227,-97.4786 +relay.openresist.com,43.6532,-79.3832 +relay.minibolt.info,43.6532,-79.3832 +nostr.thalheim.io,60.1699,24.9384 +relay.notoshi.win:443,13.3622,100.983 +vault.iris.to:443,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +relay.nearhood.co.uk,51.5072,-0.127586 +nostr.overmind.lol,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nrs-01.darkcloudarcade.com,39.1008,-94.5811 +relay.artx.market:443,43.6548,-79.3885 +nos.lol:443,50.4754,12.3683 +relay.illuminodes.com,43.6532,-79.3832 +relay.laantungir.net,-19.4692,-42.5315 +relay.plebchain.club,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostrja-kari.heguro.com,43.6532,-79.3832 +nostrsgp.notribe.net,1.32123,103.695 +nostr.thalheim.io:443,60.1699,24.9384 +prl.plus,55.7628,37.5983 +nostr.carroarmato0.be:443,50.914,3.21378 +nostr.nodesmap.com,59.3327,18.0656 +bucket.coracle.social,37.7775,-122.397 +relay.lightning.pub,39.0438,-77.4874 +relay.dreamith.to:443,43.6532,-79.3832 +nostr.na.social:443,43.6532,-79.3832 +nostr.debate.report,50.1109,8.68213 +relay2.orangesync.tech,40.7128,-74.006 +relay.comcomponent.com,43.6532,-79.3832 +nostr-check.me,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +nostr.21crypto.ch,47.5356,8.73209 +relay.sincensura.org,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5022,-48.7114 +antiprimal.net,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.nostr.place,43.6532,-79.3832 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +nostr.wecsats.io:443,43.6532,-79.3832 +cs-relay.nostrdev.com:443,50.4754,12.3683 +nostr.azzamo.net,52.2633,21.0283 +relay.cypherflow.ai:443,48.8575,2.35138 +nostr.hifish.org,47.4244,8.57658 +relay.wavefunc.live:443,41.8781,-87.6298 +nostr.tac.lol:443,47.4748,-122.273 +wot.dergigi.com,64.1476,-21.9392 +nostr.wecsats.io,43.6532,-79.3832 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.sathoarder.com:443,48.5734,7.75211 +cache.trustr.ing,43.6548,-79.3885 +relay.directsponsor.net,42.8864,-78.8784 +strfry.bonsai.com:443,39.0438,-77.4874 +nostr-01.yakihonne.com,1.32123,103.695 +relay.satlantis.io,40.8054,-74.0241 +nostr-2.21crypto.ch:443,47.5356,8.73209 +offchain.bostr.online,43.6532,-79.3832 +test.thedude.cloud,50.1109,8.68213 +relay.mostr.pub:443,43.6532,-79.3832 +relay.libernet.app:443,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.plebeian.market,50.1109,8.68213 +wot.makenomistakes.ca,43.7064,-79.3986 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +ithurtswhenip.ee,51.5072,-0.127586 +relay.staging.commonshub.brussels,49.4543,11.0746 +relay.beginningend.com,35.2227,-97.4786 +strfry.bonsai.com,39.0438,-77.4874 +nostrelay.circum.space:443,52.6907,4.8181 +relay.mostro.network,40.8302,-74.1299 +dev-relay.nostreon.com,60.1699,24.9384 +relay.fundstr.me,42.3601,-71.0589 +relay.mmwaves.de:443,48.8575,2.35138 +relay.nostu.be,40.4167,-3.70329 +relay.nostx.io,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.lightning.pub:443,39.0438,-77.4874 +relay.nostr-check.me,43.6532,-79.3832 +nostr.bitcoiner.social:443,47.6743,-117.112 +ribo.nostria.app,43.6532,-79.3832 +offchain.pub,39.1585,-94.5728 +relay.layer.systems,49.0291,8.35695 +nostr.dlcdevkit.com,40.0992,-83.1141 +relay.angor.io:443,48.1046,11.6002 +relay.mwaters.net,50.9871,2.12554 +relay.nostriot.com:443,41.5695,-83.9786 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.notribe.net:443,40.8302,-74.1299 +freelay.sovbit.host,60.1699,24.9384 +syb.lol,43.6532,-79.3832 +portal-relay.pareto.space,49.0291,8.35696 +wot.codingarena.top,50.4754,12.3683 +nostr-2.21crypto.ch,47.5356,8.73209 +nostr.hifish.org:443,47.4244,8.57658 +relay.zone667.com:443,60.1699,24.9384 +relay.lab.rytswd.com,49.4543,11.0746 +nas01xanthosnet.synology.me:7778,47.1285,8.74735 +relay.primal.net,43.6532,-79.3832 +relay.bitmacro.cloud,43.6532,-79.3832 +chat-relay.zap-work.com:443,43.6532,-79.3832 +relay.fountain.fm:443,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +nexus.libernet.app:443,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr.snowbla.de,60.1699,24.9384 +strfry.apps3.slidestr.net,40.4167,-3.70329 +nostr.spicyz.io:443,43.6532,-79.3832 +nostr.data.haus:443,50.4754,12.3683 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +purplerelay.com:443,43.6532,-79.3832 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.nostrmap.net:443,60.1699,24.9384 +relay.ohstr.com,43.6532,-79.3832 +r.0kb.io:443,32.789,-96.7989 +uncouth-panning-paternal.ngrok-free.dev,40.0992,-83.1141 +wot.sudocarlos.com,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +ribo.us.nostria.app:443,43.6532,-79.3832 +no.str.cr,10.6352,-85.4378 +relay.notoshi.win,13.3622,100.983 +nostr.spicyz.io,43.6532,-79.3832 +relay.veganostr.com,60.1699,24.9384 +r.0kb.io,32.789,-96.7989 +relay-testnet.k8s.layer3.news:443,37.3387,-121.885 +relay.cosmicbolt.net:443,37.3986,-121.964 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.sharegap.net,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr2.girino.org:443,43.6532,-79.3832 +relay.cypherflow.ai,48.8575,2.35138 +nostr-relay.corb.net,38.8353,-104.822 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.lanavault.space,60.1699,24.9384 +relay.wisp.talk,49.4543,11.0746 +relay.wisp.talk:443,49.4543,11.0746 +no.str.cr:443,10.6352,-85.4378 +nostr.hekster.org:443,37.3986,-121.964 +nostr-01.yakihonne.com:443,1.32123,103.695 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +relay.guggero.org,46.5971,9.59652 +nostr.twinkle.lol,51.902,7.6657 +nostr.janx.com,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +relay.nostriot.com,41.5695,-83.9786 +nostr.hekster.org,37.3986,-121.964 +fanfares.nostr1.com,40.7057,-74.0136 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 +relay.olas.app:443,60.1699,24.9384 +armada.sharegap.net,43.6532,-79.3832 +nostr.islandarea.net:443,35.4669,-97.6473 +relay0.gfcom.info,13.8434,100.363 +relay.chorus.community,48.5333,10.7 +nittom.nostr1.com:443,40.7057,-74.0136 +relay.staging.plebeian.market:443,51.5072,-0.127586 +chat-relay.zap-work.com,43.6532,-79.3832 +relay-rpi.edufeed.org:443,49.4521,11.0767 +relay.lanavault.space:443,60.1699,24.9384 +relay.binaryrobot.com:443,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +relay.bornheimer.app,51.5072,-0.127586 +treuzkas.branruz.com,48.8575,2.35138 +nostr-dev.wellorder.net,45.5201,-122.99 +public.crostr.com:443,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +articles.layer3.news:443,37.3387,-121.885 +nostr.azzamo.net:443,52.2633,21.0283 +premium.primal.net,43.6532,-79.3832 From 6189f0cb84fd11adbd2748191b77a4aed8b6f5cf Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:35:37 +0200 Subject: [PATCH 10/32] fix(security): apply iOS audit parity fixes (#709) Co-authored-by: CC --- .../android/geohash/LocationChannelManager.kt | 1 + .../com/bitchat/android/noise/NoiseSession.kt | 7 +- .../bitchat/android/nostr/NostrProtocol.kt | 16 +++- .../com/bitchat/android/ui/ChatViewModel.kt | 5 ++ app/src/test/kotlin/android/util/Base64.kt | 23 +++++ .../android/nostr/NostrProtocolTest.kt | 85 +++++++++++++++++++ 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/android/util/Base64.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 222cc54e..ea595697 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -539,6 +539,7 @@ class LocationChannelManager private constructor(private val context: Context) { fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() _selectedChannel.value = ChannelID.Mesh + _teleported.value = false Log.d(TAG, "Cleared persisted channel selection") } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index dc4a3d50..332dc78a 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -572,7 +572,12 @@ class NoiseSession( } val (extractedNonce, ciphertext) = nonceAndCiphertext - + + if (ciphertext.size < receiveCipher!!.macLength) { + Log.w(TAG, "Ciphertext too short: ${ciphertext.size} < ${receiveCipher!!.macLength}") + throw SessionError.DecryptionFailed + } + // Validate nonce with sliding window replay protection if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) { Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID") diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 1e0b51f6..501cf60c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -73,14 +73,24 @@ object NostrProtocol { } Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...") - + + if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) { + Log.w(TAG, "❌ Invalid NIP-17 seal signature") + return null + } + // 2. Open the seal val rumor = openSeal(seal, recipientIdentity.privateKeyHex) ?: run { Log.w(TAG, "❌ Failed to open seal") return null } - + + if (seal.pubkey != rumor.pubkey) { + Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey") + return null + } + Log.v(TAG, "Successfully opened seal") Triple(rumor.content, rumor.pubkey, rumor.createdAt) @@ -227,7 +237,7 @@ object NostrProtocol { content = encrypted ) - // Sign with the ephemeral key + // NIP-17 requires the seal to be signed by the sender identity key. return seal.sign(senderPrivateKey) } 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 786e7e91..a1f13a7a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -938,6 +938,11 @@ class ChatViewModel( store.clearAll() } catch (_: Exception) { } + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + locationManager.clearPersistedChannel() + } catch (_: Exception) { } + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") diff --git a/app/src/test/kotlin/android/util/Base64.kt b/app/src/test/kotlin/android/util/Base64.kt new file mode 100644 index 00000000..a997497a --- /dev/null +++ b/app/src/test/kotlin/android/util/Base64.kt @@ -0,0 +1,23 @@ +@file:JvmName("Base64") + +package android.util + +const val DEFAULT: Int = 0 +const val NO_WRAP: Int = 2 + +fun encodeToString(input: ByteArray, flags: Int): String { + val encoder = if (flags and NO_WRAP != 0) { + java.util.Base64.getEncoder() + } else { + java.util.Base64.getMimeEncoder() + } + return encoder.encodeToString(input) +} + +fun decode(input: String, flags: Int): ByteArray { + return if (flags and NO_WRAP != 0) { + java.util.Base64.getDecoder().decode(input) + } else { + java.util.Base64.getMimeDecoder().decode(input) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt new file mode 100644 index 00000000..a5bd9561 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.nostr + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrProtocolTest { + private val gson = Gson() + + @Test + fun decryptPrivateMessage_acceptsAuthenticatedSeal() { + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = NostrProtocol.createPrivateMessage( + content = "bitchat1:test", + recipientPubkey = recipient.publicKeyHex, + senderIdentity = sender + ).single() + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertEquals("bitchat1:test", decrypted?.first) + assertEquals(sender.publicKeyHex, decrypted?.second) + } + + @Test + fun decryptPrivateMessage_rejectsSealWhoseSignerDoesNotMatchRumor() { + val claimedSender = NostrIdentity.generate() + val attacker = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val giftWrap = forgedGiftWrap( + content = "bitchat1:forged", + claimedSender = claimedSender, + sealSigner = attacker, + recipient = recipient + ) + + val decrypted = NostrProtocol.decryptPrivateMessage(giftWrap, recipient) + + assertNull(decrypted) + } + + private fun forgedGiftWrap( + content: String, + claimedSender: NostrIdentity, + sealSigner: NostrIdentity, + recipient: NostrIdentity + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = claimedSender.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sealSigner.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sealSigner.publicKeyHex, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sealSigner.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = NostrCrypto.randomizeTimestampUpToPast(), + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +} From 3a983c57671eacf1ff9c5bcaba26f5c756ed6402 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:11:33 +0200 Subject: [PATCH 11/32] Wifi aware refactor mesh core (#711) * wifi aware wip * wifi aware wip * starting to work * werk * dms work * wip * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * Fix wifi aware socket binding 2 (#536) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. --------- Co-authored-by: GitHub Action Co-authored-by: aidenvalue <> * Fix wifi aware routing (#534) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * fix wifi aware routing --------- Co-authored-by: GitHub Action Co-authored-by: aidenvalue <> * unify connection tracker for ble and wifi (#537) * unify connection tracker for ble and wifi * cleanup nicely * Wifi aware skip bluetooth (#538) * unify connection tracker for ble and wifi * bluetooth optional * refactor mesh core * tests * share gossip * refresh peer list more often * patches: wifi aware mesh refactor core (#549) * Refactor WifiAware memory management and lifecycle handling * Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog * fix(wifiaware): allow WifiAwareController to restart service if session drops * Enhance WiFi Aware logging with network request timeouts and detailed callback status * Ensure explicit release of WiFi Aware network callbacks on failure or disconnection --------- Co-authored-by: aidenvalue <> * fix: wifi aware mesh private dms (#561) * fix(wifi-aware): restore peer check and remove aggressive session restarts * synchronized access to sockets. * clear old peer socket `onClientConnected` if one was found. * address second round of review * fix: Restart publish session on termination in WifiAwareMeshService * fix: cleanup connection tracker resources when handling peer disconnection without active socket --------- Co-authored-by: aidenvalu3 <> * fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware - Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks. - Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause. - Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance. - Deduplicate persistence logic from MainActivity delegate. * wifi aware working * more robust * fragments work * new files * wifi improvements * handshake works * improve * wifi mesh more robust * fix review * check for support * wifi aware fixes --------- Co-authored-by: aidenvalue <> Co-authored-by: GitHub Action Co-authored-by: aidenvalu3 Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Co-authored-by: CC --- app/src/main/AndroidManifest.xml | 9 + .../com/bitchat/android/BitchatApplication.kt | 6 + .../java/com/bitchat/android/MainActivity.kt | 58 +- .../java/com/bitchat/android/MainViewModel.kt | 7 + .../mesh/BluetoothConnectionManager.kt | 96 +- .../mesh/BluetoothConnectionTracker.kt | 154 +- .../mesh/BluetoothGattClientManager.kt | 173 +- .../mesh/BluetoothGattServerManager.kt | 71 +- .../android/mesh/BluetoothMeshService.kt | 156 +- .../mesh/BluetoothPacketBroadcaster.kt | 137 +- .../android/mesh/FragmentingPacketSender.kt | 138 ++ .../android/mesh/MeshConnectionTracker.kt | 143 ++ .../java/com/bitchat/android/mesh/MeshCore.kt | 843 +++++++++ .../com/bitchat/android/mesh/MeshDelegate.kt | 19 + .../bitchat/android/mesh/MeshPacketUtils.kt | 37 + .../com/bitchat/android/mesh/MeshService.kt | 56 + .../com/bitchat/android/mesh/MeshTransport.kt | 23 + .../bitchat/android/mesh/MessageHandler.kt | 12 +- .../com/bitchat/android/mesh/PeerManager.kt | 38 +- .../android/mesh/UnifiedMeshService.kt | 386 ++++ .../android/noise/NoiseEncryptionService.kt | 3 +- .../com/bitchat/android/noise/NoiseSession.kt | 30 +- .../android/noise/NoiseSessionManager.kt | 53 +- .../onboarding/BluetoothCheckScreen.kt | 46 +- .../onboarding/OnboardingCoordinator.kt | 1 + .../onboarding/PermissionExplanationScreen.kt | 2 + .../android/onboarding/PermissionManager.kt | 36 + .../android/protocol/BinaryProtocol.kt | 4 + .../android/service/AppShutdownCoordinator.kt | 4 +- .../android/service/MeshForegroundService.kt | 36 +- .../android/service/MeshServiceHolder.kt | 85 + .../android/service/TransportBridgeService.kt | 184 ++ .../bitchat/android/services/AppStateStore.kt | 55 +- .../bitchat/android/services/MessageRouter.kt | 50 +- .../java/com/bitchat/android/ui/ChatHeader.kt | 2 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 2 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 6 +- .../com/bitchat/android/ui/ChatViewModel.kt | 192 +- .../bitchat/android/ui/CommandProcessor.kt | 56 +- .../android/ui/LocationChannelsSheet.kt | 2 +- .../android/ui/MatrixEncryptionAnimation.kt | 6 +- .../bitchat/android/ui/MediaSendingManager.kt | 6 +- .../bitchat/android/ui/MeshDelegateHandler.kt | 201 ++- .../bitchat/android/ui/MeshPeerListSheet.kt | 74 +- .../bitchat/android/ui/MessageComponents.kt | 8 +- .../bitchat/android/ui/PrivateChatManager.kt | 27 +- .../android/ui/SecurityVerificationSheet.kt | 2 +- .../bitchat/android/ui/VerificationHandler.kt | 6 +- .../bitchat/android/ui/VerificationSheet.kt | 2 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 49 + .../android/ui/debug/DebugSettingsSheet.kt | 148 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 97 + .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/ImageMessageItem.kt | 4 +- .../android/wifi-aware/SyncedSocket.kt | 99 + .../wifi-aware/WifiAwareConnectionTracker.kt | 239 +++ .../android/wifi-aware/WifiAwareController.kt | 305 ++++ .../wifi-aware/WifiAwareMeshDelegate.kt | 3 + .../wifi-aware/WifiAwareMeshService.kt | 1591 +++++++++++++++++ .../android/wifi-aware/WifiAwareSupport.kt | 74 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-bn/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-fil/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-he/strings.xml | 2 + app/src/main/res/values-hi/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ka/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-mg/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 2 + app/src/main/res/values-ne/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pa-rPK/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + app/src/main/res/values-ta/strings.xml | 2 + app/src/main/res/values-th/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values-ur/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../android/ui/CommandProcessorTest.kt | 4 +- .../kotlin/com/bitchat/MeshPacketUtilsTest.kt | 45 + .../android/mesh/MessageHandlerTest.kt | 117 ++ .../android/services/AppStateStoreTest.kt | 41 + 99 files changed, 5982 insertions(+), 649 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshCore.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt create mode 100644 app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c957240..f83ecefb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -20,6 +21,12 @@ + + + + + + @@ -52,6 +59,8 @@ + + create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T + return ChatViewModel(application, meshService, unifiedMeshService) as T } } } @@ -114,6 +117,10 @@ class MainActivity : OrientationAwareActivity() { // Ensure foreground service is running and get mesh instance from holder try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) + unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext) + // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED + // Bridging is now handled by TransportBridgeService automatically + bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -164,6 +171,17 @@ class MainActivity : OrientationAwareActivity() { } } } + + // Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + WifiAwareController.running.collect { running -> + if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + unifiedMeshService.delegate = chatViewModel + } + } + } + } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() { onRetry = { checkBluetoothAndProceed() }, + onSkip = { + mainViewModel.skipBluetoothCheck() + checkLocationAndProceed() + }, isLoading = isBluetoothLoading ) } @@ -355,6 +377,13 @@ class MainActivity : OrientationAwareActivity() { private fun checkBluetoothAndProceed() { // Log.d("MainActivity", "Checking Bluetooth status") + // Check if user has skipped Bluetooth check for this session + if (mainViewModel.isBluetoothCheckSkipped.value) { + Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check") + checkLocationAndProceed() + return + } + // For first-time users, skip Bluetooth check and go straight to permissions // We'll check Bluetooth after permissions are granted if (permissionManager.isFirstTimeLaunch()) { @@ -367,6 +396,12 @@ class MainActivity : OrientationAwareActivity() { bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (!bleRequired) { + // Skip BLE checks entirely when BLE is disabled in debug settings + checkLocationAndProceed() + return + } when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next @@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() { Log.d("MainActivity", "Location services enabled by user") mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationStatus(LocationStatus.ENABLED) + // Ensure Wi-Fi Aware starts now that location is enabled + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() checkBatteryOptimizationAndProceed() } @@ -540,8 +577,9 @@ class MainActivity : OrientationAwareActivity() { else -> BatteryOptimizationStatus.ENABLED } + val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - currentBluetoothStatus != BluetoothStatus.ENABLED -> { + bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) @@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() { return@launch } - // Set up mesh service delegate and start services - meshService.delegate = chatViewModel - meshService.startServices() + // Set up unified mesh delegate and start enabled transports + unifiedMeshService.delegate = chatViewModel + unifiedMeshService.startServices() Log.d("MainActivity", "Mesh service started successfully") @@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() { // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Reattach mesh delegate to new ChatViewModel instance after Activity recreation - try { meshService.delegate = chatViewModel } catch (_: Exception) { } + try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { } // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() - if (currentBluetoothStatus != BluetoothStatus.ENABLED) { + val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) { Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) @@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() { mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateLocationLoading(false) + } else { + // If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() } } } @@ -748,7 +790,7 @@ class MainActivity : OrientationAwareActivity() { // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Detach UI delegate so the foreground service can own DM notifications while UI is closed - try { meshService.delegate = null } catch (_: Exception) { } + try { unifiedMeshService.delegate = null } catch (_: Exception) { } } } diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d85..15ec6fda 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -35,6 +35,9 @@ class MainViewModel : ViewModel() { private val _isBatteryOptimizationLoading = MutableStateFlow(false) val isBatteryOptimizationLoading: StateFlow = _isBatteryOptimizationLoading.asStateFlow() + private val _isBluetoothCheckSkipped = MutableStateFlow(false) + val isBluetoothCheckSkipped: StateFlow = _isBluetoothCheckSkipped.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { _onboardingState.value = state @@ -67,4 +70,8 @@ class MainViewModel : ViewModel() { fun updateBatteryOptimizationLoading(loading: Boolean) { _isBatteryOptimizationLoading.value = loading } + + fun skipBluetoothCheck() { + _isBluetoothCheckSkipped.value = true + } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index dce58031..328d361f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -88,22 +88,49 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isGattServerEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + + private fun isGattClientEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } + init { powerManager.delegate = this // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Master transport enable/disable + connectionScope.launch { + dbg.bleEnabled.collect { enabled -> + if (enabled) return@collect + if (isActive) { + disableTransport() + } + } + } // Role enable/disable connectionScope.launch { dbg.gattServerEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startServer() else stopServer() + if (enabled && isBleTransportEnabled()) startServer() else stopServer() } } connectionScope.launch { dbg.gattClientEnabled.collect { enabled -> if (!isActive) return@collect - if (enabled) startClient() else stopClient() + if (enabled && isBleTransportEnabled()) startClient() else stopClient() } } @@ -163,6 +190,12 @@ class BluetoothConnectionManager( */ fun startServices(): Boolean { Log.i(TAG, "Starting power-optimized Bluetooth services...") + + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services") + disableTransport() + return false + } if (!permissionManager.hasBluetoothPermissions()) { Log.e(TAG, "Missing Bluetooth permissions") @@ -197,9 +230,8 @@ class BluetoothConnectionManager( powerManager.start() // Start server/client based on debug settings - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val startServer = dbg?.gattServerEnabled?.value != false - val startClient = dbg?.gattClientEnabled?.value != false + val startServer = isGattServerEnabled() + val startClient = isGattClientEnabled() if (startServer) { if (!serverManager.start()) { @@ -234,6 +266,19 @@ class BluetoothConnectionManager( return false } } + + /** + * Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled. + */ + fun disableTransport() { + Log.i(TAG, "Disabling BLE transport") + isActive = false + connectionScope.launch { + clientManager.stop() + serverManager.stop() + connectionTracker.stop() + } + } /** * Stop all Bluetooth services with proper cleanup @@ -279,7 +324,7 @@ class BluetoothConnectionManager( * Automatically fragments large packets to fit within BLE MTU limits */ fun broadcastPacket(routed: RoutedPacket) { - if (!isActive) return + if (!isActive || !isBleTransportEnabled()) return packetBroadcaster.broadcastPacket( routed, @@ -289,7 +334,7 @@ class BluetoothConnectionManager( } fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendToPeer( peerID, routed, @@ -306,7 +351,7 @@ class BluetoothConnectionManager( * Send a packet directly to a specific peer, without broadcasting to others. */ fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { - if (!isActive) return false + if (!isActive || !isBleTransportEnabled()) return false return packetBroadcaster.sendPacketToPeer( RoutedPacket(packet), peerID, @@ -317,9 +362,15 @@ class BluetoothConnectionManager( // Expose role controls for debug UI - fun startServer() { connectionScope.launch { serverManager.start() } } + fun startServer() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattServerEnabled()) serverManager.start() } + } fun stopServer() { connectionScope.launch { serverManager.stop() } } - fun startClient() { connectionScope.launch { clientManager.start() } } + fun startClient() { + if (!isActive || !isBleTransportEnabled()) return + connectionScope.launch { if (isGattClientEnabled()) clientManager.start() } + } fun stopClient() { connectionScope.launch { clientManager.stop() } } // Inject nickname resolver for broadcaster logs @@ -347,7 +398,10 @@ class BluetoothConnectionManager( /** * Public: connect/disconnect helpers for debug UI */ - fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address) + fun connectToAddress(address: String): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return clientManager.connectToAddress(address) + } fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) } @@ -358,10 +412,10 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() delay(200) - if (isActive) { + if (isActive && isBleTransportEnabled()) { // Restart managers if service is active - serverManager.start() - clientManager.start() + if (isGattServerEnabled()) serverManager.start() + if (isGattClientEnabled()) clientManager.start() } } } @@ -396,11 +450,17 @@ class BluetoothConnectionManager( Log.i(TAG, "Power mode changed to: $newMode") connectionScope.launch { + if (!isActive || !isBleTransportEnabled()) { + serverManager.stop() + clientManager.stop() + return@launch + } + // Avoid rapid scan restarts by checking if we need to change scan behavior val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() // Update advertising with new power settings if server enabled - val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val serverEnabled = isGattServerEnabled() if (serverEnabled) { serverManager.restartAdvertising() } else { @@ -411,7 +471,7 @@ class BluetoothConnectionManager( val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() if (wasUsingDutyCycle != nowUsingDutyCycle) { Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") - val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val clientEnabled = isGattClientEnabled() if (clientEnabled) { clientManager.restartScanning() } else { @@ -427,6 +487,10 @@ class BluetoothConnectionManager( } override fun onScanStateChanged(shouldScan: Boolean) { + if (!isActive || !isBleTransportEnabled()) { + clientManager.onScanStateChanged(false) + return + } clientManager.onScanStateChanged(shouldScan) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 681185d5..f139b9d3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -16,29 +16,22 @@ import java.util.concurrent.CopyOnWriteArrayList class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, private val powerManager: PowerManager -) { +) : MeshConnectionTracker(connectionScope, TAG) { companion object { private const val TAG = "BluetoothConnectionTracker" - private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS - private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS - private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds } // Connection tracking - reduced memory footprint private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = CopyOnWriteArrayList() val addressPeerMap = ConcurrentHashMap() + // Track whether we have seen the first ANNOUNCE on a given device connection + private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() - // Connection attempt tracking with automatic cleanup - private val pendingConnections = ConcurrentHashMap() - - // State management - private var isActive = false - /** * Consolidated device connection information */ @@ -52,37 +45,28 @@ class BluetoothConnectionTracker( val peerID: String? = null ) - /** - * Connection attempt tracking with automatic expiry - */ - data class ConnectionAttempt( - val attempts: Int, - val lastAttempt: Long = System.currentTimeMillis() - ) { - fun isExpired(): Boolean = - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 - - fun shouldRetry(): Boolean = - attempts < MAX_CONNECTION_ATTEMPTS && - System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + override fun start() { + super.start() } - /** - * Start the connection tracker - */ - fun start() { - isActive = true - startPeriodicCleanup() - } - - /** - * Stop the connection tracker - */ - fun stop() { - isActive = false + override fun stop() { + super.stop() cleanupAllConnections() clearAllConnections() } + + // Abstract implementations + override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id) + + override fun disconnect(id: String) { + connectedDevices[id]?.gatt?.let { + try { it.disconnect() } catch (_: Exception) { } + } + cleanupDeviceConnection(id) + Log.d(TAG, "Requested disconnect for $id") + } + + override fun getConnectionCount(): Int = connectedDevices.size /** * Add a device connection @@ -90,7 +74,9 @@ class BluetoothConnectionTracker( fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") connectedDevices[deviceAddress] = deviceConn - pendingConnections.remove(deviceAddress) + removePendingConnection(deviceAddress) + // Mark as awaiting first ANNOUNCE on this connection + firstAnnounceSeen[deviceAddress] = false } /** @@ -163,9 +149,7 @@ class BluetoothConnectionTracker( /** * Check if device is already connected */ - fun isDeviceConnected(deviceAddress: String): Boolean { - return connectedDevices.containsKey(deviceAddress) - } + fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress) /** * Check if a peer is already connected (by PeerID) @@ -175,63 +159,15 @@ class BluetoothConnectionTracker( return connectedDevices.values.any { it.peerID == peerID } } - /** - * Check if connection attempt is allowed - */ - fun isConnectionAttemptAllowed(deviceAddress: String): Boolean { - val existingAttempt = pendingConnections[deviceAddress] - return existingAttempt?.let { - it.isExpired() || it.shouldRetry() - } ?: true - } - - /** - * Add a pending connection attempt - */ - fun addPendingConnection(deviceAddress: String): Boolean { - Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress") - synchronized(pendingConnections) { - // Double-check inside synchronized block - val currentAttempt = pendingConnections[deviceAddress] - if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { - Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress") - return false - } - if (currentAttempt != null) { - Log.d(TAG, "Tracker: current attempt: $currentAttempt") - } - - // Update connection attempt atomically - // If the previous attempt window expired, reset backoff to 1; otherwise increment - val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 - pendingConnections[deviceAddress] = ConnectionAttempt(attempts) - Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") - return true - } - } - /** * Disconnect a specific device (by MAC address) */ - fun disconnectDevice(deviceAddress: String) { - connectedDevices[deviceAddress]?.gatt?.let { - try { it.disconnect() } catch (_: Exception) { } - } - cleanupDeviceConnection(deviceAddress) - Log.d(TAG, "Requested disconnect for $deviceAddress") - } - - /** - * Remove a pending connection - */ - fun removePendingConnection(deviceAddress: String) { - pendingConnections.remove(deviceAddress) - } + fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress) /** * Get connected device count */ - fun getConnectedDeviceCount(): Int = connectedDevices.size + fun getConnectedDeviceCount(): Int = getConnectionCount() /** * Check if connection limit is reached @@ -301,6 +237,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) } + firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -334,36 +271,21 @@ class BluetoothConnectionTracker( addressPeerMap.clear() pendingConnections.clear() scanRSSI.clear() + firstAnnounceSeen.clear() } /** - * Start periodic cleanup of expired connections + * Mark that we have received the first ANNOUNCE over this device connection. */ - private fun startPeriodicCleanup() { - connectionScope.launch { - while (isActive) { - delay(CLEANUP_INTERVAL) - - if (!isActive) break - - try { - // Clean up expired pending connections - val expiredConnections = pendingConnections.filter { it.value.isExpired() } - expiredConnections.keys.forEach { pendingConnections.remove(it) } - - // Log cleanup if any - if (expiredConnections.isNotEmpty()) { - Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts") - } - - // Log current state - Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending") - - } catch (e: Exception) { - Log.w(TAG, "Error in periodic cleanup: ${e.message}") - } - } - } + fun noteAnnounceReceived(deviceAddress: String) { + firstAnnounceSeen[deviceAddress] = true + } + + /** + * Check whether the first ANNOUNCE has been seen for a device connection. + */ + fun hasSeenFirstAnnounce(deviceAddress: String): Boolean { + return firstAnnounceSeen[deviceAddress] == true } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 2bb22fce..3885a523 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -32,6 +32,11 @@ class BluetoothGattClientManager( companion object { private const val TAG = "BluetoothGattClientManager" + // Self-healing scan recovery tuning + private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures + private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay + private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive + private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long } // Core Bluetooth components @@ -39,11 +44,28 @@ class BluetoothGattClientManager( context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isClientRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }) + } /** * Public: Connect to a device by MAC address (for debug UI) */ fun connectToAddress(deviceAddress: String): Boolean { + if (!isClientRoleEnabled()) { + Log.i(TAG, "connectToAddress skipped: BLE client disabled") + return false + } val device = bluetoothAdapter?.getRemoteDevice(deviceAddress) return if (device != null) { val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50 @@ -61,8 +83,16 @@ class BluetoothGattClientManager( // Scan rate limiting to prevent "scanning too frequently" errors private var lastScanStartTime = 0L private var lastScanStopTime = 0L - private var isCurrentlyScanning = false + @Volatile private var isCurrentlyScanning = false private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts + + // Self-healing scan state. + // scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover + // from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled). + @Volatile private var scanningDesired = false + @Volatile private var lastScanResultTime = 0L + private var scanRetryCount = 0 + private var scanWatchdogJob: Job? = null // RSSI monitoring state private var rssiMonitoringJob: Job? = null @@ -75,12 +105,10 @@ class BluetoothGattClientManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) { - Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isClientRoleEnabled()) { + Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT client already active; start is a no-op") @@ -106,12 +134,16 @@ class BluetoothGattClientManager( connectionScope.launch { if (powerManager.shouldUseDutyCycle()) { Log.i(TAG, "Using power-aware duty cycling") + // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that. } else { + scanningDesired = true startScanning() } // Start RSSI monitoring startRSSIMonitoring() + // Start the scan watchdog so a silently-dead or wedged scanner self-heals. + startScanWatchdog() } return true @@ -121,6 +153,8 @@ class BluetoothGattClientManager( * Stop client manager */ fun stop() { + scanningDesired = false + stopScanWatchdog() if (!isActive) { // Idempotent stop stopScanning() @@ -150,7 +184,8 @@ class BluetoothGattClientManager( * Handle scan state changes from power manager */ fun onScanStateChanged(shouldScan: Boolean) { - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() + scanningDesired = shouldScan && enabled if (shouldScan && enabled) { startScanning() } else { @@ -199,7 +234,7 @@ class BluetoothGattClientManager( @Suppress("DEPRECATION") private fun startScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return // Rate limit scan starts to prevent "scanning too frequently" errors @@ -217,7 +252,7 @@ class BluetoothGattClientManager( // Schedule delayed scan start connectionScope.launch { delay(remainingWait) - if (isActive && !isCurrentlyScanning) { + if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) { startScanning() } } @@ -251,22 +286,39 @@ class BluetoothGattClientManager( lastScanStopTime = System.currentTimeMillis() when (errorCode) { - 1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") - 2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") - 3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") - 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") - 5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") + 1 -> { + // Already started: the stack thinks a scan is running. Re-arm from a clean + // state so we don't stay wedged (stop then restart with backoff). + Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED") + stopScanning() + scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS) + } + 2 -> { + // App registration failed: common transient stack fault. Previously had NO + // retry, which left discovery dead until a manual BLE toggle. + Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED") + scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS) + } + 3 -> { + Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR") + scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS) + } + 4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry + 5 -> { + // Out of hardware resources: back off longer so other scanners/connections + // can free up before we try again. + Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES") + scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3) + } 6 -> { Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY") Log.w(TAG, "Scan failed due to rate limiting - will retry after delay") - connectionScope.launch { - delay(10000) // Wait 10 seconds before retrying - if (isActive) { - startScanning() - } - } + scheduleScanRestart("too-frequently", 10_000L) + } + else -> { + Log.e(TAG, "Unknown scan failure code: $errorCode") + scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS) } - else -> Log.e(TAG, "Unknown scan failure code: $errorCode") } } } @@ -304,6 +356,76 @@ class BluetoothGattClientManager( lastScanStopTime = System.currentTimeMillis() } } + + /** + * Schedule a scan restart with incremental backoff. Used to recover from transient scan + * failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a + * manual BLE toggle. + */ + private fun scheduleScanRestart(reason: String, baseDelayMs: Long) { + scanRetryCount++ + val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } + + /** + * Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking + * onScanFailed (internal stack reset, Doze, background throttling), which leaves the app + * believing it is scanning while it is not. This re-arms the scanner in those cases. + */ + private fun startScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = connectionScope.launch { + while (isActive) { + delay(SCAN_WATCHDOG_INTERVAL_MS) + try { + // Only act when we are supposed to be scanning. Honors duty-cycle OFF windows + // and the client-disabled state via scanningDesired. + if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue + if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue + + val now = System.currentTimeMillis() + if (!isCurrentlyScanning) { + Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan") + startScanning() + } else if (lastScanResultTime > 0L && + now - lastScanResultTime > SCAN_STALE_RESULT_MS && + now - lastScanStartTime > SCAN_STALE_RESULT_MS) { + // We think we're scanning but haven't seen anything for a long time. The scan + // may have silently died (flag wedged true). Force a clean re-arm. + Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart") + forceRestartScan() + } + } catch (e: Exception) { + Log.w(TAG, "Scan watchdog error: ${e.message}") + } + } + } + } + + private fun stopScanWatchdog() { + scanWatchdogJob?.cancel() + scanWatchdogJob = null + } + + /** + * Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag. + */ + private fun forceRestartScan() { + stopScanning() + connectionScope.launch { + delay(500) + if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) { + startScanning() + } + } + } /** * Handle scan result and initiate connection if appropriate @@ -320,6 +442,10 @@ class BluetoothGattClientManager( return } + // Proof the scanner is alive and finding our network: refresh liveness and clear backoff. + lastScanResultTime = System.currentTimeMillis() + scanRetryCount = 0 + // Try to extract peerID from Service Data (if available) for stable identity val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID)) val peerID = if (serviceData != null && serviceData.size >= 8) { @@ -402,6 +528,7 @@ class BluetoothGattClientManager( */ @Suppress("DEPRECATION") private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) { + if (!isClientRoleEnabled()) return if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address @@ -562,7 +689,7 @@ class BluetoothGattClientManager( */ fun restartScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = isClientRoleEnabled() if (!isActive || !enabled) return connectionScope.launch { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index ad7c9cf1..0c7aabc3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -30,6 +30,9 @@ class BluetoothGattServerManager( companion object { private const val TAG = "BluetoothGattServerManager" + // Self-healing advertising recovery tuning + private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures + private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay } // Core Bluetooth components @@ -42,10 +45,24 @@ class BluetoothGattServerManager( private var gattServer: BluetoothGattServer? = null private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null + private var advertiseRetryCount = 0 // State management private var isActive = false + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isServerRoleEnabled(): Boolean { + return isBleTransportEnabled() && + (try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }) + } + /** * Disconnect a specific device (used by ConnectionManager to enforce overall limits) */ @@ -62,12 +79,10 @@ class BluetoothGattServerManager( */ fun start(): Boolean { // Respect debug setting - try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) { - Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings") - return false - } - } catch (_: Exception) { } + if (!isServerRoleEnabled()) { + Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings") + return false + } if (isActive) { Log.d(TAG, "GATT server already active; start is a no-op") @@ -322,7 +337,7 @@ class BluetoothGattServerManager( @Suppress("DEPRECATION") private fun startAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() // Guard conditions – never throw here to avoid crashing the app from a background coroutine if (!permissionManager.hasBluetoothPermissions()) { @@ -374,6 +389,7 @@ class BluetoothGattServerManager( advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + advertiseRetryCount = 0 val mode = try { powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0] } catch (_: Exception) { "unknown" } @@ -382,6 +398,28 @@ class BluetoothGattServerManager( override fun onStartFailure(errorCode: Int) { Log.e(TAG, "Advertising failed: $errorCode") + // Previously this only logged, so if advertising failed this device became + // undiscoverable until a manual BLE toggle. Retry transient failures with backoff. + when (errorCode) { + ADVERTISE_FAILED_ALREADY_STARTED -> + Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry") + ADVERTISE_FAILED_DATA_TOO_LARGE -> + Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying") + ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> + Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying") + ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> { + Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff") + scheduleAdvertiseRestart("too-many-advertisers") + } + ADVERTISE_FAILED_INTERNAL_ERROR -> { + Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff") + scheduleAdvertiseRestart("internal-error") + } + else -> { + Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff") + scheduleAdvertiseRestart("unknown-$errorCode") + } + } } } @@ -407,12 +445,29 @@ class BluetoothGattServerManager( } } + /** + * Schedule an advertising restart with incremental backoff after a transient failure. + */ + private fun scheduleAdvertiseRestart(reason: String) { + advertiseRetryCount++ + val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS) + Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)") + connectionScope.launch { + delay(delayMs) + if (isActive && isServerRoleEnabled()) { + stopAdvertising() + delay(100) + startAdvertising() + } + } + } + /** * Restart advertising (for power mode changes) */ fun restartAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = isServerRoleEnabled() if (!isActive || !enabled) { stopAdvertising() return 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 474c04a5..c6c10473 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -16,6 +16,7 @@ import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService +import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -34,7 +35,7 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { +class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer { private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -70,6 +71,7 @@ class BluetoothMeshService(private val context: Context) { // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false @@ -99,17 +101,11 @@ class BluetoothMeshService(private val context: Context) { } ) - // Wire sync manager delegate - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) - } - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - connectionManager.sendPacketToPeer(peerID, packet) - } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) - } + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> + signPacketBeforeBroadcast(packet) + } + if (isBleTransportEnabled()) { + TransportBridgeService.register("BLE", this) } // Inject dynamic direct connection check into PeerManager @@ -120,6 +116,30 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Delegates set up; GossipSyncManager initialized") } + + override fun send(packet: RoutedPacket) { + if (!isBleTransportEnabled()) return + connectionManager.broadcastPacket(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + if (!isBleTransportEnabled()) return + connectionManager.sendPacketToPeer(peerID, packet) + } + + private fun broadcastRoutedPacket(routed: RoutedPacket) { + if (!isBleTransportEnabled()) return + connectionManager.broadcastPacket(routed) + TransportBridgeService.broadcast("BLE", routed) + } + + private fun isBleTransportEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } /** * Start periodic debug logging every 10 seconds @@ -146,7 +166,8 @@ class BluetoothMeshService(private val context: Context) { * Send broadcast announcement every 30 seconds */ private fun sendPeriodicBroadcastAnnounce() { - serviceScope.launch { + announceJob?.cancel() + announceJob = serviceScope.launch { Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { @@ -175,7 +196,7 @@ class BluetoothMeshService(private val context: Context) { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { // Update process-wide state first - try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { } // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } @@ -221,7 +242,7 @@ class BluetoothMeshService(private val context: Context) { ) // Sign the handshake response val signedPacket = signPacketBeforeBroadcast(responsePacket) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)") } @@ -241,7 +262,7 @@ class BluetoothMeshService(private val context: Context) { } override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) + broadcastRoutedPacket(RoutedPacket(packet)) } } @@ -284,11 +305,11 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) } override fun getBroadcastRecipient(): ByteArray { @@ -335,7 +356,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the handshake packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)") } else { Log.w(TAG, "Failed to generate Noise handshake data for $peerID") @@ -538,11 +559,13 @@ class BluetoothMeshService(private val context: Context) { } override fun relayPacket(routed: RoutedPacket) { - connectionManager.broadcastPacket(routed) + broadcastRoutedPacket(routed) } override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { - return connectionManager.sendToPeer(peerID, routed) + val sentOverBle = connectionManager.sendToPeer(peerID, routed) + TransportBridgeService.sendToPeer("BLE", peerID, routed.packet) + return sentOverBle } override fun handleRequestSync(routed: RoutedPacket) { @@ -625,6 +648,15 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (!isBleTransportEnabled()) { + Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service") + connectionManager.disableTransport() + TransportBridgeService.unregister("BLE") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + return + } if (terminated) { // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") @@ -635,17 +667,42 @@ class BluetoothMeshService(private val context: Context) { if (connectionManager.startServices()) { isActive = true + TransportBridgeService.register("BLE", this) // Start periodic announcements for peer discovery and connectivity sendPeriodicBroadcastAnnounce() Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") // Start periodic syncs - gossipSyncManager.start() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } } + + /** + * Apply the debug master transport toggle without destroying this mesh instance. + */ + fun setBleTransportEnabled(enabled: Boolean) { + if (enabled) { + startServices() + } else { + pauseServicesForTransportDisable() + } + } + + private fun pauseServicesForTransportDisable() { + Log.i(TAG, "Disabling BLE mesh transport") + isActive = false + announceJob?.cancel() + announceJob = null + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } + connectionManager.disableTransport() + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } /** * Stop all mesh services @@ -658,6 +715,11 @@ class BluetoothMeshService(private val context: Context) { Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false + announceJob?.cancel() + announceJob = null + TransportBridgeService.unregister("BLE") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } // Send leave announcement sendLeaveAnnouncement() @@ -667,7 +729,7 @@ class BluetoothMeshService(private val context: Context) { delay(200) // Give leave message time to send // Stop all components - gossipSyncManager.stop() + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() Log.d(TAG, "BluetoothConnectionManager stop requested") @@ -717,7 +779,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) // Track our own broadcast message for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } } @@ -749,7 +811,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the file TLV payload for progress tracking val transferId = sha256Hex(payload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } } } catch (e: Exception) { @@ -793,7 +855,7 @@ class BluetoothMeshService(private val context: Context) { // Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!) val packet = BitchatPacket( - version = 1u, + version = if (encrypted.size > 0xFFFF) 2u else 1u, type = MessageType.NOISE_ENCRYPTED.value, senderID = hexStringToByteArray(myPeerID), recipientID = hexStringToByteArray(recipientPeerID), @@ -807,7 +869,7 @@ class BluetoothMeshService(private val context: Context) { val signed = signPacketBeforeBroadcast(packet) // Use a stable transferId based on the unencrypted file TLV payload for progress tracking val transferId = sha256Hex(filePayload) - connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId)) + broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") } catch (e: Exception) { @@ -887,7 +949,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + 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 @@ -958,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") // Persist as read after successful send @@ -1006,7 +1068,7 @@ class BluetoothMeshService(private val context: Context) { ) val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)") } catch (e: Exception) { Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}") @@ -1070,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) { announcePacket.copy(signature = signature) } ?: announcePacket - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)") // Track announce for sync try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } @@ -1133,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) { packet.copy(signature = signature) } ?: packet - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) peerManager.markPeerAsAnnouncedTo(peerID) Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)") @@ -1148,8 +1210,14 @@ class BluetoothMeshService(private val context: Context) { return try { // Prefer verified peers that are currently marked as direct val verified = peerManager.getVerifiedPeers() - val direct = verified.filter { it.value.isDirectConnection }.keys.toList() - direct.take(10) + val direct = verified.filter { it.value.isDirectConnection }.keys.toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct } + } catch (_: Exception) { direct } + union.distinct().take(10) } catch (_: Exception) { emptyList() } @@ -1168,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + broadcastRoutedPacket(RoutedPacket(signedPacket)) } /** @@ -1415,18 +1483,10 @@ class BluetoothMeshService(private val context: Context) { } /** - * Delegate interface for mesh service callbacks (maintains exact same interface) + * Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so + * transport-agnostic facades can receive the same callback stream. */ -interface BluetoothMeshDelegate { - fun didReceiveMessage(message: BitchatMessage) - fun didUpdatePeerList(peers: List) - fun didReceiveChannelLeave(channel: String, fromPeer: String) - fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) - fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) - fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) - fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) - fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? - fun getNickname(): String? - fun isFavorite(peerID: String): Boolean - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +interface BluetoothMeshDelegate : MeshDelegate { + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 37af030d..efb3c4df 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -18,8 +18,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.Job -import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.channels.actor /** @@ -117,7 +115,7 @@ class BluetoothPacketBroadcaster( // Actor scope for the broadcaster private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val transferJobs = ConcurrentHashMap() + private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG) // SERIALIZATION: Actor to serialize all broadcast operations @OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class) @@ -139,71 +137,14 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ) { - val packet = routed.packet - val isFile = packet.type == MessageType.FILE_TRANSFER.value - if (isFile) { - Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") - } - // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) - // Check if we need to fragment - if (fragmentManager != null) { - val fragments = try { - fragmentManager.createFragments(packet) - } catch (e: Exception) { - Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e) - if (isFile) { - Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file") - } - return - } - if (fragments.size > 1) { - if (isFile) { - Log.d(TAG, "🔀 File needs ${fragments.size} fragments") - } - Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments") - if (transferId != null) { - TransferProgressManager.start(transferId, fragments.size) - } - val job = connectionScope.launch { - var sent = 0 - fragments.forEach { fragment -> - if (!isActive) return@launch - // If cancelled, stop sending remaining fragments - if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch - broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic) - // 20ms delay between fragments - delay(20) - if (transferId != null) { - sent += 1 - TransferProgressManager.progress(transferId, sent, fragments.size) - if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size) - } - } - } - if (transferId != null) { - transferJobs[transferId] = job - job.invokeOnCompletion { transferJobs.remove(transferId) } - } - return - } - } - - // Send single packet if no fragmentation needed - if (transferId != null) { - TransferProgressManager.start(transferId, 1) - } - broadcastSinglePacket(routed, gattServer, characteristic) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) + fragmentingSender.send(routed, "BLE broadcast") { packet -> + broadcastSinglePacket(packet, gattServer, characteristic) + true } } fun cancelTransfer(transferId: String): Boolean { - val job = transferJobs.remove(transferId) ?: return false - job.cancel() - return true + return fragmentingSender.cancelTransfer(transferId) } /** @@ -215,6 +156,18 @@ class BluetoothPacketBroadcaster( targetPeerID: String, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + + private fun sendSinglePacketToPeer( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? ): Boolean { val packet = routed.packet val data = packet.toBinaryData() ?: return false @@ -222,11 +175,6 @@ class BluetoothPacketBroadcaster( if (isFile) { Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") } - // Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER - val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null) - if (transferId != null) { - TransferProgressManager.start(transferId, 1) - } val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress @@ -241,10 +189,6 @@ class BluetoothPacketBroadcaster( if (serverTarget != null) { if (notifyDevice(serverTarget, data, gattServer, characteristic)) { logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) - } return true } } @@ -255,10 +199,6 @@ class BluetoothPacketBroadcaster( if (clientTarget != null) { if (writeToDeviceConn(clientTarget, data)) { logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo) - if (transferId != null) { - TransferProgressManager.progress(transferId, 1, 1) - TransferProgressManager.complete(transferId, 1) - } return true } } @@ -266,12 +206,6 @@ class BluetoothPacketBroadcaster( return false } - private fun sha256Hex(bytes: ByteArray): String = try { - val md = java.security.MessageDigest.getInstance("SHA-256") - md.update(bytes) - md.digest().joinToString("") { "%02x".format(it) } - } catch (_: Exception) { bytes.size.toString(16) } - /** * Public entry point for broadcasting - submits request to actor for serialization @@ -303,34 +237,19 @@ class BluetoothPacketBroadcaster( gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? ): Boolean { - val packet = routed.packet - val data = packet.toBinaryData() ?: return false - val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() - val senderPeerID = routed.peerID ?: packet.senderID.toHexString() - val incomingAddr = routed.relayAddress - val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] } - val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) } - - // Try server-side connections first - val targetDevice = connectionTracker.getSubscribedDevices() - .firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID } - if (targetDevice != null) { - if (notifyDevice(targetDevice, data, gattServer, characteristic)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl) - return true - } + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) } + } - // Try client-side connections next - val targetConn = connectionTracker.getConnectedDevices().values - .firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } - if (targetConn != null) { - if (writeToDeviceConn(targetConn, data)) { - logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl) - return true - } - } - return false + private fun hasPeerConnection(targetPeerID: String): Boolean { + val hasServerTarget = connectionTracker.getSubscribedDevices() + .any { connectionTracker.addressPeerMap[it.address] == targetPeerID } + if (hasServerTarget) return true + + return connectionTracker.getConnectedDevices().values + .any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt new file mode 100644 index 00000000..115254dc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -0,0 +1,138 @@ +package com.bitchat.android.mesh + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared transport send wrapper that applies bitchat packet fragmentation and + * transfer progress before a transport writes packets to its concrete medium. + */ +class FragmentingPacketSender( + private val scope: CoroutineScope, + private val fragmentManager: FragmentManager?, + private val logTag: String, + private val interFragmentDelayMs: Long = 20L +) { + private val transferJobs = ConcurrentHashMap() + + fun send( + routed: RoutedPacket, + description: String, + sendSingle: (RoutedPacket) -> Boolean + ): Boolean { + val transferId = transferIdFor(routed) + val packets = packetsForTransport(routed.packet) ?: return false + val total = packets.size + + if (total <= 1) { + if (transferId != null) { + TransferProgressManager.start(transferId, 1) + } + val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId)) + if (sent && transferId != null) { + TransferProgressManager.progress(transferId, 1, 1) + TransferProgressManager.complete(transferId, 1) + } + return sent + } + + Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description") + if (transferId != null) { + TransferProgressManager.start(transferId, total) + } + + val job = scope.launch(start = CoroutineStart.LAZY) { + var sent = 0 + for (packet in packets) { + if (!isActive) return@launch + if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch + + val fragment = routed.copy(packet = packet, transferId = transferId) + val delivered = try { + sendSingle(fragment) + } catch (e: Exception) { + Log.e(logTag, "Fragment send failed for $description: ${e.message}", e) + false + } + + if (!delivered) { + Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments") + return@launch + } + + sent += 1 + if (transferId != null) { + TransferProgressManager.progress(transferId, sent, total) + } + if (sent < total) { + delay(interFragmentDelayMs) + } + } + + if (transferId != null) { + TransferProgressManager.complete(transferId, total) + } + } + + if (transferId != null) { + transferJobs[transferId] = job + job.invokeOnCompletion { transferJobs.remove(transferId, job) } + } + job.start() + return true + } + + fun cancelTransfer(transferId: String): Boolean { + val job = transferJobs.remove(transferId) ?: return false + job.cancel() + return true + } + + private fun packetsForTransport(packet: BitchatPacket): List? { + if (packet.type == MessageType.FRAGMENT.value) { + return listOf(packet) + } + + val manager = fragmentManager ?: return listOf(packet) + return try { + val fragments = manager.createFragments(packet) + if (fragments.isEmpty()) { + Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}") + null + } else { + fragments + } + } catch (e: Exception) { + Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e) + null + } + } + + private fun transferIdFor(routed: RoutedPacket): String? { + routed.transferId?.let { return it } + val packet = routed.packet + return if (packet.type == MessageType.FILE_TRANSFER.value) { + sha256Hex(packet.payload) + } else { + null + } + } + + private fun sha256Hex(bytes: ByteArray): String = try { + val md = MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt new file mode 100644 index 00000000..0dd6a552 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshConnectionTracker.kt @@ -0,0 +1,143 @@ +package com.bitchat.android.mesh + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.) + * Encapsulates common state machine logic: + * - Connection attempt tracking (retries, backoff) + * - Pending connection management + * - Automatic cleanup of expired attempts + */ +abstract class MeshConnectionTracker( + private val scope: CoroutineScope, + protected val tag: String +) { + companion object { + const val CONNECTION_RETRY_DELAY = 5_000L + const val MAX_CONNECTION_ATTEMPTS = 3 + const val CLEANUP_INTERVAL = 30_000L + } + + /** + * Connection attempt tracking with automatic expiry + */ + protected data class ConnectionAttempt( + val attempts: Int, + val lastAttempt: Long = System.currentTimeMillis() + ) { + fun isExpired(): Boolean = + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2 + + fun shouldRetry(): Boolean = + attempts < MAX_CONNECTION_ATTEMPTS && + System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY + } + + // Tracks in-progress or failed attempts + protected val pendingConnections = ConcurrentHashMap() + + private var isActive = false + + /** + * Start the tracker and its cleanup loop + */ + open fun start() { + isActive = true + startPeriodicCleanup() + } + + /** + * Stop the tracker + */ + open fun stop() { + isActive = false + pendingConnections.clear() + } + + /** + * Check if a connection attempt is allowed for this peer/address + */ + fun isConnectionAttemptAllowed(id: String): Boolean { + // If already connected, usually no need to retry (subclasses can override logic if needed, + // but typically the caller checks isConnected() first). + + val existingAttempt = pendingConnections[id] + return existingAttempt?.let { + it.isExpired() || it.shouldRetry() + } ?: true + } + + /** + * Record a new connection attempt. + * Returns true if the attempt was recorded (allowed), false if skipped. + */ + fun addPendingConnection(id: String): Boolean { + synchronized(pendingConnections) { + val currentAttempt = pendingConnections[id] + + // If strictly not allowed right now, reject + if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) { + Log.d(tag, "Connection attempt already in progress for $id") + return false + } + + // Update attempt count + // Reset to 1 if expired, otherwise increment + val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 + pendingConnections[id] = ConnectionAttempt(attempts) + Log.d(tag, "Added pending connection for $id (attempts: $attempts)") + return true + } + } + + /** + * Remove a pending attempt (e.g., on success or fatal error) + */ + fun removePendingConnection(id: String) { + pendingConnections.remove(id) + } + + /** + * Abstract: Subclasses must define what "connected" means + */ + abstract fun isConnected(id: String): Boolean + + /** + * Abstract: Subclasses must implement disconnect logic + */ + abstract fun disconnect(id: String) + + /** + * Abstract: Subclasses report their active connection count + */ + abstract fun getConnectionCount(): Int + + private fun startPeriodicCleanup() { + scope.launch { + while (isActive) { + try { + delay(CLEANUP_INTERVAL) + if (!isActive) break + + // Clean up expired pending connections + val expired = pendingConnections.filter { it.value.isExpired() } + expired.keys.forEach { pendingConnections.remove(it) } + + if (expired.isNotEmpty()) { + Log.d(tag, "Cleaned up ${expired.size} expired connection attempts") + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.w(tag, "Error in periodic cleanup: ${e.message}") + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt new file mode 100644 index 00000000..46518428 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -0,0 +1,843 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared mesh coordinator that wires all mesh-layer components and provides common APIs + * for send/receive operations across transports. + */ +class MeshCore( + private val context: Context, + private val scope: CoroutineScope, + private val transport: MeshTransport, + private val encryptionService: EncryptionService, + val myPeerID: String, + private val maxTtl: UByte, + sharedGossipManager: GossipSyncManager?, + gossipConfigProvider: GossipSyncManager.ConfigProvider, + private val hooks: Hooks = Hooks() +) { + data class Hooks( + val onMessageReceived: ((BitchatMessage) -> Unit)? = null, + val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null, + val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, + val readReceiptInterceptor: ((String, String) -> Boolean)? = null, + val onReadReceiptSent: ((String) -> Unit)? = null, + val announcementNicknameProvider: (() -> String?)? = null, + val leavePayloadProvider: (() -> ByteArray)? = null + ) + + private val peerManager = PeerManager() + val fragmentManager = FragmentManager() + private val securityManager = SecurityManager(encryptionService, myPeerID) + private val storeForwardManager = StoreForwardManager() + private val messageHandler = MessageHandler(myPeerID, context.applicationContext) + private val packetProcessor = PacketProcessor(myPeerID) + private val directPeers = ConcurrentHashMap.newKeySet() + + val gossipSyncManager: GossipSyncManager = + sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider) + private val ownsGossipManager: Boolean = sharedGossipManager == null + + var delegate: MeshDelegate? = null + + private var announceJob: Job? = null + private var isActive = false + + init { + messageHandler.packetProcessor = packetProcessor + peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) } + setupDelegates() + + if (sharedGossipManager == null) { + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + transport.sendPacketToPeer(peerID, packet) + TransportBridgeService.sendToPeer(transport.id, peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } + } + } + + fun startCore() { + if (isActive) return + isActive = true + startPeriodicBroadcastAnnounce() + if (ownsGossipManager) { + gossipSyncManager.start() + } + } + + fun stopCore() { + if (!isActive) return + isActive = false + announceJob?.cancel() + announceJob = null + if (ownsGossipManager) { + gossipSyncManager.stop() + } + } + + fun shutdown() { + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() + } + + fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) { + packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress)) + } + + fun sendFromBridge(packet: RoutedPacket) { + transport.broadcastPacket(packet) + } + + private fun dispatchGlobal(routed: RoutedPacket) { + transport.broadcastPacket(routed) + TransportBridgeService.broadcast(transport.id, routed) + } + + private fun startPeriodicBroadcastAnnounce() { + announceJob?.cancel() + announceJob = scope.launch { + while (isActive) { + try { + delay(30_000) + sendBroadcastAnnounce() + } catch (_: Exception) { } + } + } + } + + private fun setupDelegates() { + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { } + delegate?.didUpdatePeerList(peerIDs) + } + + override fun onPeerRemoved(peerID: String) { + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + } + + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + scope.launch { + delay(100) + sendAnnouncementToPeer(peerID) + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + val responsePacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket))) + } + + override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + } + + storeForwardManager.delegate = object : StoreForwardManagerDelegate { + override fun isFavorite(peerID: String): Boolean { + return delegate?.isFavorite(peerID) ?: false + } + + override fun isPeerOnline(peerID: String): Boolean { + return peerManager.isPeerActive(peerID) + } + + override fun sendPacket(packet: BitchatPacket) { + dispatchGlobal(RoutedPacket(packet)) + } + } + + messageHandler.delegate = object : MessageHandlerDelegate { + override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun removePeer(peerID: String) { + peerManager.removePeer(peerID) + } + + override fun updatePeerNickname(peerID: String, nickname: String) { + peerManager.addOrUpdatePeer(peerID, nickname) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getMyNickname(): String? { + return delegate?.getNickname() + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + } + + override fun sendPacket(packet: BitchatPacket) { + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.verifySignature(packet, peerID) + } + + override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? { + return securityManager.encryptForPeer(data, recipientPeerID) + } + + override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + return securityManager.decryptFromPeer(encryptedData, senderPeerID) + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + return encryptionService.verifyEd25519Signature(signature, data, publicKey) + } + + override fun hasNoiseSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + override fun initiateNoiseHandshake(peerID: String) { + this@MeshCore.initiateNoiseHandshake(peerID) + } + + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? { + return try { + encryptionService.processHandshakeMessage(payload, peerID) + } catch (_: Exception) { + null + } + } + + override fun updatePeerIDBinding( + newPeerID: String, + nickname: String, + publicKey: ByteArray, + previousPeerID: String? + ) { + peerManager.addOrUpdatePeer(newPeerID, nickname) + val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + previousPeerID?.let { peerManager.removePeer(it) } + Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}") + hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun onMessageReceived(message: BitchatMessage) { + hooks.onMessageReceived?.invoke(message) + delegate?.didReceiveMessage(message) + } + + override fun onChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun onDeliveryAckReceived(messageID: String, peerID: String) { + delegate?.didReceiveDeliveryAck(messageID, peerID) + } + + override fun onReadReceiptReceived(messageID: String, peerID: String) { + delegate?.didReceiveReadReceipt(messageID, peerID) + } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + } + + packetProcessor.delegate = object : PacketProcessorDelegate { + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean { + return securityManager.validatePacket(packet, peerID) + } + + override fun updatePeerLastSeen(peerID: String) { + peerManager.updatePeerLastSeen(peerID) + } + + override fun getPeerNickname(peerID: String): String? { + return peerManager.getPeerNickname(peerID) + } + + override fun getNetworkSize(): Int { + return peerManager.getActivePeerCount() + } + + override fun getBroadcastRecipient(): ByteArray { + return SpecialRecipients.BROADCAST + } + + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean { + return runBlocking { securityManager.handleNoiseHandshake(routed) } + } + + override fun handleNoiseEncrypted(routed: RoutedPacket) { + scope.launch { messageHandler.handleNoiseEncrypted(routed) } + } + + override fun handleAnnounce(routed: RoutedPacket) { + scope.launch { + val isFirst = messageHandler.handleAnnounce(routed) + hooks.onAnnounceProcessed?.invoke(routed, isFirst) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + } + + override fun handleMessage(routed: RoutedPacket) { + scope.launch { messageHandler.handleMessage(routed) } + try { + val pkt = routed.packet + val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + gossipSyncManager.onPublicPacketSeen(pkt) + } + } catch (_: Exception) { } + } + + override fun handleLeave(routed: RoutedPacket) { + scope.launch { messageHandler.handleLeave(routed) } + } + + override fun handleFragment(packet: BitchatPacket): BitchatPacket? { + try { + val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (isBroadcast && packet.type == MessageType.FRAGMENT.value) { + gossipSyncManager.onPublicPacketSeen(packet) + } + } catch (_: Exception) { } + return fragmentManager.handleFragment(packet) + } + + override fun sendAnnouncementToPeer(peerID: String) { + this@MeshCore.sendAnnouncementToPeer(peerID) + } + + override fun sendCachedMessages(peerID: String) { + storeForwardManager.sendCachedMessages(peerID) + } + + override fun relayPacket(routed: RoutedPacket) { + dispatchGlobal(routed) + } + + override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean { + val sent = transport.sendPacketToPeer(peerID, routed.packet) + TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet) + return sent + } + + override fun handleRequestSync(routed: RoutedPacket) { + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + } + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + if (content.isEmpty()) return + scope.launch { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = content.toByteArray(Charsets.UTF_8), + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + fun sendFileBroadcast(file: BitchatFilePacket) { + try { + val payload = file.encode() ?: return + scope.launch { + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + val transferId = MeshPacketUtils.sha256Hex(payload) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e) + } + } + + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + try { + scope.launch { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) { + initiateNoiseHandshake(recipientPeerID) + return@launch + } + val tlv = file.encode() ?: return@launch + val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode() + val enc = encryptionService.encrypt(np, recipientPeerID) + val packet = BitchatPacket( + version = if (enc.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + val transferId = MeshPacketUtils.sha256Hex(tlv) + dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + } + } catch (e: Exception) { + Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e) + } + } + + fun cancelFileTransfer(transferId: String): Boolean { + return transport.cancelTransfer(transferId) + } + + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + scope.launch { + val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() + + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content) + val tlvData = privateMessage.encode() ?: return@launch + val messagePayload = NoisePayload( + type = NoisePayloadType.PRIVATE_MESSAGE, + data = tlvData + ) + val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to encrypt private message: ${e.message}") + } + } else { + initiateNoiseHandshake(recipientPeerID) + } + } + } + + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + scope.launch { + if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch + try { + val payload = NoisePayload( + type = NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + hooks.onReadReceiptSent?.invoke(messageID) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send read receipt: ${e.message}") + } + } + } + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + ) + sendNoisePayloadToPeer(payload, peerID) + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID) + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) { + scope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}") + } + } + } + + fun sendBroadcastAnnounce() { + scope.launch { + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: run { + Log.e("MeshCore", "No static public key available for announcement") + return@launch + } + val signingKey = encryptionService.getSigningPublicKey() ?: run { + Log.e("MeshCore", "No signing public key available for announcement") + return@launch + } + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch + val announcePacket = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(announcePacket) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + } + + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + val nickname = hooks.announcementNicknameProvider?.invoke() + ?: delegate?.getNickname() + ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: return + val signingKey = encryptionService.getSigningPublicKey() ?: return + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = tlvPayload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + peerManager.markPeerAsAnnouncedTo(peerID) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + + private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? { + var tlvPayload = announcement.encode() ?: return null + val directPeersForGossip = getDirectPeerIDsForGossip() + try { + if (directPeersForGossip.isNotEmpty()) { + tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip) + } + com.bitchat.android.services.meshgraph.MeshGraphService.getInstance() + .updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong()) + } catch (_: Exception) { } + return tlvPayload + } + + private fun getDirectPeerIDsForGossip(): List { + return try { + val verifiedDirect = peerManager.getVerifiedPeers() + .filter { it.value.isDirectConnection } + .keys + val localDirect = (verifiedDirect + directPeers).toSet() + // Publish this transport's direct peers and gossip the cross-transport union so a + // node connected via multiple transports advertises a complete neighbor list. + try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { } + val union = try { + com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect } + } catch (_: Exception) { localDirect } + union.distinct().take(10) + } catch (_: Exception) { + directPeers.toList().take(10) + } + } + + fun sendLeaveAnnouncement() { + val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf() + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = maxTtl, + senderID = myPeerID, + payload = payload + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } + + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) + + fun addOrUpdatePeer(peerID: String, nickname: String): Boolean { + return peerManager.addOrUpdatePeer(peerID, nickname) + } + + fun removePeer(peerID: String) { + peerManager.removePeer(peerID) + } + + fun setDirectConnection(peerID: String, isDirect: Boolean) { + if (isDirect) { + directPeers.add(peerID) + } else { + directPeers.remove(peerID) + } + peerManager.refreshPeerList() + } + + fun updatePeerRSSI(peerID: String, rssi: Int) { + peerManager.updatePeerRSSI(peerID, rssi) + } + + fun getDebugInfoWithDeviceAddresses(deviceMap: Map): String { + return peerManager.getDebugInfoWithDeviceAddresses(deviceMap) + } + + fun getFingerprintDebugInfo(): String { + return peerManager.getFingerprintDebugInfo() + } + + fun hasEstablishedSession(peerID: String): Boolean { + return encryptionService.hasEstablishedSession(peerID) + } + + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState { + return encryptionService.getSessionState(peerID) + } + + fun initiateNoiseHandshake(peerID: String) { + scope.launch { + try { + val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch + 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 + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}") + } + } + } + + fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + + fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + + fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID) + + fun getEncryptedPeers(): List = emptyList() + + fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + + fun refreshPeerList() { + try { peerManager.refreshPeerList() } catch (_: Exception) { } + } + + fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID) + + fun getDeviceAddressToPeerMapping(): Map = transport.getDeviceAddressToPeerMapping() + + fun getDebugStatus( + transportInfo: String, + deviceMap: Map, + extraLines: List = emptyList(), + title: String? = null + ): String { + return buildString { + appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===") + appendLine("My Peer ID: $myPeerID") + if (extraLines.isNotEmpty()) { + extraLines.forEach { appendLine(it) } + } + appendLine(transportInfo) + appendLine(peerManager.getDebugInfo(deviceMap)) + appendLine(fragmentManager.getDebugInfo()) + appendLine(securityManager.getDebugInfo()) + appendLine(storeForwardManager.getDebugInfo()) + appendLine(messageHandler.getDebugInfo()) + appendLine(packetProcessor.getDebugInfo()) + } + } + + fun clearAllInternalData() { + fragmentManager.clearAllFragments() + storeForwardManager.clearAllCache() + securityManager.clearAllData() + peerManager.clearAllPeers() + peerManager.clearAllFingerprints() + } + + fun clearAllEncryptionData() { + encryptionService.clearPersistentIdentity() + } + + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + return try { + val withRoute = try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.toHexString() + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + + val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute + val signature = encryptionService.signData(packetDataForSigning) + if (signature != null) { + withRoute.copy(signature = signature) + } else { + withRoute + } + } catch (_: Exception) { + packet + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt new file mode 100644 index 00000000..de662384 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -0,0 +1,19 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatMessage + +/** + * Shared mesh delegate interface for transport-agnostic callbacks. + */ +interface MeshDelegate { + fun didReceiveMessage(message: BitchatMessage) + fun didUpdatePeerList(peers: List) + fun didReceiveChannelLeave(channel: String, fromPeer: String) + fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) + fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt new file mode 100644 index 00000000..514e7a99 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshPacketUtils.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.mesh + +/** + * Shared helpers for mesh packet handling. + */ +object MeshPacketUtils { + /** + * Convert hex string peer ID to binary data (8 bytes), matching iOS behavior. + */ + fun hexStringToByteArray(hexString: String): ByteArray { + val result = ByteArray(8) { 0 } + 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 + } + + /** + * Hash payloads to a stable hex ID for transfer tracking. + */ + fun sha256Hex(bytes: ByteArray): String = try { + val md = java.security.MessageDigest.getInstance("SHA-256") + md.update(bytes) + md.digest().joinToString("") { "%02x".format(it) } + } catch (_: Exception) { + bytes.size.toString(16) + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt new file mode 100644 index 00000000..c612e8ed --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -0,0 +1,56 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket + +/** + * Transport-agnostic mesh service API for UI and routing layers. + */ +interface MeshService { + val myPeerID: String + var delegate: MeshDelegate? + + fun startServices() + fun stopServices() + + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) + fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} + fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendFileBroadcast(file: BitchatFilePacket) + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) + fun cancelFileTransfer(transferId: String): Boolean + + fun sendBroadcastAnnounce() + fun sendAnnouncementToPeer(peerID: String) + + fun getPeerNicknames(): Map + fun getPeerRSSI(): Map + fun getActivePeerCount(): Int + fun hasEstablishedSession(peerID: String): Boolean + fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState + fun initiateNoiseHandshake(peerID: String) + fun getPeerFingerprint(peerID: String): String? + fun getPeerInfo(peerID: String): PeerInfo? + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean + fun getIdentityFingerprint(): String + fun getStaticNoisePublicKey(): ByteArray? + fun shouldShowEncryptionIcon(peerID: String): Boolean + fun getEncryptedPeers(): List + + fun getDeviceAddressForPeer(peerID: String): String? + fun getDeviceAddressToPeerMapping(): Map + fun printDeviceAddressesForPeers(): String + fun getDebugStatus(): String + + fun clearAllInternalData() + fun clearAllEncryptionData() +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt new file mode 100644 index 00000000..26a63848 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -0,0 +1,23 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket + +/** + * Transport abstraction used by MeshCore to send packets via a specific medium. + */ +interface MeshTransport { + val id: String + + fun broadcastPacket(routed: RoutedPacket) + + fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean + + fun cancelTransfer(transferId: String): Boolean = false + + fun getDeviceAddressForPeer(peerID: String): String? = null + + fun getDeviceAddressToPeerMapping(): Map = emptyMap() + + fun getTransportDebugInfo(): String = "" +} 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 ecac572b..d6daa547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" + private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L } // Delegate for callbacks @@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro if (peerID == myPeerID) return false - // Ignore stale announcements older than STALE_PEER_TIMEOUT + // Peers use wall-clock packet timestamps; tolerate moderate device clock skew + // during identity learning, or later signed messages cannot be verified. val now = System.currentTimeMillis() - val age = now - packet.timestamp.toLong() - if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { - Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)") + val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) + if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { + Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") return false + } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { + Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)") } // Try to decode as iOS-compatible IdentityAnnouncement with TLV format diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 536a9b2a..01132ae7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -110,10 +110,21 @@ class PeerManager { isVerified: Boolean ): Boolean { if (peerID == "unknown") return false + + fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean { + if (a == null && b == null) return true + if (a == null || b == null) return false + return a.contentEquals(b) + } val now = System.currentTimeMillis() val existingPeer = peers[peerID] val isNewPeer = existingPeer == null + val wasVerified = existingPeer?.isVerifiedNickname == true + val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname + val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey) + val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey) + val connectedChanged = existingPeer != null && existingPeer.isConnected != true // Update or create peer info val peerInfo = PeerInfo( @@ -133,18 +144,27 @@ class PeerManager { // No legacy maps; peers map is the single source of truth // Maintain announcedPeers for first-time announce semantics + val shouldNotify = when { + isNewPeer && isVerified -> true + wasVerified != isVerified -> true + nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true + else -> false + } + if (isNewPeer && isVerified) { announcedPeers.add(peerID) - notifyPeerListUpdate() Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)") - return true } else if (isVerified) { Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)") } else { Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)") } + + if (shouldNotify) { + notifyPeerListUpdate() + } - return false + return isNewPeer && isVerified } /** @@ -179,14 +199,6 @@ class PeerManager { } } - /** - * Force a peer list update notification. - * Call this when connection state changes to refresh UI badges. - */ - fun refreshPeerList() { - notifyPeerListUpdate() - } - // MARK: - Legacy Methods (maintained for compatibility) /** @@ -414,6 +426,10 @@ class PeerManager { val peerList = getActivePeerIDs() delegate?.onPeerListUpdated(peerList) } + + fun refreshPeerList() { + notifyPeerListUpdate() + } /** * Start periodic cleanup of stale peers diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt new file mode 100644 index 00000000..46d36a8f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -0,0 +1,386 @@ +package com.bitchat.android.mesh + +import android.content.Context +import android.util.Log +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.wifiaware.WifiAwareController + +/** + * Feature-facing mesh service that hides local transport selection from the rest of the app. + * + * BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh + * behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever + * local transport already has the peer/session, falling back to a connected transport handshake. + */ +class UnifiedMeshService( + private val context: Context, + private val bluetooth: BluetoothMeshService +) : MeshService, BluetoothMeshDelegate { + + companion object { + private const val TAG = "UnifiedMeshService" + } + + override val myPeerID: String + get() = bluetooth.myPeerID + + override var delegate: MeshDelegate? = null + set(value) { + field = value + refreshDelegates() + } + + fun refreshDelegates() { + try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { } + try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { } + } + + override fun startServices() { + if (isBleEnabled()) { + try { bluetooth.startServices() } catch (e: Exception) { + Log.w(TAG, "Failed to start BLE transport: ${e.message}") + } + } else { + try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { } + } + try { WifiAwareController.startIfPossible() } catch (e: Exception) { + Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") + } + refreshDelegates() + } + + override fun stopServices() { + try { bluetooth.stopServices() } catch (_: Exception) { } + try { WifiAwareController.stop() } catch (_: Exception) { } + } + + override fun sendMessage(content: String, mentions: List, channel: String?) { + when { + isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) + else -> wifiService()?.sendMessage(content, mentions, channel) + } + } + + override fun sendPrivateMessage( + content: String, + recipientPeerID: String, + recipientNickname: String, + messageID: String? + ) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + } + + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) + isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + } + + override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) { + 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 = getPeerNicknames()[peerID] ?: peerID + if (hasEstablishedSession(peerID)) { + sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString()) + } + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + when { + isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + } + + override fun sendFileBroadcast(file: BitchatFilePacket) { + when { + isBleEnabled() -> bluetooth.sendFileBroadcast(file) + else -> wifiService()?.sendFileBroadcast(file) + } + } + + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file) + isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file) + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.sendFilePrivate(recipientPeerID, file) + else -> wifiService()?.sendFilePrivate(recipientPeerID, file) + } + } + + override fun cancelFileTransfer(transferId: String): Boolean { + val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false } + val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false } + return bleCancelled || wifiCancelled + } + + override fun sendBroadcastAnnounce() { + if (isBleEnabled()) { + try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { } + } + try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { } + } + + override fun sendAnnouncementToPeer(peerID: String) { + when { + isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID) + else -> wifiService()?.sendAnnouncementToPeer(peerID) + } + } + + override fun getPeerNicknames(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { } + return merged + } + + override fun getPeerRSSI(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { } + return merged + } + + override fun getActivePeerCount(): Int { + return mergedPeerIDs().filter { it != myPeerID }.distinct().size + } + + override fun hasEstablishedSession(peerID: String): Boolean { + return isBleReady(peerID) || isWifiReady(peerID) + } + + override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { + val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null } + return when { + bleState is NoiseSession.NoiseSessionState.Established -> bleState + wifiState is NoiseSession.NoiseSessionState.Established -> wifiState + bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState + wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState + bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState + wifiState != null -> wifiState + else -> bleState + } + } + + override fun initiateNoiseHandshake(peerID: String) { + when { + isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID) + isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID) + isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID) + else -> wifiService()?.initiateNoiseHandshake(peerID) + } + } + + override fun getPeerFingerprint(peerID: String): String? { + return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null } + } + + override fun getPeerInfo(peerID: String): PeerInfo? { + val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null } + val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null } + return when { + ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble + wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi + ble?.isConnected == true -> ble + wifi?.isConnected == true -> wifi + else -> ble ?: wifi + } + } + + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean { + val bleUpdated = try { + bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + } catch (_: Exception) { + false + } + val wifiUpdated = try { + wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true + } catch (_: Exception) { + false + } + return bleUpdated || wifiUpdated + } + + override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? { + return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey() + } + + override fun shouldShowEncryptionIcon(peerID: String): Boolean { + return hasEstablishedSession(peerID) + } + + override fun getEncryptedPeers(): List { + val encrypted = linkedSetOf() + try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { } + try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { } + mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) } + return encrypted.toList() + } + + override fun getDeviceAddressForPeer(peerID: String): String? { + return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + ?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val merged = linkedMapOf() + try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { } + try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { } + return merged + } + + override fun printDeviceAddressesForPeers(): String { + return buildString { + appendLine(bluetooth.printDeviceAddressesForPeers()) + wifiService()?.let { + appendLine() + appendLine(it.printDeviceAddressesForPeers()) + } + } + } + + override fun getDebugStatus(): String { + return buildString { + appendLine("=== Unified Mesh Service Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}") + appendLine() + appendLine(bluetooth.getDebugStatus()) + wifiService()?.let { + appendLine() + appendLine(it.getDebugStatus()) + } + } + } + + override fun clearAllInternalData() { + try { bluetooth.clearAllInternalData() } catch (_: Exception) { } + try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } + } + + override fun clearAllEncryptionData() { + try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { } + try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { } + } + + override fun didReceiveMessage(message: BitchatMessage) { + delegate?.didReceiveMessage(message) + } + + override fun didUpdatePeerList(peers: List) { + delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() }) + } + + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { + delegate?.didReceiveChannelLeave(channel, fromPeer) + } + + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + delegate?.didReceiveDeliveryAck(messageID, recipientPeerID) + } + + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + delegate?.didReceiveReadReceipt(messageID, recipientPeerID) + } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return delegate?.decryptChannelMessage(encryptedContent, channel) + } + + override fun getNickname(): String? = delegate?.getNickname() + + override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false + + private fun mergedPeerIDs(): List { + val ids = linkedSetOf() + try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { } + try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { } + try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { } + return ids.toList() + } + + private fun wifiService(): MeshService? { + return try { + WifiAwareController.getService()?.also { service -> + if (delegate != null && service.delegate !== this) { + service.delegate = this + } + } + } catch (_: Exception) { + null + } + } + + private fun isBleEnabled(): Boolean { + return try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value + } catch (_: Exception) { + try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } + } + } + + private fun isBleConnected(peerID: String): Boolean { + return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isWifiConnected(peerID: String): Boolean { + return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false } + } + + private fun isBleReady(peerID: String): Boolean { + return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID) + } + + private fun isWifiReady(peerID: String): Boolean { + return try { + val wifi = wifiService() + wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean { + return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false } + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 58dc810e..5ca8df28 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -70,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) { private fun initializeSessionManager() { // Create new session manager with current keys - sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey) + val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16) + sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID) // Set up session callbacks sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index 332dc78a..4eab42bb 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -153,6 +153,9 @@ class NoiseSession( // Session state private var state: NoiseSessionState = NoiseSessionState.Uninitialized private val creationTime = System.currentTimeMillis() + private var handshakeStartMs: Long? = null + private var lastHandshakeActivityMs: Long? = null + private var handshakeMessage1: ByteArray? = null // Session counters private var currentPattern = 0; @@ -195,6 +198,15 @@ class NoiseSession( fun isEstablished(): Boolean = state is NoiseSessionState.Established fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking fun getCreationTime(): Long = creationTime + fun isInitiatorRole(): Boolean = isInitiator + fun getHandshakeStartMs(): Long? = handshakeStartMs + fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs + + internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone() + + internal fun setLastHandshakeActivityForTest(timestampMs: Long) { + lastHandshakeActivityMs = timestampMs + } init { try { @@ -317,19 +329,25 @@ class NoiseSession( // Initialize handshake as initiator initializeNoiseHandshake(HandshakeState.INITIATOR) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } + lastHandshakeActivityMs = System.currentTimeMillis() val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE) val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0) currentPattern++ val firstMessage = messageBuffer.copyOf(messageLength) + handshakeMessage1 = firstMessage // Validate message size matches XX pattern expectations if (firstMessage.size != XX_MESSAGE_1_SIZE) { Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE") } - Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern") + val ePrefix = firstMessage.take(4).toByteArray().toHexString() + Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern") return firstMessage } catch (e: Exception) { state = NoiseSessionState.Failed(e) @@ -344,19 +362,24 @@ class NoiseSession( */ @Synchronized fun processHandshakeMessage(message: ByteArray): ByteArray? { - Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)") + val inputPrefix = message.take(4).toByteArray().toHexString() + Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix") try { // Initialize as responder if receiving first message if (state == NoiseSessionState.Uninitialized && !isInitiator) { initializeNoiseHandshake(HandshakeState.RESPONDER) state = NoiseSessionState.Handshaking + if (handshakeStartMs == null) { + handshakeStartMs = System.currentTimeMillis() + } Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID") } if (state != NoiseSessionState.Handshaking) { throw IllegalStateException("Invalid state for handshake: $state") } + lastHandshakeActivityMs = System.currentTimeMillis() val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null") @@ -366,7 +389,8 @@ class NoiseSession( // Read the incoming message - the Noise library will handle validation val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0) currentPattern++ - Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern") + val readPrefix = message.take(4).toByteArray().toHexString() + Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern") // Check what action the handshake state wants us to take next val action = handshakeStateLocal.getAction() diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index 2d9e06d8..618f26be 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap */ class NoiseSessionManager( private val localStaticPrivateKey: ByteArray, - private val localStaticPublicKey: ByteArray + private val localStaticPublicKey: ByteArray, + private val localPeerID: String ) { companion object { private const val TAG = "NoiseSessionManager" + private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_MESSAGE_1_SIZE = 32 } private val sessions = ConcurrentHashMap() @@ -51,11 +54,30 @@ class NoiseSessionManager( /** * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ - fun initiateHandshake(peerID: String): ByteArray { + fun initiateHandshake(peerID: String): ByteArray? { Log.d(TAG, "initiateHandshake($peerID)") - // Remove any existing session first - removeSession(peerID) + val now = System.currentTimeMillis() + val existing = getSession(peerID) + if (existing != null) { + when { + existing.isEstablished() -> { + Log.d(TAG, "Handshake already established with $peerID, skipping initiate") + return null + } + existing.isHandshaking() -> { + if (!isHandshakeStale(existing, now)) { + Log.d(TAG, "Handshake already in progress with $peerID, not restarting") + return null + } + Log.d(TAG, "Handshake with $peerID is stale; restarting") + removeSession(peerID) + } + else -> { + removeSession(peerID) + } + } + } // Create new session as initiator val session = NoiseSession( @@ -85,6 +107,23 @@ class NoiseSessionManager( try { var session = getSession(peerID) + + // Collision handling: both sides initiated and we received message 1 + if (session != null && + session.isHandshaking() && + session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE + ) { + val shouldYield = localPeerID > peerID + if (shouldYield) { + Log.d(TAG, "Handshake collision with $peerID; yielding to responder role") + removeSession(peerID) + session = null + } else { + Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") + return null + } + } // If no session exists, create one as responder if (session == null) { @@ -119,6 +158,12 @@ class NoiseSessionManager( throw e } } + + private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean { + val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() + if (lastActivity == null) return false + return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS + } /** * SIMPLIFIED: Encrypt data diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index bdfc9733..60c6e5e4 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -26,6 +26,7 @@ fun BluetoothCheckScreen( status: BluetoothStatus, onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, isLoading: Boolean = false ) { val colorScheme = MaterialTheme.colorScheme @@ -39,13 +40,15 @@ fun BluetoothCheckScreen( BluetoothDisabledContent( onEnableBluetooth = onEnableBluetooth, onRetry = onRetry, + onSkip = onSkip, colorScheme = colorScheme, isLoading = isLoading ) } BluetoothStatus.NOT_SUPPORTED -> { BluetoothNotSupportedContent( - colorScheme = colorScheme + colorScheme = colorScheme, + onSkip = onSkip ) } BluetoothStatus.ENABLED -> { @@ -61,6 +64,7 @@ fun BluetoothCheckScreen( private fun BluetoothDisabledContent( onEnableBluetooth: () -> Unit, onRetry: () -> Unit, + onSkip: () -> Unit, colorScheme: ColorScheme, isLoading: Boolean ) { @@ -77,7 +81,7 @@ private fun BluetoothDisabledContent( ) Text( - text = stringResource(R.string.bluetooth_required), + text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, @@ -141,20 +145,17 @@ private fun BluetoothDisabledContent( ) } - //Since we are automatically checking bluetooth state -- commented - -// OutlinedButton( -// onClick = onRetry, -// modifier = Modifier.fillMaxWidth() -// ) { -// Text( -// text = "Check Again", -// style = MaterialTheme.typography.bodyMedium.copy( -// fontFamily = FontFamily.Monospace -// ), -// modifier = Modifier.padding(vertical = 4.dp) -// ) -// } + TextButton( + onClick = onSkip, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.skip), + style = MaterialTheme.typography.labelLarge.copy( + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + ) + } } } } @@ -162,7 +163,8 @@ private fun BluetoothDisabledContent( @Composable private fun BluetoothNotSupportedContent( - colorScheme: ColorScheme + colorScheme: ColorScheme, + onSkip: () -> Unit ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp), @@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent( textAlign = TextAlign.Center ) } + + Button( + onClick = onSkip, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary + ) + ) { + Text(text = stringResource(R.string.continue_btn)) + } } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt index 701e9d19..674deb7d 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -271,6 +271,7 @@ class OnboardingCoordinator( permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" permission.contains("BACKGROUND") -> "Background Location" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" + permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index e982d2e1..48138d66 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications + PermissionType.WIFI_AWARE -> Icons.Filled.Wifi PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.OTHER -> Icons.Filled.Settings } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index c32f855b..8fcf9c31 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -23,6 +23,22 @@ class PermissionManager(private val context: Context) { private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private fun shouldRequireWifiAwarePermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + val enabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true) + } catch (_: Exception) { + true + } + if (!enabled) return false + + return try { + com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context) + } catch (_: Exception) { + false + } + } + /** * Check if this is the first time the user is launching the app */ @@ -69,6 +85,11 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (shouldRequireWifiAwarePermission()) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // Notification permission intentionally excluded to keep it optional return permissions @@ -209,6 +230,20 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (shouldRequireWifiAwarePermission()) { + val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + categories.add( + PermissionCategory( + type = PermissionType.WIFI_AWARE, + description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.", + permissions = wifiAwarePermissions, + isGranted = wifiAwarePermissions.all { isPermissionGranted(it) }, + systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices" + ) + ) + } + if (needsBackgroundLocationPermission()) { val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) categories.add( @@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) { BACKGROUND_LOCATION("Background Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), + WIFI_AWARE("Wi‑Fi Aware"), BATTERY_OPTIMIZATION("Battery Optimization"), OTHER("Other") } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index db17ba1c..88e5f783 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -255,6 +255,10 @@ object BinaryProtocol { if (packet.version >= 2u.toUByte()) { buffer.putInt(payloadDataSize) // 4 bytes for v2+ } else { + if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) { + Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes") + return null + } buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1 } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index 1c3abaf9..f4ca9f74 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -3,7 +3,7 @@ package com.bitchat.android.service import android.app.Application import android.os.Process import androidx.core.app.NotificationManagerCompat -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope @@ -39,7 +39,7 @@ object AppShutdownCoordinator { fun requestFullShutdownAndKill( app: Application, - mesh: BluetoothMeshService?, + mesh: MeshService?, notificationManager: NotificationManagerCompat, stopForeground: () -> Unit, stopService: () -> Unit diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index aa9e6fc3..218631ea 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -115,6 +115,8 @@ class MeshForegroundService : Service() { private var updateJob: Job? = null private val meshService: BluetoothMeshService? get() = MeshServiceHolder.meshService + private val unifiedMeshService: com.bitchat.android.mesh.MeshService? + get() = MeshServiceHolder.unifiedMeshService private val serviceJob = Job() private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false @@ -134,6 +136,7 @@ class MeshForegroundService : Service() { Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") MeshServiceHolder.attach(created) } + MeshServiceHolder.getUnifiedOrCreate(applicationContext) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -147,7 +150,7 @@ class MeshForegroundService : Service() { when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly - try { meshService?.stopServices() } catch (_: Exception) { } + try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } try { stopForeground(true) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) @@ -165,7 +168,7 @@ class MeshForegroundService : Service() { // Fully stop all background activity, stop Tor (without changing setting), then kill the app AppShutdownCoordinator.requestFullShutdownAndKill( app = application, - mesh = meshService, + mesh = unifiedMeshService, notificationManager = notificationManager, stopForeground = { try { stopForeground(true) } catch (_: Exception) { } @@ -178,7 +181,7 @@ class MeshForegroundService : Service() { ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val n = buildNotification(meshService?.getActivePeerCount() ?: 0) + val n = buildNotification(getUnifiedActivePeerCount()) startForegroundCompat(n) isInForeground = true } else { @@ -193,7 +196,7 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) + val notification = buildNotification(getUnifiedActivePeerCount()) startForegroundCompat(notification) isInForeground = true } @@ -226,6 +229,21 @@ class MeshForegroundService : Service() { private fun ensureMeshStarted() { if (isShuttingDown) return + try { + com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") + } + + val bleEnabled = try { + com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) + } catch (_: Exception) { + true + } + if (!bleEnabled) { + try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } + return + } if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") @@ -241,7 +259,7 @@ class MeshForegroundService : Service() { notificationManager.cancel(NOTIFICATION_ID) return } - val count = meshService?.getActivePeerCount() ?: 0 + val count = getUnifiedActivePeerCount() val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { notificationManager.notify(NOTIFICATION_ID, notification) @@ -261,6 +279,14 @@ class MeshForegroundService : Service() { return hasBluetoothPermissions() && hasNotificationPermission() } + private fun getUnifiedActivePeerCount(): Int { + return try { + unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0 + } catch (_: Exception) { + 0 + } + } + private fun hasBluetoothPermissions(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab29..1ff4ec29 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -2,6 +2,10 @@ package com.bitchat.android.service import android.content.Context import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.UnifiedMeshService +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.sync.GossipSyncManager /** * Process-wide holder to share a single BluetoothMeshService instance @@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService */ object MeshServiceHolder { private const val TAG = "MeshServiceHolder" + @Volatile + var sharedGossipSyncManager: GossipSyncManager? = null + private set + + private val activeGossipOwners = mutableSetOf() + + @Synchronized + fun setGossipManager( + mgr: GossipSyncManager, + signer: (BitchatPacket) -> BitchatPacket + ) { + val previous = sharedGossipSyncManager + if (previous !== mgr) { + try { previous?.stop() } catch (_: Exception) { } + } + sharedGossipSyncManager = mgr + mgr.delegate = TransportGossipDelegate(signer) + if (activeGossipOwners.isNotEmpty()) { + mgr.start() + } + } + + @Synchronized + fun startSharedGossip(owner: String) { + val wasIdle = activeGossipOwners.isEmpty() + activeGossipOwners.add(owner) + if (wasIdle) { + sharedGossipSyncManager?.start() + } + } + + @Synchronized + fun stopSharedGossip(owner: String) { + activeGossipOwners.remove(owner) + if (activeGossipOwners.isEmpty()) { + sharedGossipSyncManager?.stop() + } + } + + private class TransportGossipDelegate( + private val signer: (BitchatPacket) -> BitchatPacket + ) : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + TransportBridgeService.sendToPeerFromLocal(peerID, packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signer(packet) + } + } + @Volatile var meshService: BluetoothMeshService? = null private set + @Volatile + var unifiedMeshService: UnifiedMeshService? = null + private set + @Synchronized fun getOrCreate(context: Context): BluetoothMeshService { val existing = meshService @@ -31,18 +94,35 @@ object MeshServiceHolder { val created = BluetoothMeshService(context.applicationContext) android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") meshService = created + unifiedMeshService = null created } } catch (e: Exception) { android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") val created = BluetoothMeshService(context.applicationContext) meshService = created + unifiedMeshService = null created } } val created = BluetoothMeshService(context.applicationContext) android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") meshService = created + unifiedMeshService = null + return created + } + + @Synchronized + fun getUnifiedOrCreate(context: Context): UnifiedMeshService { + val bluetooth = getOrCreate(context) + val existing = unifiedMeshService + if (existing != null) { + existing.refreshDelegates() + return existing + } + val created = UnifiedMeshService(context.applicationContext, bluetooth) + unifiedMeshService = created + android.util.Log.i(TAG, "Created new UnifiedMeshService") return created } @@ -50,11 +130,16 @@ object MeshServiceHolder { fun attach(service: BluetoothMeshService) { android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") meshService = service + unifiedMeshService = null } @Synchronized fun clear() { android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + try { sharedGossipSyncManager?.stop() } catch (_: Exception) { } + sharedGossipSyncManager = null + activeGossipOwners.clear() meshService = null + unifiedMeshService = null } } diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt new file mode 100644 index 00000000..9422be0e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -0,0 +1,184 @@ +package com.bitchat.android.service + +import android.util.Log +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.util.toHexString +import java.security.MessageDigest +import java.util.Collections +import java.util.LinkedHashMap +import java.util.concurrent.ConcurrentHashMap + +/** + * Central bridge for routing packets between different transport layers + * (e.g., Bluetooth LE <-> Wi-Fi Aware). + * + * Allows a packet received on one transport to be seamlessly relayed + * to all other active transports, effectively bridging separate meshes. + */ +object TransportBridgeService { + private const val TAG = "TransportBridgeService" + private const val MAX_SEEN_PACKETS = 4096 + private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L + + /** + * Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement + * to receive bridged packets. + */ + interface TransportLayer { + /** + * Send a packet out via this transport. + */ + fun send(packet: RoutedPacket) + + /** + * Send a packet to a specific peer via this transport (optional). + */ + fun sendToPeer(peerID: String, packet: BitchatPacket) { } + } + + private val transports = ConcurrentHashMap() + private val seenPackets = Collections.synchronizedMap( + object : LinkedHashMap(MAX_SEEN_PACKETS, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > MAX_SEEN_PACKETS + } + } + ) + + /** + * Register a transport layer to receive bridged packets. + * @param id Unique identifier (e.g., "BLE", "WIFI") + * @param layer The transport implementation + */ + fun register(id: String, layer: TransportLayer) { + Log.i(TAG, "Registering transport layer: $id") + transports[id] = layer + } + + /** + * Unregister a transport layer. + */ + fun unregister(id: String) { + Log.i(TAG, "Unregistering transport layer: $id") + transports.remove(id) + } + + /** + * Broadcast a packet from a specific source transport to ALL other registered transports. + * + * @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE"). + * The packet will NOT be sent back to this source. + * @param packet The packet to bridge. + */ + fun broadcast(sourceId: String, packet: RoutedPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return + val forwarded = packet.copy(packet = forwardedPacket) + + // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") + + targets.forEach { (id, layer) -> + try { + layer.send(forwarded) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge packet to $id: ${e.message}") + } + } + } + + /** + * Send a packet to a specific peer across all other transports. + */ + fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) { + val targets = transports.filterKeys { it != sourceId } + if (targets.isEmpty()) return + val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, forwardedPacket) + } catch (e: Exception) { + Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet to every active transport without applying relay TTL + * handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is + * intentionally zero on the first radio hop. + */ + fun broadcastFromLocal(packet: RoutedPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.send(packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local packet to $id: ${e.message}") + } + } + } + + /** + * Send a locally originated packet directly to a peer on every active transport. + */ + fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) { + val targets = transports.toMap() + if (targets.isEmpty()) return + + targets.forEach { (id, layer) -> + try { + layer.sendToPeer(peerID, packet) + } catch (e: Exception) { + Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}") + } + } + } + + private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? { + if (packet.ttl == 0u.toUByte()) { + Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") + return null + } + + val key = "$kind:${logicalPacketId(packet)}" + val now = System.currentTimeMillis() + synchronized(seenPackets) { + pruneSeen(now) + val previous = seenPackets[key] + if (previous != null && now - previous < SEEN_PACKET_TTL_MS) { + Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}") + return null + } + seenPackets[key] = now + } + + return packet.copy(ttl = (packet.ttl - 1u).toUByte()) + } + + private fun pruneSeen(now: Long) { + val iterator = seenPackets.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (now - entry.value > SEEN_PACKET_TTL_MS) { + iterator.remove() + } + } + } + + private fun logicalPacketId(packet: BitchatPacket): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(packet.type.toByte()) + digest.update(packet.senderID) + packet.recipientID?.let { digest.update(it) } + digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8)) + digest.update(packet.payload) + packet.route?.forEach { digest.update(it) } + packet.signature?.let { digest.update(it) } + return digest.digest().toHexString() + } +} 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 0997beff..7a935396 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -14,6 +14,9 @@ object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() private val seenPublicMessageKeys = mutableSetOf() + private val peerIdsByTransport = mutableMapOf>() + // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. + private val directPeerIdsByTransport = mutableMapOf>() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -31,7 +34,55 @@ object AppStateStore { val channelMessages: StateFlow>> = _channelMessages.asStateFlow() fun setPeers(ids: List) { - _peers.value = ids + synchronized(this) { + _peers.value = ids.distinct() + } + } + + fun setTransportPeers(transportId: String, ids: List) { + synchronized(this) { + peerIdsByTransport[transportId] = ids.toSet() + publishTransportPeersLocked() + } + } + + fun clearTransportPeers(transportId: String) { + synchronized(this) { + peerIdsByTransport.remove(transportId) + publishTransportPeersLocked() + } + } + + private fun publishTransportPeersLocked() { + _peers.value = peerIdsByTransport.values + .asSequence() + .flatten() + .distinct() + .toList() + } + + /** + * Record the set of direct (single-hop) peers reachable over a given transport. Each transport + * (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every + * transport can gossip the same complete neighbor list under our shared node identity. + */ + fun setTransportDirectPeers(transportId: String, ids: Collection) { + synchronized(this) { + directPeerIdsByTransport[transportId] = ids.toSet() + } + } + + fun clearTransportDirectPeers(transportId: String) { + synchronized(this) { + directPeerIdsByTransport.remove(transportId) + } + } + + /** Union of direct peers across all transports. */ + fun getDirectPeers(): Set { + synchronized(this) { + return directPeerIdsByTransport.values.flatten().toSet() + } } fun addPublicMessage(msg: BitchatMessage) { @@ -104,6 +155,8 @@ object AppStateStore { synchronized(this) { seenMessageIds.clear() seenPublicMessageKeys.clear() + peerIdsByTransport.clear() + directPeerIdsByTransport.clear() _peers.value = emptyList() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() 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 a6bac09a..77949697 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -2,23 +2,23 @@ package com.bitchat.android.services import android.content.Context import android.util.Log -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport /** - * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. + * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ class MessageRouter private constructor( private val context: Context, - private var mesh: BluetoothMeshService, + private var mesh: MeshService, private val nostr: NostrTransport ) { companion object { private const val TAG = "MessageRouter" @Volatile private var INSTANCE: MessageRouter? = null fun tryGetInstance(): MessageRouter? = INSTANCE - fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { + fun getInstance(context: Context, mesh: MeshService): MessageRouter { val instance = INSTANCE ?: synchronized(this) { INSTANCE ?: run { val nostr = NostrTransport.getInstance(context) @@ -70,9 +70,8 @@ class MessageRouter private constructor( } } - val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true - val hasEstablished = mesh.hasEstablishedSession(toPeerID) - if (hasMesh && hasEstablished) { + 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)) { @@ -83,12 +82,12 @@ class MessageRouter private constructor( val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") - mesh.initiateNoiseHandshake(toPeerID) + if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { + 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) } else { @@ -113,11 +112,11 @@ class MessageRouter private constructor( } fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { - if (mesh.getPeerInfo(toPeerID)?.isConnected == true) { + if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) { 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) + mesh.sendPrivateMessage(content, toPeerID, nickname, null) } else { nostr.sendFavoriteNotification(toPeerID, isFavorite) } @@ -131,11 +130,11 @@ class MessageRouter private constructor( val iterator = queued.iterator() while (iterator.hasNext()) { val (content, nickname, messageID) = iterator.next() - var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) + 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 = resolveMeshPeerForNoiseHex(peerID) - if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) { + val meshPeer = resolvePeerForNoiseHex(peerID, mesh) + if (meshPeer != null && isReady(mesh, meshPeer)) { mesh.sendPrivateMessage(content, meshPeer, nickname, messageID) iterator.remove() continue @@ -182,10 +181,27 @@ class MessageRouter private constructor( return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } - private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? { + private fun isConnected(service: MeshService, peerID: String): Boolean { return try { - mesh.getPeerNicknames().keys.firstOrNull { pid -> - val info = mesh.getPeerInfo(pid) + service.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } + } + + private fun isReady(service: MeshService, peerID: String): Boolean { + return try { + service.getPeerInfo(peerID)?.isConnected == true && + service.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + 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) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 99244290..7273813b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -444,7 +444,7 @@ private fun MainHeader( ) Spacer(modifier = Modifier.width(2.dp)) PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, + connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, isConnected = isConnected, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0d22a0d5..bc83a9f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -133,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) { MessagesList( messages = displayMessages, currentUserNickname = nickname, - meshService = viewModel.meshService, + meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 82db6b64..e3097eed 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Shield import androidx.compose.ui.graphics.vector.ImageVector import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import androidx.compose.material3.ColorScheme import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.text.SimpleDateFormat @@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color { fun formatMessageAsAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) ): AnnotatedString { @@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString( fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) ): AnnotatedString { 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 a1f13a7a..2dd82cdb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType @@ -38,12 +39,16 @@ import java.security.MessageDigest */ class ChatViewModel( application: Application, - initialMeshService: BluetoothMeshService + initialMeshService: BluetoothMeshService, + initialUnifiedMeshService: MeshService ) : AndroidViewModel(application), BluetoothMeshDelegate { // Made var to support mesh service replacement after panic clear var meshService: BluetoothMeshService = initialMeshService private set + private var unifiedMeshService: MeshService = initialUnifiedMeshService + private val mesh: MeshService + get() = unifiedMeshService private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { @@ -93,9 +98,9 @@ class ChatViewModel( // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { - override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID) - override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID) - override fun getMyPeerID(): String = meshService.myPeerID + override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID) + override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID) + override fun getMyPeerID(): String = mesh.myPeerID } val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) @@ -109,7 +114,7 @@ class ChatViewModel( private val verificationHandler = VerificationHandler( context = application.applicationContext, scope = viewModelScope, - getMeshService = { meshService }, + getMeshService = { mesh }, identityManager = identityManager, state = state, notificationManager = notificationManager, @@ -118,7 +123,7 @@ class ChatViewModel( val verifiedFingerprints = verificationHandler.verifiedFingerprints // Media file sending manager - private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService } + private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -129,8 +134,8 @@ class ChatViewModel( notificationManager = notificationManager, coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, - getMyPeerID = { meshService.myPeerID }, - getMeshService = { meshService } + getMyPeerID = { mesh.myPeerID }, + getMeshService = { mesh } ) // New Geohash architecture ViewModel (replaces God object service usage in UI path) @@ -184,6 +189,18 @@ class ChatViewModel( val geohashPeople: StateFlow> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val meshServiceFacade: MeshService + get() = mesh + val myPeerID: String + get() = mesh.myPeerID + + fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID) + + fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID) + + fun initiateMeshHandshake(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } init { // Note: Mesh service delegate is now set by MainActivity @@ -208,7 +225,7 @@ class ChatViewModel( // Recompute unread set using SeenMessageStore for robustness across Activity recreation try { val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) - val myNick = state.getNicknameValue() ?: meshService.myPeerID + val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() byPeer.forEach { (peer, list) -> if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) @@ -299,7 +316,7 @@ class ChatViewModel( // Ensure NostrTransport knows our mesh peer ID for embedded packets try { val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.senderPeerID = mesh.myPeerID } catch (_: Exception) { } // Note: Mesh service is now started by MainActivity @@ -317,7 +334,7 @@ class ChatViewModel( fun setNickname(newNickname: String) { state.setNickname(newNickname) dataManager.saveNickname(newNickname) - meshService.sendBroadcastAnnounce() + mesh.sendBroadcastAnnounce() } /** @@ -363,7 +380,7 @@ class ChatViewModel( // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { - return channelManager.joinChannel(channel, password, meshService.myPeerID) + return channelManager.joinChannel(channel, password, mesh.myPeerID) } fun switchToChannel(channel: String?) { @@ -372,7 +389,7 @@ class ChatViewModel( fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) - meshService.sendMessage("left $channel") + mesh.sendMessage("left $channel", emptyList(), null) } // MARK: - Private Chat Management (delegated) @@ -383,7 +400,7 @@ class ChatViewModel( ensureGeohashDMSubscriptionIfNeeded(peerID) } - val success = privateChatManager.startPrivateChat(peerID, meshService) + val success = privateChatManager.startPrivateChat(peerID, mesh) if (success) { // Notify notification manager about current private chat setCurrentPrivateChatPeer(peerID) @@ -420,7 +437,7 @@ class ChatViewModel( val unreadKeys = state.getUnreadPrivateMessagesValue() if (unreadKeys.isEmpty()) return - val me = state.getNicknameValue() ?: meshService.myPeerID + val me = state.getNicknameValue() ?: mesh.myPeerID val chats = state.getPrivateChatsValue() // Pick the latest incoming message among unread conversations @@ -451,8 +468,8 @@ class ChatViewModel( val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = targetKey, connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + 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) } ) @@ -476,24 +493,23 @@ class ChatViewModel( // Check for commands if (content.startsWith("/")) { val selectedLocationForCommand = state.selectedLocationChannel.value - commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> + commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel -> if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { // Route command-generated public messages via Nostr in geohash channels geohashViewModel.sendGeohashMessage( messageContent, selectedLocationForCommand.channel, - meshService.myPeerID, + mesh.myPeerID, state.getNicknameValue() ) } else { - // Default: route via mesh - meshService.sendMessage(messageContent, mentions, channel) + mesh.sendMessage(messageContent, mentions, channel) } - }) + }, this) return } - val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue()) + 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" @@ -505,13 +521,13 @@ class ChatViewModel( selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( selectedPeerID = selectedPeer, connectedPeers = state.getConnectedPeersValue(), - meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + 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, meshService) + privateChatManager.startPrivateChat(canonical, mesh) // If we're in the private chat sheet, update its active peer too if (state.getPrivateChatSheetPeerValue() != null) { showPrivateChatSheet(canonical) @@ -519,16 +535,16 @@ class ChatViewModel( } } // Send private message - val recipientNickname = meshService.getPeerNicknames()[selectedPeer] + val recipientNickname = nicknameForPeer(selectedPeer) privateChatManager.sendPrivateMessage( content, selectedPeer, recipientNickname, state.getNicknameValue(), - meshService.myPeerID + mesh.myPeerID ) { messageContent, peerID, recipientNicknameParam, messageId -> // Route via MessageRouter (mesh when connected+established, else Nostr) - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService) + val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh) router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) } } else { @@ -536,21 +552,21 @@ class ChatViewModel( val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event - geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) + geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue()) } else { // Send public/channel message via mesh val message = BitchatMessage( - sender = state.getNicknameValue() ?: meshService.myPeerID, + sender = state.getNicknameValue() ?: mesh.myPeerID, content = content, timestamp = Date(), isRelay = false, - senderPeerID = meshService.myPeerID, + senderPeerID = mesh.myPeerID, mentions = if (mentions.isNotEmpty()) mentions else null, channel = currentChannelValue ) if (currentChannelValue != null) { - channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID) + channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) // Check if encrypted channel if (channelManager.hasChannelKey(currentChannelValue)) { @@ -559,21 +575,20 @@ class ChatViewModel( mentions, currentChannelValue, state.getNicknameValue(), - meshService.myPeerID, + mesh.myPeerID, onEncryptedPayload = { encryptedData -> - // This would need proper mesh service integration - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) }, onFallback = { - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) } ) } else { - meshService.sendMessage(content, mentions, currentChannelValue) + mesh.sendMessage(content, mentions, currentChannelValue) } } else { messageManager.addMessage(message) - meshService.sendMessage(content, mentions, null) + mesh.sendMessage(content, mentions, null) } } } @@ -582,7 +597,7 @@ class ChatViewModel( // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { - return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key + return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key } fun toggleFavorite(peerID: String) { @@ -592,10 +607,10 @@ class ChatViewModel( // Persist relationship in FavoritesPersistenceService try { var noiseKey: ByteArray? = null - var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID + var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID // Case 1: Live mesh peer with known info - val peerInfo = meshService.getPeerInfo(peerID) + val peerInfo = mesh.getPeerInfo(peerID) if (peerInfo?.noisePublicKey != null) { noiseKey = peerInfo.noisePublicKey nickname = peerInfo.nickname @@ -625,22 +640,9 @@ class ChatViewModel( // Send favorite notification via mesh or Nostr with our npub if available try { - val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}" - // Prefer mesh if session established, else try Nostr - if (meshService.hasEstablishedSession(peerID)) { - // Reuse existing private message path for notifications - meshService.sendPrivateMessage( - announcementContent, - peerID, - nickname, - java.util.UUID.randomUUID().toString() - ) - } else { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) - } + com.bitchat.android.services.MessageRouter + .getInstance(getApplication(), mesh) + .sendFavoriteNotification(peerID, isNowFavorite) } catch (_: Exception) { } } } catch (_: Exception) { } @@ -656,6 +658,40 @@ class ChatViewModel( Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") } + + private fun isConnectedOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnMesh(peerID: String): Boolean { + return try { + mesh.getPeerInfo(peerID)?.isConnected == true && + mesh.hasEstablishedSession(peerID) + } catch (_: Exception) { + false + } + } + + private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean { + return hasEstablishedSessionOnMesh(peerID) + } + + private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) { + mesh.initiateNoiseHandshake(peerID) + } + + private fun nicknameForPeer(peerID: String): String? { + return state.peerNicknames.value[peerID] + ?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null } + } + + private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState { + return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized } + } /** * Initialize session state monitoring for reactive UI updates @@ -680,7 +716,7 @@ class ChatViewModel( // Update session states val prevStates = state.getPeerSessionStatesValue() val sessionStates = currentPeers.associateWith { peerID -> - meshService.getSessionState(peerID).toString() + sessionStateForPeer(peerID).toString() } state.setPeerSessionStates(sessionStates) // Detect new established sessions and flush router outbox for them and their noiseHex aliases @@ -688,7 +724,7 @@ class ChatViewModel( val old = prevStates[peerID] if (old != "established" && newState == "established") { com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), meshService) + .getInstance(getApplication(), mesh) .onSessionEstablished(peerID) } } @@ -697,7 +733,7 @@ class ChatViewModel( state.setPeerFingerprints(fingerprints) fingerprints.forEach { (peerID, fingerprint) -> identityManager.cachePeerFingerprint(peerID, fingerprint) - val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null } + val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null } val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() if (noiseKeyHex != null) { identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) @@ -708,23 +744,21 @@ class ChatViewModel( } } - val nicknames = meshService.getPeerNicknames() - state.setPeerNicknames(nicknames) + state.setPeerNicknames(mesh.getPeerNicknames()) - val rssiValues = meshService.getPeerRSSI() - state.setPeerRSSI(rssiValues) + state.setPeerRSSI(mesh.getPeerRSSI()) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - meshService.getPeerInfo(pid)?.isDirectConnection == true + mesh.getPeerInfo(pid)?.isDirectConnection == true } state.setPeerDirect(directMap) } catch (_: Exception) { } // Flush any pending QR verification once a Noise session is established currentPeers.forEach { peerID -> - if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) { verificationHandler.sendPendingVerificationIfNeeded(peerID) } } @@ -754,7 +788,7 @@ class ChatViewModel( // MARK: - Debug and Troubleshooting fun getDebugStatus(): String { - return meshService.getDebugStatus() + return mesh.getDebugStatus() } fun setCurrentPrivateChatPeer(peerID: String?) { @@ -851,7 +885,7 @@ class ChatViewModel( // MARK: - Mention Autocomplete fun updateMentionSuggestions(input: String) { - commandProcessor.updateMentionSuggestions(input, meshService, this) + commandProcessor.updateMentionSuggestions(input, mesh, this) } fun selectMentionSuggestion(nickname: String, currentText: String): String { @@ -867,7 +901,7 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) } - + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer) } @@ -956,7 +990,7 @@ class ChatViewModel( // Recreate mesh service with fresh identity recreateMeshServiceAfterPanic() - Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}") + Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } /** @@ -964,25 +998,27 @@ class ChatViewModel( * This ensures the new cryptographic keys are used for a new peer ID. */ private fun recreateMeshServiceAfterPanic() { - val oldPeerID = meshService.myPeerID + val oldPeerID = mesh.myPeerID // Clear the holder so getOrCreate() returns a fresh instance MeshServiceHolder.clear() // Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData) val freshMeshService = MeshServiceHolder.getOrCreate(getApplication()) + val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication()) // Replace our reference and set up the new service meshService = freshMeshService - meshService.delegate = this + unifiedMeshService = freshUnifiedMeshService + mesh.delegate = this // Restart mesh operations with new identity - meshService.startServices() - meshService.sendBroadcastAnnounce() + mesh.startServices() + mesh.sendBroadcastAnnounce() Log.d( TAG, - "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}" + "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" ) } @@ -992,7 +1028,7 @@ class ChatViewModel( private fun clearAllMeshServiceData() { try { // Request mesh service to clear all its internal data - meshService.clearAllInternalData() + mesh.clearAllInternalData() Log.d(TAG, "✅ Cleared all mesh service data") } catch (e: Exception) { @@ -1006,7 +1042,7 @@ class ChatViewModel( private fun clearAllCryptographicData() { try { // Clear encryption service persistent identity (Ed25519 signing keys) - meshService.clearAllEncryptionData() + mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 499b3926..a92d2d60 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,6 +1,6 @@ package com.bitchat.android.ui -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date @@ -29,21 +29,21 @@ class CommandProcessor( // MARK: - Command Processing - fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { + fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean { if (!command.startsWith("/")) return false val parts = command.split(" ") val cmd = parts.first().lowercase() when (cmd) { "/j", "/join" -> handleJoinCommand(parts, myPeerID) - "/m", "/msg" -> handleMessageCommand(parts, meshService) + "/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel) "/w" -> handleWhoCommand(meshService, viewModel) "/clear" -> handleClearCommand() "/pass" -> handlePassCommand(parts, myPeerID) "/block" -> handleBlockCommand(parts, meshService) "/unblock" -> handleUnblockCommand(parts, meshService) - "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage) - "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage) + "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel) + "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel) "/channels" -> handleChannelsCommand() else -> handleUnknownCommand(cmd) } @@ -77,7 +77,7 @@ class CommandProcessor( } } - private fun handleMessageCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleMessageCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") val peerID = getPeerIDForNickname(targetName, meshService) @@ -96,8 +96,7 @@ class CommandProcessor( state.getNicknameValue(), getMyPeerID(meshService) ) { content, peerIdParam, recipientNicknameParam, messageId -> - // This would trigger the actual mesh service send - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) } } else { val systemMessage = BitchatMessage( @@ -129,7 +128,7 @@ class CommandProcessor( } } - private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) { // Channel-aware who command (matches iOS behavior) val (peerList, contextDescription) = if (viewModel != null) { when (val selectedChannel = viewModel.selectedLocationChannel.value) { @@ -248,7 +247,7 @@ class CommandProcessor( } } - private fun handleBlockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleBlockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.blockPeerByNickname(targetName, meshService) @@ -265,7 +264,7 @@ class CommandProcessor( } } - private fun handleUnblockCommand(parts: List, meshService: BluetoothMeshService) { + private fun handleUnblockCommand(parts: List, meshService: MeshService) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") privateChatManager.unblockPeerByNickname(targetName, meshService) @@ -284,9 +283,10 @@ class CommandProcessor( parts: List, verb: String, object_: String, - meshService: BluetoothMeshService, + meshService: MeshService, myPeerID: String, - onSendMessage: (String, List, String?) -> Unit + onSendMessage: (String, List, String?) -> Unit, + viewModel: ChatViewModel? ) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") @@ -306,7 +306,7 @@ class CommandProcessor( state.getNicknameValue(), myPeerID ) { content, peerIdParam, recipientNicknameParam, messageId -> - sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId) + sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel) } } else if (isInLocationChannel) { // Let the transport layer add the echo; just send it out @@ -423,7 +423,7 @@ class CommandProcessor( // MARK: - Mention Autocomplete - fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) { + fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) { // Check if input contains @ and we're at the end of a word or at the end of input val atIndex = input.lastIndexOf('@') if (atIndex == -1) { @@ -503,19 +503,33 @@ class CommandProcessor( // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { - return meshService.getPeerNicknames()[peerID] ?: peerID + private fun getPeerNickname(peerID: String, meshService: MeshService): String { + return meshService.getPeerNicknames()[peerID] + ?: peerID } - private fun getMyPeerID(meshService: BluetoothMeshService): String { + private fun getMyPeerID(meshService: MeshService): String { return meshService.myPeerID } - private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) { - meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + private fun sendPrivateMessageVia( + meshService: MeshService, + content: String, + peerID: String, + recipientNickname: String, + messageId: String, + viewModel: ChatViewModel? + ) { + if (viewModel != null) { + com.bitchat.android.services.MessageRouter + .getInstance(viewModel.getApplication(), meshService) + .sendPrivate(content, peerID, recipientNickname, messageId) + } else { + meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 5bf7003b..99ed10cc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -656,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String { } private fun meshCount(viewModel: ChatViewModel): Int { - val myID = viewModel.meshService.myPeerID + val myID = viewModel.myPeerID return viewModel.connectedPeers.value?.count { peerID -> peerID != myID } ?: 0 diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 9c7d776f..045ea127 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation( message: com.bitchat.android.model.BitchatMessage, messages: List = emptyList(), currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme, timeFormatter: java.text.SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, @@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation( private fun AnimatedMessageDisplay( message: com.bitchat.android.model.BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme, timeFormatter: java.text.SimpleDateFormat, modifier: Modifier = Modifier @@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay( private fun formatMessageAsAnnotatedStringWithoutTimestamp( message: com.bitchat.android.model.BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.BluetoothMeshService, + meshService: com.bitchat.android.mesh.MeshService, colorScheme: androidx.compose.material3.ColorScheme ): AnnotatedString { // Get the full formatted text first diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 8de53239..b7e87f91 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -1,7 +1,7 @@ package com.bitchat.android.ui import android.util.Log -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType @@ -16,10 +16,10 @@ class MediaSendingManager( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val getMeshService: () -> BluetoothMeshService + private val getMeshService: () -> MeshService ) { // Helper to get current mesh service (may change after panic clear) - private val meshService: BluetoothMeshService + private val meshService: MeshService get() = getMeshService() companion object { private const val TAG = "MediaSendingManager" 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 d27719c7..f77e146d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -2,7 +2,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.ui.NotificationTextUtils -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import kotlinx.coroutines.CoroutineScope @@ -21,7 +21,7 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> BluetoothMeshService + private val getMeshService: () -> MeshService ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { @@ -96,99 +96,104 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) - state.setIsConnected(peers.isNotEmpty()) - notificationManager.showActiveUserNotification(peers) - // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + processPeerUpdate(peers.distinct()) + } + } - // Clean up channel members who disconnected - channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) + private suspend fun processPeerUpdate(mergedPeers: List) { + state.setConnectedPeers(mergedPeers) + state.setIsConnected(mergedPeers.isNotEmpty()) + notificationManager.showActiveUserNotification(mergedPeers) + + // Flush router outbox for any peers that just connected (and their noiseHex aliases) + runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) } - // Handle chat view migration based on current selection and new peer list - 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]+$")) + // Clean up channel members who disconnected + channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID()) - 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 = peers, - meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, - meshHasPeer = { pid -> peers.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 - } + // Handle chat view migration based on current selection and new peer list + 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) } - ) - 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 && !peers.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) + } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } } - } else { - privateChatManager.cleanupDisconnectedPeer(currentPeer) + }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + 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) } } + } - // 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. - peers.forEach { pid -> - try { - val info = getPeerInfo(pid) - val noiseKey = info?.noisePublicKey ?: return@forEach - val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } + // 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 } + // 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)) - } catch (_: Exception) { } - } + unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey)) + } catch (_: Exception) { } } } @@ -294,15 +299,27 @@ class MeshDelegateHandler( if (shouldSendReadReceipt) { android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") val nickname = state.getNicknameValue() ?: "unknown" - // Send directly for this message to avoid relying on unread queues - getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) - // Ensure unread badge is cleared for this peer immediately - try { - val current = state.getUnreadPrivateMessagesValue().toMutableSet() - if (current.remove(senderPeerID)) { - state.setUnreadPrivateMessages(current) + val mesh = getMeshService() + val sent = try { + val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID) + if (hasMesh) { + mesh.sendReadReceipt(message.id, senderPeerID, nickname) + true + } else { + false } - } catch (_: Exception) { } + } catch (_: Exception) { + false + } + if (sent) { + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } } else { android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") } 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 e5688a4e..f2bccdb6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -61,6 +61,8 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } // Bottom sheet state val sheetState = rememberModalBottomSheetState( @@ -163,11 +165,12 @@ fun MeshPeerListSheet( nickname = nickname, colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, + wifiAwarePeerIDs = wifiAwarePeerIDs, viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.showPrivateChatSheet(peerID) - onDismiss() - } + onPrivateChatStart = { peerID -> + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } ) } } @@ -279,6 +282,7 @@ fun PeopleSection( nickname: String, colorScheme: ColorScheme, selectedPrivatePeer: String?, + wifiAwarePeerIDs: Set = emptySet(), viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -333,7 +337,7 @@ fun PeopleSection( // Build mapping of connected peerID -> noise key hex to unify with offline favorites val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } } catch (_: Exception) { null } }.filterValues { it != null }.mapValues { it.value!! } @@ -411,11 +415,12 @@ fun PeopleSection( val showHash = (baseNameCounts[bName] ?: 0) > 1 val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() - val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } + val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, displayName = displayName, isDirect = isDirectLive, + isWifiAware = peerID in wifiAwarePeerIDs, isSelected = peerID == selectedPrivatePeer, isFavorite = isFavorite, isVerified = isVerified, @@ -544,6 +549,7 @@ private fun PeerItem( peerID: String, displayName: String, isDirect: Boolean, + isWifiAware: Boolean = false, isSelected: Boolean, isFavorite: Boolean, isVerified: Boolean, @@ -619,8 +625,16 @@ private fun PeerItem( ) } else { Icon( - imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route, - contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + imageVector = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.Bluetooth + else -> Icons.Filled.Route + }, + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, modifier = Modifier.size(16.dp), tint = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -652,6 +666,16 @@ private fun PeerItem( color = baseColor.copy(alpha = 0.6f) ) } + + if (isWifiAware && hasUnreadDM) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = "Direct Wi-Fi Aware", + modifier = Modifier.size(13.dp), + tint = colorScheme.onSurface.copy(alpha = 0.8f) + ) + } } } @@ -763,6 +787,8 @@ fun PrivateChatSheet( val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() + val isWifiAware = peerID in wifiAwareConnected.keys // Start private chat when screen opens LaunchedEffect(peerID) { @@ -831,7 +857,7 @@ fun PrivateChatSheet( MessagesList( messages = messages, currentUserNickname = nickname, - meshService = viewModel.meshService, + meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, @@ -915,10 +941,26 @@ fun PrivateChatSheet( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { when { + isNostrPeer -> { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_nostr_reachable), + modifier = Modifier.size(14.dp), + tint = Color(0xFF9C27B0) + ) + } + isWifiAware -> { + Icon( + imageVector = Icons.Filled.Wifi, + contentDescription = "Direct Wi-Fi Aware", + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } isDirect -> { Icon( - imageVector = Icons.Outlined.SettingsInputAntenna, - contentDescription = stringResource(R.string.cd_connected_peers), + imageVector = Icons.Outlined.Bluetooth, + contentDescription = "Direct Bluetooth", modifier = Modifier.size(14.dp), tint = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -926,19 +968,11 @@ fun PrivateChatSheet( isConnected -> { Icon( imageVector = Icons.Filled.Route, - contentDescription = stringResource(R.string.cd_ready_for_handshake), + contentDescription = "Routed", modifier = Modifier.size(14.dp), tint = colorScheme.onSurface.copy(alpha = 0.6f) ) } - isNostrPeer -> { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), - tint = Color(0xFF9C27B0) - ) - } } Text( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 986c8c63..88b24015 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -31,7 +31,7 @@ import android.content.Intent import android.net.Uri import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import java.text.SimpleDateFormat import java.util.* import com.bitchat.android.ui.media.VoiceNotePlayer @@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource fun MessagesList( messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, modifier: Modifier = Modifier, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, @@ -137,7 +137,7 @@ fun MessagesList( fun MessageItem( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, messages: List = emptyList(), onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -201,7 +201,7 @@ fun MessageItem( message: BitchatMessage, messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, 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 af2882bb..1ddf8cbf 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -3,9 +3,9 @@ package com.bitchat.android.ui 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.mesh.BluetoothMeshService import java.util.* import android.util.Log @@ -42,7 +42,7 @@ class PrivateChatManager( // MARK: - Private Chat Lifecycle - fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean { + fun startPrivateChat(peerID: String, meshService: MeshService): Boolean { if (isPeerBlocked(peerID)) { val peerNickname = getPeerNickname(peerID, meshService) val systemMessage = BitchatMessage( @@ -188,7 +188,7 @@ class PrivateChatManager( // MARK: - Block/Unblock Operations - fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun blockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null) { dataManager.addBlockedUser(fingerprint) @@ -212,7 +212,7 @@ class PrivateChatManager( return false } - fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeer(peerID: String, meshService: MeshService): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) { dataManager.removeBlockedUser(fingerprint) @@ -230,7 +230,7 @@ class PrivateChatManager( return false } - fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -247,7 +247,7 @@ class PrivateChatManager( } } - fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean { + fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean { val peerID = getPeerIDForNickname(targetName, meshService) if (peerID != null) { @@ -333,7 +333,7 @@ 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: BluetoothMeshService) { + fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) { // 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() @@ -343,13 +343,16 @@ class PrivateChatManager( } val myNickname = state.getNicknameValue() ?: "unknown" + val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false } var sentCount = 0 messages.forEach { msg -> // Only for incoming messages from this peer if (msg.senderPeerID == peerID) { try { - meshService.sendReadReceipt(msg.id, peerID, myNickname) - sentCount += 1 + if (hasMesh) { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } } catch (e: Exception) { Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") } @@ -380,7 +383,7 @@ class PrivateChatManager( * Establish Noise session if needed before starting private chat * Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement */ - private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) { + private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) { if (noiseSessionDelegate.hasEstablishedSession(peerID)) { Log.d(TAG, "Noise session already established with $peerID") return @@ -413,11 +416,11 @@ class PrivateChatManager( // MARK: - Utility Functions - private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { + private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key } - private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String { + private fun getPeerNickname(peerID: String, meshService: MeshService): String { return meshService.getPeerNicknames()[peerID] ?: peerID } diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 9a4a1592..2633f888 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -134,7 +134,7 @@ fun SecurityVerificationSheet( displayName = displayName, accent = accent, canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex), - onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) }, + onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) }, onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 2b0d503f..28c051bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -4,7 +4,7 @@ import android.content.Context import com.bitchat.android.R import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.identity.SecureIdentityStateManager -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry @@ -26,14 +26,14 @@ import java.util.concurrent.ConcurrentHashMap class VerificationHandler( private val context: Context, private val scope: CoroutineScope, - private val getMeshService: () -> BluetoothMeshService, + private val getMeshService: () -> MeshService, private val identityManager: SecureIdentityStateManager, private val state: ChatState, private val notificationManager: NotificationManager, private val messageManager: MessageManager ) { // Helper to get current mesh service (may change after panic clear) - private val meshService: BluetoothMeshService + private val meshService: MeshService get() = getMeshService() private val _verifiedFingerprints = MutableStateFlow>(emptySet()) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 04d93e96..97392eae 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -193,7 +193,7 @@ fun VerificationSheet( val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() if (peerID != null) { - val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!) + val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!) if (fingerprint != null && fingerprints.contains(fingerprint)) { Spacer(modifier = Modifier.height(16.dp)) Button( diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index 04ad48a2..f03df3da 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,7 +20,10 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" - // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled + // Transport master toggles + private const val KEY_BLE_ENABLED = "ble_enabled" + private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled" + private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose" private lateinit var prefs: SharedPreferences @@ -102,5 +105,25 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } - // No longer storing persistent notification in debug prefs. + // Transport toggles + fun getBleEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default + + fun setBleEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() + } + + fun getWifiAwareEnabled(default: Boolean = true): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default + + fun setWifiAwareEnabled(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply() + } + + fun getWifiAwareVerbose(default: Boolean = false): Boolean = + if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default + + fun setWifiAwareVerbose(value: Boolean) { + if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply() + } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 04341fa5..82bc7fc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -40,6 +40,17 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Master transport toggles + private val _bleEnabled = MutableStateFlow(true) + val bleEnabled: StateFlow = _bleEnabled.asStateFlow() + + private val _wifiAwareEnabled = MutableStateFlow(true) + val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() + + // Master transport toggles + private val _wifiAwareVerbose = MutableStateFlow(false) + val wifiAwareVerbose: StateFlow = _wifiAwareVerbose.asStateFlow() + // Visibility of the debug sheet; gates heavy work private val _debugSheetVisible = MutableStateFlow(false) val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() @@ -63,6 +74,10 @@ class DebugSettingsManager private constructor() { _maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8) _maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8) _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) + // Transport toggles + _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true) + _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(true) + _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. } @@ -266,6 +281,30 @@ class DebugSettingsManager private constructor() { )) } + fun setBleEnabled(enabled: Boolean) { + DebugPreferenceManager.setBleEnabled(enabled) + _bleEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + try { + com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareEnabled(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareEnabled(enabled) + _wifiAwareEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled")) + try { + com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled) + } catch (_: Exception) { } + } + + fun setWifiAwareVerbose(enabled: Boolean) { + DebugPreferenceManager.setWifiAwareVerbose(enabled) + _wifiAwareVerbose.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled")) + } + fun setMaxConnectionsOverall(value: Int) { val clamped = value.coerceIn(1, 32) DebugPreferenceManager.setMaxConnectionsOverall(clamped) @@ -323,6 +362,16 @@ class DebugSettingsManager private constructor() { fun updateConnectedDevices(devices: List) { _connectedDevices.value = devices } + + // Wi‑Fi Aware debug collections + private val _wifiAwareDiscovered = MutableStateFlow>(emptyMap()) // peerID->nickname + val wifiAwareDiscovered: StateFlow> = _wifiAwareDiscovered.asStateFlow() + + private val _wifiAwareConnected = MutableStateFlow>(emptyMap()) // peerID->ip + val wifiAwareConnected: StateFlow> = _wifiAwareConnected.asStateFlow() + + fun updateWifiAwareDiscovered(map: Map) { _wifiAwareDiscovered.value = map } + fun updateWifiAwareConnected(map: Map) { _wifiAwareConnected.value = map } fun updateRelayStats(stats: PacketRelayStats) { _relayStats.value = stats diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 672d352a..65b19182 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.animation.core.animateFloatAsState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.PowerSettingsNew @@ -40,10 +42,15 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle @Composable -fun MeshTopologySection() { +fun MeshTopologySection( + localPeerID: String? = null, + blePeerIDs: Set = emptySet(), +) { val colorScheme = MaterialTheme.colorScheme val graphService = remember { MeshGraphService.getInstance() } val snapshot by graphService.graphState.collectAsState() + val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -60,6 +67,9 @@ fun MeshTopologySection() { ForceDirectedMeshGraph( nodes = nodes, edges = edges, + wifiAwarePeerIDs = wifiAwarePeerIDs, + blePeerIDs = blePeerIDs, + localPeerID = localPeerID, modifier = Modifier .fillMaxWidth() .height(300.dp) @@ -102,7 +112,7 @@ fun DebugSettingsSheet( val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState() val gattClientEnabled by manager.gattClientEnabled.collectAsState() - val packetRelayEnabled by manager.packetRelayEnabled.collectAsState() + val packetRelayed by manager.packetRelayEnabled.collectAsState() val maxOverall by manager.maxConnectionsOverall.collectAsState() val maxServer by manager.maxServerConnections.collectAsState() val maxClient by manager.maxClientConnections.collectAsState() @@ -114,6 +124,15 @@ fun DebugSettingsSheet( val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() val context = LocalContext.current + + val bleEnabled by manager.bleEnabled.collectAsState() + val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() + val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() + val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() + val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() + val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState() + val wifiAwareAvailable by com.bitchat.android.wifiaware.WifiAwareController.available.collectAsState() + val wifiAwareSupportStatus by com.bitchat.android.wifiaware.WifiAwareController.supportStatus.collectAsState() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled val listState = rememberLazyListState() val isScrolled by remember { @@ -148,6 +167,15 @@ fun DebugSettingsSheet( ) } manager.updateConnectedDevices(devices) + // Also surface Wi‑Fi Aware status + try { + val ctrl = com.bitchat.android.wifiaware.WifiAwareController + val known = ctrl.knownPeers.value + val discovered = ctrl.discoveredPeers.value + val discoveredMap = discovered.associateWith { pid -> known[pid] ?: "" } + manager.updateWifiAwareDiscovered(discoveredMap) + manager.updateWifiAwareConnected(ctrl.connectedPeers.value) + } catch (_: Exception) { } kotlinx.coroutines.delay(1000) } } @@ -204,7 +232,13 @@ fun DebugSettingsSheet( // Mesh topology visualization (moved below verbose logging) item { - MeshTopologySection() + val blePeerIDs = remember(connectedDevices) { + connectedDevices.mapNotNull { it.peerID }.toSet() + } + MeshTopologySection( + localPeerID = meshService.myPeerID, + blePeerIDs = blePeerIDs, + ) } // GATT controls @@ -276,6 +310,53 @@ fun DebugSettingsSheet( } } + // Transport toggles (BLE + Wi‑Fi Aware) + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) + Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) + Spacer(Modifier.width(8.dp)) + Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Switch(checked = bleEnabled, onCheckedChange = { + manager.setBleEnabled(it) + }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) + Spacer(Modifier.width(8.dp)) + Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + val wifiSwitchEnabled = wifiAwareSupported + Text( + when { + !wifiAwareSupported -> "unsupported" + wifiAwareAvailable -> "available" + else -> "unavailable" + }, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(8.dp)) + Switch( + checked = wifiAwareEnabled && wifiAwareSupported, + enabled = wifiSwitchEnabled, + onCheckedChange = { manager.setWifiAwareEnabled(it) } + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(24.dp)) + Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) + } + } + } + } + // Packet relay controls and stats item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -286,7 +367,7 @@ fun DebugSettingsSheet( Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) + Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } // Removed aggregate labels; we will show per-direction compact labels below titles // Toggle: overall vs per-connection vs per-peer @@ -535,6 +616,65 @@ fun DebugSettingsSheet( } } + // Wi‑Fi Aware controls and status + item { + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) + Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + val wifiStatusText = when { + !wifiAwareSupported -> "unsupported" + running -> "running" + !wifiAwareAvailable -> "unavailable" + else -> "stopped" + } + Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + if (!wifiAwareSupported) { + Text( + wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip( + onClick = { manager.setWifiAwareEnabled(true) }, + enabled = wifiAwareSupported, + label = { Text("Start") } + ) + AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") }) + AssistChip( + onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, + enabled = running, + label = { Text("Announce") } + ) + } + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + if (wifiAwareDiscovered.isEmpty()) { + Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + } + } + Divider() + Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + if (wifiAwareConnected.isEmpty()) { + Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + } else { + wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> + Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + } + } + } + } + } + // Connected devices item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 2df56b5d..44c7ef55 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -4,6 +4,12 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth +import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -14,6 +20,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.services.meshgraph.MeshGraphService @@ -31,6 +38,44 @@ private const val DAMPING = 0.85f private const val MAX_VELOCITY = 30f private const val PULSE_DECAY = 0.05f private const val ROUTE_DECAY = 0.02f +private val EDGE_ICON_OFFSET = 14.dp + +private enum class EdgeTransport { WIFI, BLE } + +private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport) + +private fun shouldMarkDirectEdge( + edge: MeshGraphService.GraphEdge, + transportPeerIDs: Set, + localID: String? +): Boolean { + if (transportPeerIDs.isEmpty()) return false + return if (localID != null) { + (edge.a == localID && edge.b in transportPeerIDs) || + (edge.b == localID && edge.a in transportPeerIDs) + } else { + edge.a in transportPeerIDs || edge.b in transportPeerIDs + } +} + +private fun edgeMarkerPosition( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + offsetPx: Float, + sideSign: Float +): Pair { + val midX = (x1 + x2) / 2f + val midY = (y1 + y2) / 2f + val dx = x2 - x1 + val dy = y2 - y1 + val len = sqrt(dx * dx + dy * dy) + if (len < 0.1f) return midX to midY + val px = -dy / len * offsetPx * sideSign + val py = dx / len * offsetPx * sideSign + return (midX + px) to (midY + py) +} private class GraphNodeState( val id: String, @@ -212,6 +257,9 @@ private class Simulation { fun ForceDirectedMeshGraph( nodes: List, edges: List, + wifiAwarePeerIDs: Set = emptySet(), + blePeerIDs: Set = emptySet(), + localPeerID: String? = null, modifier: Modifier = Modifier ) { val density = LocalDensity.current @@ -405,5 +453,54 @@ fun ForceDirectedMeshGraph( ) } } + + val iconSize = 16.dp + val iconSizePx = with(density) { iconSize.toPx() } + val halfIconSizePx = iconSizePx / 2f + val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() } + val localID = localPeerID + + val edgeMarkers = tick.let { + simulation.edges.flatMap { edge -> + val n1 = simulation.nodes[edge.a] + val n2 = simulation.nodes[edge.b] + if (n1 == null || n2 == null) return@flatMap emptyList() + + val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID) + val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID) + if (!isWifi && !isBle) return@flatMap emptyList() + + val markers = mutableListOf() + if (isWifi) { + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f) + markers.add(EdgeMarker(x, y, EdgeTransport.WIFI)) + } + if (isBle) { + val side = if (isWifi) -1f else 1f + val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side) + markers.add(EdgeMarker(x, y, EdgeTransport.BLE)) + } + markers + } + } + + edgeMarkers.forEach { marker -> + Icon( + imageVector = when (marker.transport) { + EdgeTransport.WIFI -> Icons.Filled.Wifi + EdgeTransport.BLE -> Icons.Outlined.Bluetooth + }, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.82f), + modifier = Modifier + .offset { + IntOffset( + x = (marker.x - halfIconSizePx).roundToInt(), + y = (marker.y - halfIconSizePx).roundToInt() + ) + } + .size(iconSize) + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 264d9d6c..6af2cc51 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import com.bitchat.android.R -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme import java.text.SimpleDateFormat @@ -30,7 +30,7 @@ import java.text.SimpleDateFormat fun AudioMessageItem( message: BitchatMessage, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 2310cd5b..43ae84f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.text.font.FontFamily -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme @@ -39,7 +39,7 @@ fun ImageMessageItem( message: BitchatMessage, messages: List, currentUserNickname: String, - meshService: BluetoothMeshService, + meshService: MeshService, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt new file mode 100644 index 00000000..c3039327 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.wifiaware + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.Socket +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import android.util.Log + +/** + * A synchronized wrapper around a raw Socket that implements a framed protocol: + * [4 bytes length][N bytes payload] + */ +class SyncedSocket( + val rawSocket: Socket, + readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS +) { + private val TAG = "SyncedSocket" + private val writeLock = ReentrantLock() + private val readLock = ReentrantLock() + + private val inputStream: DataInputStream + private val outputStream: DataOutputStream + + companion object { + // Both peers exchange keep-alive frames every ~2s while connected, so a read that + // stalls well beyond that means the link is dead (half-open). Time out so the read + // loop can detect it and trigger disconnection instead of blocking forever. + const val DEFAULT_READ_TIMEOUT_MS = 15_000 + } + + init { + // A read timeout converts dead/half-open connections into a SocketTimeoutException + // (an IOException) so read() returns null and the peer is cleaned up. + try { rawSocket.soTimeout = readTimeoutMs } catch (_: Exception) {} + // We wrap streams to create DataInput/Output helpers + inputStream = DataInputStream(rawSocket.getInputStream()) + outputStream = DataOutputStream(rawSocket.getOutputStream()) + } + + /** + * Writes a framed message to the socket. + * Thread-safe. + */ + fun write(data: ByteArray) { + writeLock.withLock { + Log.v(TAG, "Writing frame of size: ${data.size}") + outputStream.writeInt(data.size) + if (data.isNotEmpty()) { + outputStream.write(data) + } + outputStream.flush() + } + } + + /** + * Reads a framed message from the socket. + * Blocks until a full frame is available. + * Returns null if socket is closed or EOF. + * Returns empty byte array for keep-alive (0 length frame). + */ + fun read(): ByteArray? { + readLock.withLock { + try { + // Read length prefix + val length = try { + inputStream.readInt() + } catch (e: java.io.EOFException) { + return null + } + Log.v(TAG, "Reading frame of size: $length") + + if (length < 0) throw IOException("Negative frame length: $length") + if (length > 64 * 1024) throw IOException("Frame length exceeds 64KB limit: $length") + + if (length == 0) { + return ByteArray(0) + } + + val buf = ByteArray(length) + inputStream.readFully(buf) + return buf + } catch (e: IOException) { + Log.e(TAG, "Socket read failed: ${e.message}") + // Socket closed or error + return null + } + } + } + + fun close() { + try { rawSocket.close() } catch (_: Exception) {} + } + + fun isClosed() = rawSocket.isClosed + fun isConnected() = rawSocket.isConnected + val inetAddress get() = rawSocket.inetAddress +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt new file mode 100644 index 00000000..115dbb05 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -0,0 +1,239 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import android.util.Log +import com.bitchat.android.mesh.MeshConnectionTracker +import kotlinx.coroutines.CoroutineScope +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks Wi-Fi Aware connections and manages retry logic using the shared state machine. + */ +class WifiAwareConnectionTracker( + scope: CoroutineScope, + private val cm: ConnectivityManager +) : MeshConnectionTracker(scope, TAG) { + + companion object { + private const val TAG = "WifiAwareConnectionTracker" + } + + // Active resources per peer + val peerSockets = ConcurrentHashMap() + private val socketAliases = ConcurrentHashMap() + val serverSockets = ConcurrentHashMap() + val networkCallbacks = ConcurrentHashMap() + + override fun isConnected(id: String): Boolean { + // We consider it connected if we have a client socket to them + return getSocketForPeer(id) != null + } + + override fun disconnect(id: String) { + Log.d(TAG, "Disconnecting peer $id") + val canonicalId = resolveCanonicalPeerId(id) + + // 1. Close client socket + peerSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } + } + socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } + + // 2. Close server socket + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } + } + + // Ensure any pending/active network request is explicitly released + releaseNetworkRequest(canonicalId) + removePendingConnection(id) + removePendingConnection(canonicalId) + } + + fun releaseNetworkRequest(id: String) { + val canonicalId = resolveCanonicalPeerId(id) + if (!networkCallbacks.containsKey(canonicalId)) return + + // 3. Unregister network callback properly from ConnectivityManager + networkCallbacks.remove(canonicalId)?.let { + try { + Log.d(TAG, "Unregistering network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $canonicalId: ${e.message}") } + } + } + + override fun getConnectionCount(): Int = peerSockets.size + + /** + * Successfully established a client connection + */ + fun onClientConnected(peerId: String, socket: SyncedSocket) { + // Close previous socket if one exists to prevent zombie readers + peerSockets[peerId]?.let { + try { it.close() } catch (_: Exception) {} + } + peerSockets[peerId] = socket + removePendingConnection(peerId) // Clear retry state on success + } + + fun getSocketForPeer(peerId: String): SyncedSocket? { + val canonicalId = resolveCanonicalPeerId(peerId) + return peerSockets[canonicalId] + } + + fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) + + fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { + if (previousPeerId == resolvedPeerId) { + peerSockets[resolvedPeerId] = socket + return resolvedPeerId + } + + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + val existing = peerSockets[previousCanonical] + if (existing === socket) { + peerSockets.remove(previousCanonical) + } + + peerSockets[resolvedPeerId]?.let { current -> + if (current !== socket) { + try { current.close() } catch (_: Exception) { } + } + } + peerSockets[resolvedPeerId] = socket + serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it } + networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it } + socketAliases[previousPeerId] = resolvedPeerId + if (previousCanonical != previousPeerId) { + socketAliases[previousCanonical] = resolvedPeerId + } + removePendingConnection(previousPeerId) + removePendingConnection(resolvedPeerId) + + Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}") + return resolvedPeerId + } + + private fun resolveCanonicalPeerId(peerId: String): String { + var current = peerId + val visited = mutableSetOf() + while (visited.add(current)) { + val next = socketAliases[current] ?: return current + current = next + } + return current + } + + fun addServerSocket(peerId: String, socket: ServerSocket) { + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.put(canonicalId, socket)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing replaced server socket for $peerId: ${e.message}") } + } + } + + fun hasOpenServerSocket(peerId: String): Boolean { + val canonicalId = resolveCanonicalPeerId(peerId) + val socket = serverSockets[canonicalId] ?: return false + if (!socket.isClosed) return true + serverSockets.remove(canonicalId) + return false + } + + fun closeServerSocket(peerId: String) { + val canonicalId = resolveCanonicalPeerId(peerId) + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $peerId: ${e.message}") } + } + } + + fun hasPendingDataPathRequest(exceptPeerId: String? = null): Boolean { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return pendingDataPathPeerIds(exceptCanonical).isNotEmpty() + } + + fun pendingDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + val pendingIds = linkedSetOf() + + pendingConnections.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + networkCallbacks.keys.forEach { peerId -> + val canonicalId = resolveCanonicalPeerId(peerId) + if (canonicalId != exceptCanonical && !isConnected(canonicalId)) { + pendingIds.add(canonicalId) + } + } + + return pendingIds + } + + fun pendingServerDataPathPeerIds(exceptPeerId: String? = null): Set { + val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) } + return serverSockets.keys.map { resolveCanonicalPeerId(it) } + .filter { peerId -> + peerId != exceptCanonical && + !isConnected(peerId) && + networkCallbacks.containsKey(peerId) && + serverSockets[peerId]?.isClosed == false + } + .toSet() + } + + fun cancelPendingServerDataPaths(exceptPeerId: String? = null): Set { + val cancelled = pendingServerDataPathPeerIds(exceptPeerId) + cancelled.forEach { disconnect(it) } + return cancelled + } + + fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) { + val canonicalId = resolveCanonicalPeerId(peerId) + networkCallbacks.put(canonicalId, callback)?.let { + try { + Log.d(TAG, "Replacing network callback for $canonicalId") + cm.unregisterNetworkCallback(it) + } catch (e: Exception) { + Log.w(TAG, "Error unregistering replaced callback for $canonicalId: ${e.message}") + } + } + } + + /** + * Clean up all resources + */ + override fun stop() { + super.stop() + val allIds = peerSockets.keys + serverSockets.keys + networkCallbacks.keys + allIds.toSet().forEach { disconnect(it) } + } + + fun getDebugInfo(): String { + return buildString { + appendLine("Aware Connections: ${getConnectionCount()}") + peerSockets.keys.forEach { pid -> + appendLine(" - $pid (Socket)") + } + if (socketAliases.isNotEmpty()) { + appendLine("Socket aliases:") + socketAliases.forEach { (alias, canonical) -> + appendLine(" - $alias -> $canonical") + } + } + appendLine("Server Sockets: ${serverSockets.size}") + serverSockets.keys.forEach { pid -> + appendLine(" - $pid (Listening)") + } + appendLine("Pending Attempts: ${pendingConnections.size}") + pendingConnections.forEach { (pid, attempt) -> + appendLine(" - $pid: ${attempt.attempts} attempts") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 00000000..f2ec4cfa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -0,0 +1,305 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.os.Build +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService. + * It starts/stops the service based on debug preferences and exposes simple flows for UI. + */ +object WifiAwareController { + private const val TAG = "WifiAwareController" + private const val MAX_RESTART_ATTEMPTS = 15 + private const val RESTART_RETRY_DELAY_MS = 2_000L + + private var service: WifiAwareMeshService? = null + private var appContext: Context? = null + private val lifecycleLock = Any() + private var starting = false + private val restartInFlight = AtomicBoolean(false) + private var awareReceiverRegistered = false + private var lastBlockedReason: String? = null + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.asStateFlow() + + private val _supported = MutableStateFlow(false) + val supported: StateFlow = _supported.asStateFlow() + + private val _available = MutableStateFlow(false) + val available: StateFlow = _available.asStateFlow() + + private val _supportStatus = MutableStateFlow(null) + val supportStatus: StateFlow = _supportStatus.asStateFlow() + + private val _running = MutableStateFlow(false) + val running: StateFlow = _running.asStateFlow() + + // Simple debug surfacing + private val _connectedPeers = MutableStateFlow>(emptyMap()) // peerID -> ip + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() + + private val _knownPeers = MutableStateFlow>(emptyMap()) // peerID -> nickname + val knownPeers: StateFlow> = _knownPeers.asStateFlow() + + private val _discoveredPeers = MutableStateFlow>(emptySet()) + val discoveredPeers: StateFlow> = _discoveredPeers.asStateFlow() + + fun initialize(context: Context, enabledByDefault: Boolean) { + appContext = context.applicationContext + val status = refreshSupportStatus(appContext!!) + if (status.supported) { + registerAwareStateReceiver(appContext!!) + } else { + Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") + } + setEnabled(enabledByDefault) + // Start background poller for debug surfacing + scope.launch { + while (isActive) { + try { + val s = service + if (s != null) { + _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip + _knownPeers.value = s.getPeerNicknames() + _discoveredPeers.value = s.getDiscoveredPeerIds() + } else { + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + } + } catch (_: Exception) { } + delay(1000) + } + } + } + + fun setEnabled(value: Boolean) { + _enabled.value = value + if (value) startIfPossible() else stop() + } + + fun startIfPossible() { + val reusableService = synchronized(lifecycleLock) { + if (!_enabled.value) return + val existing = service + if (existing?.isRunning() == true) { + _running.value = true + return + } + if (starting) return + starting = true + existing + } + + val ctx = appContext ?: run { + synchronized(lifecycleLock) { starting = false } + return + } + + val status = refreshSupportStatus(ctx) + if (!status.supported) { + val reason = status.reason ?: "not supported" + Log.w(TAG, "Wi‑Fi Aware unsupported; not starting ($reason)") + addBlockedDebugMessage("unsupported:$reason", "Wi-Fi Aware not supported on this device ($reason)") + synchronized(lifecycleLock) { starting = false } + return + } + + val awareManager = WifiAwareSupport.getManager(ctx) + if (awareManager == null || !status.available) { + Log.w(TAG, "Wi-Fi Aware is not currently available; not starting") + addBlockedDebugMessage("unavailable", "Wi-Fi Aware is not available right now") + synchronized(lifecycleLock) { starting = false } + return + } + + // Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled + val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager + val locationEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lm?.isLocationEnabled == true + } else { + @Suppress("DEPRECATION") + lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true || + lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true + } + + if (!locationEnabled) { + Log.w(TAG, "Location services are disabled; Wi-Fi Aware cannot start.") + addBlockedDebugMessage("location-disabled", "Enable Location Services to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } + return + } + + // Android 13+: require NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED + if (!granted) { + Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting Wi‑Fi Aware") + addBlockedDebugMessage("missing-nearby-wifi", "Grant Nearby Wi-Fi Devices to start Wi-Fi Aware") + synchronized(lifecycleLock) { starting = false } + return + } + } + if (!_enabled.value) { + synchronized(lifecycleLock) { starting = false } + return + } + try { + val startedService = reusableService ?: run { + Log.i(TAG, "Instantiating WifiAwareMeshService...") + WifiAwareMeshService(ctx) + } + startedService.startServices() + if (startedService.isRunning()) { + synchronized(lifecycleLock) { + service = startedService + _running.value = true + } + try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { } + clearBlockedDebugMessage() + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {} + } else { + if (reusableService == null) { + try { startedService.stopServices() } catch (_: Exception) { } + } + synchronized(lifecycleLock) { + if (service === startedService) service = null + _running.value = false + } + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware did not start")) } catch (_: Exception) {} + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start WifiAwareMeshService", e) + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware failed to start: ${e.message}")) } catch (_: Exception) {} + } finally { + synchronized(lifecycleLock) { starting = false } + } + } + + fun stop() { + val stopped = synchronized(lifecycleLock) { + val current = service + service = null + starting = false + _running.value = false + current + } + try { stopped?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} + } + + internal fun onServiceStopped(stoppedService: WifiAwareMeshService) { + synchronized(lifecycleLock) { + if (service !== stoppedService) return + service = null + _running.value = false + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + _connectedPeers.value = emptyMap() + _knownPeers.value = emptyMap() + _discoveredPeers.value = emptySet() + } + } + + /** + * Schedules a restart of the Wi-Fi Aware transport. Concurrent requests are coalesced into + * a single in-flight loop that retries with backoff. This is important because a single fixed + * delay can land while the service is still tearing down (recoveryInProgress), in which case + * startServices() defers and we must try again rather than give up. + */ + internal fun restartIfStillEnabled(delayMs: Long = 0L) { + if (!restartInFlight.compareAndSet(false, true)) { + Log.d(TAG, "Restart already in flight; coalescing request") + return + } + scope.launch { + try { + if (delayMs > 0L) delay(delayMs) + var attempt = 0 + while (_enabled.value && !_running.value && attempt < MAX_RESTART_ATTEMPTS) { + val ctx = appContext + if (ctx != null && !refreshSupportStatus(ctx).supported) break + startIfPossible() + if (_running.value) break + attempt++ + delay(RESTART_RETRY_DELAY_MS) + } + } finally { + restartInFlight.set(false) + } + } + } + + /** + * Listens for system Wi-Fi Aware availability changes. Aware can flip off/on at runtime + * (Wi-Fi toggling, hotspot/SoftAP, location changes); without this we would only recover on + * an unrelated trigger. + */ + private fun registerAwareStateReceiver(ctx: Context) { + if (awareReceiverRegistered) return + if (!refreshSupportStatus(ctx).supported) return + try { + val filter = android.content.IntentFilter( + android.net.wifi.aware.WifiAwareManager.ACTION_WIFI_AWARE_STATE_CHANGED + ) + ctx.registerReceiver(object : android.content.BroadcastReceiver() { + override fun onReceive(c: Context?, intent: android.content.Intent?) { + val status = refreshSupportStatus(ctx) + Log.i(TAG, "Wi-Fi Aware availability changed: supported=${status.supported} available=${status.available} enabled=${_enabled.value} running=${_running.value}") + if (status.available) { + if (_enabled.value) restartIfStillEnabled(500) + } else if (_running.value) { + // Aware went away; tear down cleanly so we can re-attach when it returns. + // Note: this does not change the enabled preference. + stop() + } + } + }, filter) + awareReceiverRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Failed to register Wi-Fi Aware state receiver: ${e.message}") + } + } + + private fun refreshSupportStatus(ctx: Context): WifiAwareSupport.Status { + val status = WifiAwareSupport.evaluate(ctx) + _supported.value = status.supported + _available.value = status.available + _supportStatus.value = status + return status + } + + private fun addBlockedDebugMessage(key: String, message: String) { + if (lastBlockedReason == key) return + lastBlockedReason = key + try { + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + .addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage(message)) + } catch (_: Exception) { } + } + + private fun clearBlockedDebugMessage() { + lastBlockedReason = null + } + + fun getService(): WifiAwareMeshService? = service +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt new file mode 100644 index 00000000..066a8b82 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshDelegate.kt @@ -0,0 +1,3 @@ +package com.bitchat.android.wifiaware + +typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate 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 new file mode 100644 index 00000000..36eaea1c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1591 @@ +package com.bitchat.android.wifiaware + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.* +import android.net.wifi.aware.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.system.OsConstants +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.mesh.FragmentingPacketSender +import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.MeshTransport +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +import java.io.InterruptedIOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.IOException +import java.net.Inet6Address +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * WifiAware mesh service - LATEST + * + * This is now a coordinator that orchestrates the following components: + * - PeerManager: Peer lifecycle management + * - FragmentManager: Message fragmentation and reassembly + * - SecurityManager: Security, duplicate detection, encryption + * - StoreForwardManager: Offline message caching + * - MessageHandler: Message type processing and relay logic + * - PacketProcessor: Incoming packet routing + */ +class WifiAwareMeshService(private val context: Context) : MeshService, TransportBridgeService.TransportLayer { + + companion object { + private const val TAG = "WifiAwareMeshService" + private const val MAX_TTL: UByte = 7u + private const val SERVICE_NAME = "bitchat" + private const val PSK = "bitchat_secret" + // Network request / socket timeouts + private const val NETWORK_REQUEST_TIMEOUT_MS = 30_000 + private const val ACCEPT_TIMEOUT_MS = 30_000 + private const val CLIENT_CONNECT_TIMEOUT_MS = 7_000 + private const val CLIENT_SOCKET_READY_DELAY_MS = 750L + private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L + private const val CLIENT_SOCKET_ATTEMPTS = 3 + private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 + // 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 + private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 + private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" + } + + // Core crypto/services + private val encryptionService = EncryptionService(context) + + // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) + override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val wifiTransport = WifiAwareTransport() + private lateinit var meshCore: MeshCore + private lateinit var fragmentingSender: FragmentingPacketSender + + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) + + // Wi-Fi Aware transport + private val awareManager = context.getSystemService(WifiAwareManager::class.java) + @Volatile private var wifiAwareSession: WifiAwareSession? = null + @Volatile private var publishSession: PublishDiscoverySession? = null + @Volatile private var subscribeSession: SubscribeDiscoverySession? = null + private val listenerExec = Executors.newCachedThreadPool() + @Volatile private var isActive = false + @Volatile private var recoveryInProgress = false + private val sessionGeneration = AtomicInteger(0) + + // Delegate + override var delegate: WifiAwareMeshDelegate? = null + set(value) { + field = value + if (::meshCore.isInitialized) { + meshCore.delegate = value + meshCore.refreshPeerList() + } + } + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Transport state + private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) + 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 + // from the publish session is NOT valid for subscribeSession.sendMessage(). Maintenance re-pings + // (subscriber -> publisher) must use a handle that originated from the subscribe session. + private val subscribeHandles = ConcurrentHashMap() // peerID -> latest subscribe handle + private val publishHandles = ConcurrentHashMap() // peerID -> latest publish handle + private val forcedServerPeers = ConcurrentHashMap.newKeySet() + private val forcedClientPeers = ConcurrentHashMap.newKeySet() + private val clientSocketFailures = ConcurrentHashMap() + private val lastDiscoveryActivityAt = AtomicLong(0L) + private val lastDiscoveryRefreshAt = AtomicLong(0L) + + fun isRunning(): Boolean = isActive + + init { + // Ensure BluetoothMeshService is initialized so we share its GossipSyncManager + // This avoids race conditions and ensures a single gossip source/delegate + com.bitchat.android.service.MeshServiceHolder.getOrCreate(context) + val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager + encryptionService.onSessionEstablished = { peerID -> + Log.d(TAG, "Wi-Fi Aware Noise session established with ${peerID.take(8)}") + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.onSessionEstablished(peerID) + } catch (_: Exception) { } + } + meshCore = MeshCore( + context = context.applicationContext, + scope = serviceScope, + transport = wifiTransport, + encryptionService = encryptionService, + myPeerID = myPeerID, + maxTtl = MAX_TTL, + sharedGossipManager = shared, + gossipConfigProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + }, + hooks = MeshCore.Hooks( + onMessageReceived = { message -> handleMessageReceived(message) }, + onAnnounceProcessed = { routed, _ -> + routed.peerID?.let { pid -> + try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + }, + announcementNicknameProvider = { + try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } + }, + leavePayloadProvider = { + (delegate?.getNickname() ?: myPeerID).toByteArray(Charsets.UTF_8) + } + ) + ) + fragmentingSender = FragmentingPacketSender(serviceScope, meshCore.fragmentManager, TAG) + } + + private fun handleMessageReceived(message: BitchatMessage) { + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { meshCore.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } + } + + /** + * Broadcasts raw bytes to currently connected peer. + */ + private fun broadcastRaw(bytes: ByteArray) { + var sent = 0 + connectionTracker.peerSockets.forEach { (pid, sock) -> + try { + sock.write(bytes) + sent++ + } catch (e: IOException) { + Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}") + } + } + Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})") + } + + // TransportLayer implementation + override fun send(packet: RoutedPacket) { + // Received from bridge (e.g. BLE) -> Send via Wi-Fi + // Direct injection prevents routing loops (bridge handles source check) + meshCore.sendFromBridge(packet) + } + + override fun sendToPeer(peerID: String, packet: BitchatPacket) { + sendPacketToPeer(peerID, packet) + } + + /** + * Broadcasts routed packet to currently connected peers. + */ + private fun broadcastPacket(routed: RoutedPacket) { + Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") + + val packet = routed.packet + if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) { + val firstHop = packet.route!![0].toHexString() + if (sendRoutedPacketToPeer(firstHop, routed)) { + Log.d(TAG, "TX: source-routed packet sent only to first Wi-Fi hop ${firstHop.take(8)}") + return + } + Log.w(TAG, "TX: first Wi-Fi source-route hop ${firstHop.take(8)} unavailable; falling back to broadcast") + } + + val recipientId = packet.recipientID?.toHexString() + if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) { + if (sendRoutedPacketToPeer(recipientId, routed)) { + Log.d(TAG, "TX: addressed packet sent directly to Wi-Fi peer ${recipientId.take(8)}") + return + } + } + + fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single -> + broadcastSinglePacket(single) + } + } + + // Expose a public method so BLE can forward relays to Wi-Fi Aware + fun broadcastRoutedPacket(routed: RoutedPacket) { + broadcastPacket(routed) + } + + /** + * Send packet to connected peer. + */ + private fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return sendRoutedPacketToPeer(peerID, RoutedPacket(packet)) + } + + private fun sendRoutedPacketToPeer(peerID: String, routed: RoutedPacket): Boolean { + if (connectionTracker.getSocketForPeer(peerID) == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + return fragmentingSender.send(routed, "Wi-Fi Aware peer ${peerID.take(8)}") { single -> + sendSinglePacketToPeer(peerID, single.packet) + } + } + + private fun broadcastSinglePacket(routed: RoutedPacket): Boolean { + val data = routed.packet.toBinaryData() ?: return false + broadcastRaw(data) + return true + } + + private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + val data = packet.toBinaryData() ?: return false + val sock = connectionTracker.getSocketForPeer(peerID) + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return false + } + try { + sock.write(data) + Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") + return true + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + return false + } + } + + + + /** + * Starts Wi-Fi Aware services (publish + subscribe). + * + * Requires Wi-Fi state and location permissions. This method attaches to the + * Aware session and initializes both the publisher (server role) and subscriber + * (client role). + */ + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_WIFI_STATE, + Manifest.permission.CHANGE_WIFI_STATE + ]) + override fun startServices() { + if (isActive) return + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + Log.i(TAG, "Wi-Fi Aware transport disabled by debug settings; not starting") + return + } + val supportStatus = com.bitchat.android.wifiaware.WifiAwareSupport.evaluate(context) + if (!supportStatus.supported) { + Log.i(TAG, "Wi-Fi Aware unsupported on this device; not starting (${supportStatus.reason})") + return + } + if (!supportStatus.available) { + Log.i(TAG, "Wi-Fi Aware unavailable right now; not starting (${supportStatus.reason})") + return + } + if (recoveryInProgress) { + Log.i(TAG, "Wi-Fi Aware recovery cleanup still in progress; deferring start") + return + } + val manager = awareManager + if (manager == null || !manager.isAvailable) { + Log.w(TAG, "Wi-Fi Aware manager unavailable; not starting") + return + } + isActive = true + val startTime = System.currentTimeMillis() + lastDiscoveryActivityAt.set(startTime) + lastDiscoveryRefreshAt.set(startTime) + val generation = sessionGeneration.incrementAndGet() + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") + + manager.attach(object : AttachCallback() { + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES + ]) + override fun onAttached(session: WifiAwareSession) { + if (!isCurrentSession(generation)) { + session.close() + return + } + wifiAwareSession = session + Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)") + + // PUBLISH (server role) + session.publish( + PublishConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray()) + .build(), + object : DiscoverySessionCallback() { + override fun onPublishStarted(pub: PublishDiscoverySession) { + if (!isCurrentSession(generation)) { + pub.close() + return + } + publishSession = pub + Log.d(TAG, "PUBLISH: onPublishStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Publish Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + if (peerId.isNotBlank()) { + rememberDiscoveredPeer(peerId) + publishHandles[peerId] = peerHandle + Log.i(TAG, "PUBLISH: Discovered subscriber '$peerId' via Aware") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + offerServerPathIfAppropriate(peerId, peerHandle, "publish discovery") + } + } + Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}") + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId.startsWith(ROLE_REVERSAL_PREFIX)) { + val requesterId = subscriberId.removePrefix(ROLE_REVERSAL_PREFIX) + handleRoleReversalRequest(peerHandle, requesterId) + return + } + if (subscriberId == myPeerID) return + + handleToPeerId[peerHandle] = subscriberId + if (subscriberId.isNotBlank()) { + rememberDiscoveredPeer(subscriberId) + publishHandles[subscriberId] = peerHandle + } + Log.i(TAG, "PUBLISH: Received discovery ping from subscriber '$subscriberId'") + handleSubscriberPing(publishSession!!, peerHandle) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "PUBLISH: onSessionTerminated()") + publishSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "PUBLISH: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + + // SUBSCRIBE (client role) + session.subscribe( + SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(myPeerID.toByteArray(Charsets.UTF_8)) + .build(), + object : DiscoverySessionCallback() { + override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + if (!isCurrentSession(generation)) { + sub.close() + return + } + subscribeSession = sub + Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Subscribe Started")) } catch (_: Exception) {} + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + if (!isCurrentSession(generation)) return + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + // This handle came from the subscribe session, so it is valid for + // subscribeSession.sendMessage() (used by maintenance reconnection). + if (peerId.isNotBlank()) subscribeHandles[peerId] = peerHandle + sendSubscribePing(peerId, peerHandle, "discovery") + if (peerId.isNotBlank()) rememberDiscoveredPeer(peerId) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (!isCurrentSession(generation)) return + if (message.isEmpty()) return + handleServerReady(peerHandle, message) + } + + override fun onSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "SUBSCRIBE: onSessionTerminated()") + subscribeSession = null + val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + Log.i(TAG, "SUBSCRIBE: Scheduling Wi-Fi Aware restart") + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000) + } + } + }, + Handler(Looper.getMainLooper()) + ) + } + override fun onAttachFailed() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Wi-Fi Aware attach failed") + handleUnexpectedStop(generation) + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + + override fun onAwareSessionTerminated() { + if (!isCurrentSession(generation)) return + Log.e(TAG, "Aware Session Terminated unexpectedly") + wifiAwareSession = null + val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value + handleUnexpectedStop(generation) + if (shouldRestart) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(3000) + } + } + }, Handler(Looper.getMainLooper())) + + // Register with cross-layer transport bridge + TransportBridgeService.register("WIFI", this) + + meshCore.startCore() + com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI") + startPeriodicConnectionMaintenance() + connectionTracker.start() + } + + /** + * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. + */ + override fun stopServices() { + val wasActive = isActive + isActive = false + sessionGeneration.incrementAndGet() + Log.i(TAG, "Stopping Wi-Fi Aware mesh") + + // Unregister from bridge + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + + if (wasActive) { + meshCore.sendLeaveAnnouncement() + } + + serviceScope.launch { + delay(200) + + meshCore.stopCore() + connectionTracker.stop() // Handles socket closing and callback unregistration + + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + + meshCore.shutdown() + + // Tear down listener threads; this instance is discarded after a full stop. + try { listenerExec.shutdownNow() } catch (_: Exception) { } + + com.bitchat.android.wifiaware.WifiAwareController.onServiceStopped(this@WifiAwareMeshService) + serviceScope.cancel() + } + } + + private fun isCurrentSession(generation: Int): Boolean { + return generation == sessionGeneration.get() && isActive + } + + private fun handleUnexpectedStop(generation: Int) { + if (generation != sessionGeneration.get()) return + if (!isActive) { + return + } + recoveryInProgress = true + isActive = false + TransportBridgeService.unregister("WIFI") + com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } + try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } + val oldPublishSession = publishSession + val oldSubscribeSession = subscribeSession + val oldWifiAwareSession = wifiAwareSession + serviceScope.launch { + try { + try { meshCore.stopCore() } catch (_: Exception) { } + try { connectionTracker.stop() } catch (_: Exception) { } + try { oldPublishSession?.close() } catch (_: Exception) { } + try { oldSubscribeSession?.close() } catch (_: Exception) { } + try { oldWifiAwareSession?.close() } catch (_: Exception) { } + if (generation == sessionGeneration.get() && !isActive) { + if (publishSession === oldPublishSession) publishSession = null + if (subscribeSession === oldSubscribeSession) subscribeSession = null + if (wifiAwareSession === oldWifiAwareSession) wifiAwareSession = null + handleToPeerId.clear() + subscribeHandles.clear() + publishHandles.clear() + discoveredTimestamps.clear() + } + } finally { + recoveryInProgress = false + // Recovery cleanup is done; nudge a restart now that startServices() will no + // longer be deferred by recoveryInProgress. The controller coalesces requests. + if (com.bitchat.android.wifiaware.WifiAwareController.enabled.value) { + com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(500) + } + } + } + } + + private fun rememberDiscoveredPeer(peerId: String) { + if (peerId.isBlank() || peerId == myPeerID) return + val now = System.currentTimeMillis() + discoveredTimestamps[peerId] = now + lastDiscoveryActivityAt.set(now) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun offerServerPathIfAppropriate(peerId: String, peerHandle: PeerHandle, reason: String) { + val pubSession = publishSession ?: return + if (peerId.isBlank() || peerId == myPeerID || !amIServerFor(peerId)) return + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) return + + Log.d(TAG, "PUBLISH: offering server path to ${peerId.take(8)} after $reason") + handleSubscriberPing(pubSession, peerHandle) + } + + private fun refreshDiscoverySessions(reason: String, now: Long = System.currentTimeMillis()): Boolean { + if (!isActive || recoveryInProgress) return false + if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false + + val lastRefresh = lastDiscoveryRefreshAt.get() + if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false + + Log.i(TAG, "Maintenance: refreshing Wi-Fi Aware discovery sessions ($reason)") + handleUnexpectedStop(sessionGeneration.get()) + return true + } + + /** + * Periodic active maintenance: retries connections to discovered but unconnected peers. + */ + private fun startPeriodicConnectionMaintenance() { + serviceScope.launch { + Log.d(TAG, "Starting periodic connection maintenance loop") + while (isActive) { + try { + delay(15_000) // Check every 15 seconds + if (!isActive) break + + val now = System.currentTimeMillis() + + // 0. Prune stale discovery entries. PeerHandles become invalid when the + // discovery sessions restart, so we must not keep pinging old handles forever. + val staleIds = discoveredTimestamps.filter { (id, ts) -> + (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + }.keys.toSet() + if (staleIds.isNotEmpty()) { + staleIds.forEach { discoveredTimestamps.remove(it) } + handleToPeerId.entries.removeIf { it.value in staleIds } + staleIds.forEach { subscribeHandles.remove(it) } + staleIds.forEach { publishHandles.remove(it) } + Log.d(TAG, "Maintenance: pruned ${staleIds.size} stale discovery entries") + } + + // 1. Identify peers that are discovered (recently seen) but not currently connected + val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> + (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes + }.keys + + // 2. Filter out those who are already connected + val disconnectedPeers = recentDiscovered.filter { peerId -> + !connectionTracker.isConnected(peerId) + } + + // 3. Attempt reconnection. Aware discovery is not always symmetrical: + // subscribe handles can disappear while publish handles still see the peer. + var attemptedReconnect = false + var missingUsableHandle = false + for (peerId in disconnectedPeers) { + if (amIServerFor(peerId)) { + val handle = publishHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.i(TAG, "Maintenance: offering Wi-Fi Aware server path to ${peerId.take(8)}") + offerServerPathIfAppropriate(peerId, handle, "maintenance") + attemptedReconnect = true + } + continue + } + + // Use a subscribe-session-scoped handle. A publish-scoped handle would be + // invalid for subscribeSession.sendMessage() and silently fail. + val handle = subscribeHandles[peerId] + if (handle == null) { + missingUsableHandle = true + continue + } + + // Check tracker policy + if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue + + Log.i(TAG, "Maintenance: attempting Wi-Fi Aware reconnect to ${peerId.take(8)}") + sendSubscribePing(peerId, handle, "maintenance") + attemptedReconnect = true + } + + val noActiveDataPath = connectionTracker.getConnectionCount() == 0 && + !connectionTracker.hasPendingDataPathRequest() + if (noActiveDataPath) { + val idleFor = now - lastDiscoveryActivityAt.get() + when { + disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { + refreshDiscoverySessions("missing peer handle", now) + } + recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + refreshDiscoverySessions("idle discovery", now) + } + } + } + } catch (e: CancellationException) { + break + } catch (e: Exception) { + Log.e(TAG, "Error in connection maintenance: ${e.message}") + } + } + } + } + + private fun sendSubscribePing(peerId: String, peerHandle: PeerHandle, reason: String) { + if (peerId.isBlank()) return + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + try { + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + Log.d(TAG, "SUBSCRIBE: sent $reason ping to '${peerId.take(16)}' (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}") + } + } + + private fun requestRoleReversal(peerId: String, allowForcedClientOverride: Boolean = false) { + if (peerId.isBlank()) return + if (forcedClientPeers.contains(peerId) && !allowForcedClientOverride) return + forcedServerPeers.add(peerId) + forcedClientPeers.remove(peerId) + + val handle = subscribeHandles[peerId] + if (handle == null) { + Log.i(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available") + return + } + + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + val payload = "$ROLE_REVERSAL_PREFIX$myPeerID".toByteArray() + try { + subscribeSession?.sendMessage(handle, msgId, payload) + Log.i(TAG, "CLIENT: requested Wi-Fi Aware role reversal with ${peerId.take(8)} (msgId=$msgId)") + } catch (e: Exception) { + Log.w(TAG, "CLIENT: failed to request role reversal with ${peerId.take(8)}: ${e.message}") + } + } + + private fun shouldRequestRoleReversalAfterClientFailure(peerId: String): Boolean { + val failures = clientSocketFailures + .computeIfAbsent(peerId) { AtomicInteger(0) } + .incrementAndGet() + val shouldReverse = failures >= CLIENT_ROLE_REVERSAL_FAILURES + if (shouldReverse) { + clientSocketFailures.remove(peerId) + Log.i(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal") + } else { + Log.d(TAG, "CLIENT: ${peerId.take(8)} failed client socket attempt $failures/$CLIENT_ROLE_REVERSAL_FAILURES; retrying same role") + } + return shouldReverse + } + + private fun handleRoleReversalRequest(peerHandle: PeerHandle, requesterId: String) { + if (requesterId.isBlank() || requesterId == myPeerID) return + handleToPeerId[peerHandle] = requesterId + discoveredTimestamps[requesterId] = System.currentTimeMillis() + forcedClientPeers.add(requesterId) + forcedServerPeers.remove(requesterId) + Log.i(TAG, "PUBLISH: role reversal requested by ${requesterId.take(8)}; switching to client role") + + subscribeHandles[requesterId]?.let { handle -> + sendSubscribePing(requesterId, handle, "role-reversal") + } + } + + /** + * Handles subscriber ping: spawns a server socket and responds with connection info. + * + * @param pubSession The current publish discovery session + * @param peerHandle The handle for the peer that pinged us + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleSubscriberPing( + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + val peerId = handleToPeerId[peerHandle] ?: return + if (!amIServerFor(peerId)) return + + if (connectionTracker.isConnected(peerId)) { + Log.v(TAG, "↪ already connected to $peerId, skipping serve") + return + } + if (connectionTracker.hasOpenServerSocket(peerId)) { + Log.v(TAG, "↪ already serving $peerId, skipping") + return + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "SERVER: deferring serve for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val ss = ServerSocket() + try { + ss.reuseAddress = true + val anyIpv6 = Inet6Address.getByAddress(ByteArray(16)) + ss.bind(java.net.InetSocketAddress(anyIpv6, 0)) + } catch (e: Exception) { + Log.e(TAG, "Failed to bind server socket", e) + handleNetworkFailure(peerId) + return + } + + connectionTracker.addServerSocket(peerId, ss) + val port = ss.localPort + + Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on ${ss.localSocketAddress}") + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .setTransportProtocol(OsConstants.IPPROTO_TCP) + .build() + // Default capabilities include NET_CAPABILITY_NOT_VPN. + // Keeping defaults for hardware interface handle acquisition compatibility with global VPNs. + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val acceptStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + Log.i(TAG, "SERVER: onAvailable() - Aware network is ready for ${peerId.take(8)}") + // Only accept once per network request + if (!acceptStarted.compareAndSet(false, true)) return + // Offload the blocking accept() off the callback thread so we never stall + // the (main-thread) ConnectivityManager callback dispatcher. + listenerExec.execute { + try { + try { ss.soTimeout = ACCEPT_TIMEOUT_MS } catch (_: Exception) {} + val client = ss.accept() + Log.i(TAG, "SERVER: Accepted raw TCP connection from ${peerId.take(8)}") + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.i(TAG, "SERVER: Bound and established TCP with ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") + val synced = SyncedSocket(client) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + // We only ever accept a single data socket per server request. Close the + // listening ServerSocket now so it can't block a future re-serve (its + // presence makes hasOpenServerSocket() true for the life of the process) + // and so we free the fd/port promptly. + connectionTracker.closeServerSocket(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "SERVER: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + if (ss.isClosed || !isActive) { + Log.d(TAG, "SERVER: accept stopped for ${peerId.take(8)} after socket cleanup") + } else { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + handleNetworkFailure(peerId) + } + } + } + } + + override fun onUnavailable() { + Log.e(TAG, "SERVER: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)} (timeout or refused)") + handleNetworkFailure(peerId) + } + + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "SERVER: WiFi Aware network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.i(TAG, "SERVER: [Calling requestNetwork] for ${peerId.take(8)} with port $port") + try { + // use requestNetwork with a timeout to trigger onUnavailable if it fails + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "SERVER: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + + val readyId = (System.nanoTime() and 0x7fffffff).toInt() + val readyPayload = buildServerReadyPayload(port) + Handler(Looper.getMainLooper()).post { + try { + val sent = pubSession.sendMessage(peerHandle, readyId, readyPayload) + Log.d(TAG, "PUBLISH: server-ready sent=$sent (msgId=$readyId, port=$port)") + } catch (e: Exception) { + Log.e(TAG, "PUBLISH: Exception sending server-ready to $peerHandle", e) + } + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages to maintain a subscriber connection. + * + * @param client Connected client socket + * @param peerId ID of the connected peer + */ + private fun handleSubscriberKeepAlive( + client: SyncedSocket, + peerId: String, + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + // TCP keep-alive pings + serviceScope.launch { + try { + while (connectionTracker.isConnected(peerId)) { + // write empty byte array effectively sends [4 bytes length=0] which is our ping + try { + client.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Don't just stop pinging: actively tear down so the + // half-open socket stops counting as "connected" and maintenance can retry. + handlePeerDisconnection(peerId, client) + break + } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + private fun connectAwareClientSocket( + network: Network, + scopedAddr: Inet6Address, + port: Int, + peerId: String + ): Socket { + var lastFailure: IOException? = null + for (attempt in 1..CLIENT_SOCKET_ATTEMPTS) { + val delayMs = if (attempt == 1) CLIENT_SOCKET_READY_DELAY_MS else CLIENT_SOCKET_RETRY_DELAY_MS + if (delayMs > 0) { + try { + Thread.sleep(delayMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw InterruptedIOException("Interrupted before Wi-Fi Aware socket connect") + } + } + + var sock: Socket? = null + try { + sock = network.socketFactory.createSocket() + sock.tcpNoDelay = true + sock.keepAlive = true + sock.connect(java.net.InetSocketAddress(scopedAddr, port), CLIENT_CONNECT_TIMEOUT_MS) + if (attempt > 1) { + Log.i(TAG, "CLIENT: socket connect succeeded for ${peerId.take(8)} on attempt $attempt") + } + return sock + } catch (e: IOException) { + lastFailure = e + try { sock?.close() } catch (_: Exception) { } + if (attempt < CLIENT_SOCKET_ATTEMPTS) { + Log.w(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}; retrying") + } + } + } + + throw lastFailure ?: IOException("Wi-Fi Aware socket connect failed without an exception") + } + + private fun buildServerReadyPayload(port: Int): ByteArray { + val peerIdBytes = myPeerID.toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(Int.SIZE_BYTES + peerIdBytes.size) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .put(peerIdBytes) + .array() + } + + private fun peerIdFromServerReadyPayload(payload: ByteArray): String? { + if (payload.size <= Int.SIZE_BYTES) return null + val peerId = try { + String(payload.copyOfRange(Int.SIZE_BYTES, payload.size), Charsets.UTF_8).trim() + } catch (_: Exception) { + return null + } + return peerId.takeIf { id -> + id.length == 16 && id.all { ch -> ch in '0'..'9' || ch in 'a'..'f' || ch in 'A'..'F' } + }?.lowercase() + } + + private fun resolveServerReadyPeerId(peerHandle: PeerHandle, payload: ByteArray): String? { + val advertisedPeerId = peerIdFromServerReadyPayload(payload) + val mappedPeerId = handleToPeerId[peerHandle]?.takeIf { it.isNotBlank() } + val peerId = advertisedPeerId ?: mappedPeerId + if (peerId == null) { + Log.w(TAG, "SUBSCRIBE: dropped server-ready with no peer mapping and no peer ID payload (payload=${payload.size}B)") + return null + } + + handleToPeerId[peerHandle] = peerId + subscribeHandles[peerId] = peerHandle + rememberDiscoveredPeer(peerId) + if (advertisedPeerId != null && mappedPeerId != null && advertisedPeerId != mappedPeerId) { + Log.d(TAG, "SUBSCRIBE: server-ready remapped handle ${mappedPeerId.take(8)} -> ${advertisedPeerId.take(8)}") + } + return peerId + } + + /** + * Handles a "server ready" message from a publishing peer and initiates a client connection. + */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleServerReady( + peerHandle: PeerHandle, + payload: ByteArray + ) { + if (payload.size < Int.SIZE_BYTES) { + Log.w(TAG, "handleServerReady called with invalid payload size=${payload.size}, dropping") + return + } + + val peerId = resolveServerReadyPeerId(peerHandle, payload) ?: return + if (peerId == myPeerID) return + if (amIServerFor(peerId)) return + if (connectionTracker.peerSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already client-connected to $peerId, skipping") + return + } + val cancelledServerOffers = connectionTracker.cancelPendingServerDataPaths(peerId) + if (cancelledServerOffers.isNotEmpty()) { + val cancelled = cancelledServerOffers.joinToString(", ") { it.take(8) } + Log.i(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}") + } + if (connectionTracker.hasPendingDataPathRequest(peerId)) { + val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) } + Log.d(TAG, "CLIENT: deferring server-ready for ${peerId.take(8)}; pending Aware data path(s): $pending") + return + } + if (!connectionTracker.addPendingConnection(peerId)) { + return + } + + val port = ByteBuffer.wrap(payload, 0, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int + Log.i(TAG, "CLIENT: Received server-ready from ${peerId.take(8)} on port $port (payload=${payload.size}B). Requesting network...") + + val subSession = subscribeSession ?: run { + Log.w(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}") + connectionTracker.removePendingConnection(peerId) + return + } + val spec = WifiAwareNetworkSpecifier.Builder(subSession, peerHandle) + .setPskPassphrase(PSK) + .build() + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + @Volatile private var activeSocket: SyncedSocket? = null + private val connectStarted = AtomicBoolean(false) + + override fun onAvailable(network: Network) { + Log.i(TAG, "CLIENT: onAvailable() - Aware network is ready for ${peerId.take(8)}") + // Do not bind process for Aware; use per-socket binding instead + } + + override fun onUnavailable() { + Log.e(TAG, "CLIENT: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)}") + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (connectionTracker.peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as? Inet6Address ?: return + val connectPort = if (info.port > 0) info.port else port + // onCapabilitiesChanged can fire multiple times; only connect once + if (!connectStarted.compareAndSet(false, true)) return + Log.i(TAG, "CLIENT: onCapabilitiesChanged() - Peer IPv6 discovered: $addr port=$connectPort") + + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + + // Offload the blocking connect() off the callback thread. + listenerExec.execute { + try { + // Use scoped IPv6 if interface name is available + val scopedAddr = if (iface != null && addr.scopeId == 0) { + try { + Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface)) + } catch (e: Exception) { + addr + } + } else { + addr + } + + val sock = connectAwareClientSocket(network, scopedAddr, connectPort, peerId) + Log.i(TAG, "CLIENT: TCP connected to ${peerId.take(8)} at $scopedAddr:$connectPort") + + val synced = SyncedSocket(sock) + activeSocket = synced + connectionTracker.onClientConnected(peerId, synced) + clientSocketFailures.remove(peerId) + try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(synced, peerId) } + handleServerKeepAlive(synced, peerId, peerHandle) + + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + meshCore.initiateNoiseHandshake(peerId) + Log.i(TAG, "CLIENT: Initiating Noise handshake to ${peerId.take(8)}") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe) + if (shouldRequestRoleReversalAfterClientFailure(peerId)) { + requestRoleReversal(peerId, allowForcedClientOverride = true) + } + handleNetworkFailure(peerId) + } + } + } + override fun onLost(network: Network) { + handlePeerDisconnection(peerId, activeSocket) + Log.i(TAG, "CLIENT: WiFi Aware network lost for ${peerId.take(8)}") + } + } + + connectionTracker.addNetworkCallback(peerId, cb) + Log.i(TAG, "CLIENT: [Calling requestNetwork] for ${peerId.take(8)}") + try { + cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS) + } catch (e: Exception) { + Log.e(TAG, "CLIENT: ConnectivityManager.requestNetwork threw exception", e) + connectionTracker.disconnect(peerId) + } + } + + /** + * Sends periodic TCP and discovery keep-alive messages for server connections. + */ + private fun handleServerKeepAlive( + sock: SyncedSocket, + peerId: String, + peerHandle: PeerHandle + ) { + // TCP keep-alive + serviceScope.launch { + try { + while (connectionTracker.isConnected(peerId)) { + try { + sock.write(ByteArray(0)) + } catch (_: IOException) { + // The write side is dead. Tear down so the half-open socket stops counting + // as "connected" and maintenance can retry instead of silently stalling. + handlePeerDisconnection(peerId, sock) + break + } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (connectionTracker.isConnected(peerId)) { + try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + /** + * Determines whether this device should act as the server in a given peer relationship. + */ + private fun amIServerFor(peerId: String): Boolean = when { + forcedClientPeers.contains(peerId) -> false + forcedServerPeers.contains(peerId) -> true + else -> myPeerID < peerId + } + + /** + * Listens for incoming packets from a connected peer and dispatches them through + * the packet processor. + * + * @param socket Socket connected to the peer + * @param initialLogicalPeerId Temporary identifier before peer ID resolution + */ + private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { + var logicalPeerId = initialLogicalPeerId + while (isActive) { + val raw = socket.read() ?: break + + if (raw.isEmpty()) { + // Keep-alive (0 length frame) + continue + } + + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + + if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { + val previousPeerId = logicalPeerId + logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == previousPeerId) { + handleToPeerId[handle] = senderPeerHex + } + } + subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it } + discoveredTimestamps.remove(previousPeerId) + discoveredTimestamps[senderPeerHex] = System.currentTimeMillis() + try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { } + try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { } + publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it } + Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}") + } + + // Route the packet: + // - peerID = Originator (who signed it) + // - relayAddress = Neighbor (who sent it to us over this socket) + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})") + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId) + } + + // 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.") + handlePeerDisconnection(logicalPeerId, socket) + socket.close() + } + + private fun handleNetworkFailure(peerId: String) { + serviceScope.launch { + Log.d(TAG, "Network failure cleanup for: $peerId") + if (!connectionTracker.isConnected(peerId)) { + val canonicalPeerId = connectionTracker.canonicalPeerId(peerId) + connectionTracker.disconnect(peerId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != peerId) { + meshCore.removePeer(peerId) + } + } else { + Log.d(TAG, "Network failure ignored for $peerId - another socket is active") + } + } + } + + private fun handlePeerDisconnection(initialId: String, socket: SyncedSocket? = null) { + serviceScope.launch { + // Check if this socket is the current active one before nuking the session + val currentSocket = connectionTracker.getSocketForPeer(initialId) + val canonicalPeerId = connectionTracker.canonicalPeerId(initialId) + if (currentSocket === socket) { + Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else if (socket == null && currentSocket == null) { + // Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup + Log.d(TAG, "Cleaning up peer: $initialId (no active socket)") + connectionTracker.disconnect(initialId) + meshCore.removePeer(canonicalPeerId) + if (canonicalPeerId != initialId) { + meshCore.removePeer(initialId) + } + } else { + Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive") + // Do not remove peer/session, as a new socket has likely taken over + } + } + } + + /** + * Sends a broadcast message to all peers. + * @param content Text content of the message + * @param mentions Optional list of mentioned peer IDs + * @param channel Optional channel name + */ + override fun sendMessage(content: String, mentions: List, channel: String?) { + meshCore.sendMessage(content, mentions, channel) + } + + /** + * Sends a private encrypted message to a specific peer. + * + * @param content The message text + * @param recipientPeerID Destination peer ID + * @param recipientNickname Recipient nickname + * @param messageID Optional message ID (UUID if null) + */ + override fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String?) { + meshCore.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID) + } + + /** + * Sends a read receipt for a specific message to the given peer over an established + * Noise session. If no session exists, this will log an error. + * + * @param messageID The ID of the message that was read. + * @param recipientPeerID The peer to notify. + * @param readerNickname Nickname of the reader (may be shown by the receiver). + */ + override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname) + } + + override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyChallenge(peerID, noiseKeyHex, nonceA) + } + + override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) + } + + /** + * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support + * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. + * + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFileBroadcast(file: BitchatFilePacket) { + meshCore.sendFileBroadcast(file) + } + + /** + * Sends a file privately to a specific peer. If no Noise session is established, + * a handshake will be initiated and the send is deferred/aborted for now. + * + * @param recipientPeerID Target peer. + * @param file Encoded metadata and chunks descriptor of the file to send. + */ + override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + meshCore.sendFilePrivate(recipientPeerID, file) + } + + /** + * Attempts to cancel an in-flight file transfer identified by its transferId. + * + * @param transferId Deterministic id (usually sha256 of the file TLV). + * @return true if a transfer with this id was found and cancellation was scheduled, false otherwise. + */ + override fun cancelFileTransfer(transferId: String): Boolean { + return meshCore.cancelFileTransfer(transferId) + } + + /** + * Broadcasts an ANNOUNCE packet to the entire mesh. + */ + override fun sendBroadcastAnnounce() { + meshCore.sendBroadcastAnnounce() + } + + /** + * Sends an ANNOUNCE packet to a specific peer. + */ + override fun sendAnnouncementToPeer(peerID: String) { + meshCore.sendAnnouncementToPeer(peerID) + } + + /** @return Mapping of peer IDs to nicknames. */ + override fun getPeerNicknames(): Map = meshCore.getPeerNicknames() + + /** @return Mapping of peer IDs to RSSI values. */ + override fun getPeerRSSI(): Map = meshCore.getPeerRSSI() + + /** @return current active peer count for status surfaces. */ + override fun getActivePeerCount(): Int = meshCore.getActivePeerCount() + + /** + * @return true if a Noise session with the peer is fully established. + */ + override fun hasEstablishedSession(peerID: String) = meshCore.hasEstablishedSession(peerID) + + /** + * @return a human-readable Noise session state for the given peer (implementation-defined). + */ + override fun getSessionState(peerID: String) = meshCore.getSessionState(peerID) + + /** + * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. + */ + override fun initiateNoiseHandshake(peerID: String) = meshCore.initiateNoiseHandshake(peerID) + + /** + * @return the stored public-key fingerprint (hex) for a peer, if known. + */ + override fun getPeerFingerprint(peerID: String): String? = meshCore.getPeerFingerprint(peerID) + + /** + * Retrieves the full profile for a peer, including keys and verification state, if available. + */ + override fun getPeerInfo(peerID: String): PeerInfo? = meshCore.getPeerInfo(peerID) + + /** + * Updates local metadata for a peer and returns whether the change was applied. + * + * @param peerID Target peer id. + * @param nickname Display name. + * @param noisePublicKey Peer’s Noise static public key. + * @param signingPublicKey Peer’s Ed25519 signing public key. + * @param isVerified Whether this identity is verified by the user. + * @return true if the record was updated or created, false otherwise. + */ + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = meshCore.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + /** + * @return the local device’s long-term identity fingerprint (hex). + */ + override fun getIdentityFingerprint(): String = meshCore.getIdentityFingerprint() + + override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + + /** + * @return true if the UI should show an “encrypted” indicator for this peer. + */ + override fun shouldShowEncryptionIcon(peerID: String) = meshCore.shouldShowEncryptionIcon(peerID) + + /** + * @return a snapshot list of peers with established Noise sessions. + */ + override fun getEncryptedPeers(): List = meshCore.getEncryptedPeers() + + /** + * @return the current IPv4/IPv6 address of a connected peer, if any. + * Prefers the scoped IPv6 address format. + */ + override fun getDeviceAddressForPeer(peerID: String): String? = + meshCore.getDeviceAddressForPeer(peerID) + + /** + * Helper to resolve a scoped IPv6 address from a socket for UI display. + */ + private fun resolveScopedAddress(sock: Socket): String? { + val addr = sock.inetAddress as? Inet6Address ?: return sock.inetAddress?.hostAddress + if (addr.scopeId != 0 || addr.isLoopbackAddress) return addr.hostAddress + + // If address has no scope but we are on Aware (Link-Local fe80), attempt interface resolution + val iface = try { + val lp = cm.getLinkProperties(cm.activeNetwork) + lp?.interfaceName ?: "aware0" + } catch (_: Exception) { "aware0" } + + return "${addr.hostAddress}%$iface" + } + + /** + * @return a mapping of peerID → connected device IP address for all active sockets. + * Results are formatted as scoped addresses if applicable. + */ + override fun getDeviceAddressToPeerMapping(): Map = + meshCore.getDeviceAddressToPeerMapping() + + /** + * @return map of peer ID to nickname, bridged for UI warning fix. + */ + fun getPeerNicknamesMap(): Map = meshCore.getPeerNicknames() + + /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */ + fun getDiscoveredPeerIds(): Set = + (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + + /** + * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. + */ + override fun printDeviceAddressesForPeers(): String = + getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } + + /** + * @return A detailed string containing the debug status of all mesh components. + */ + override fun getDebugStatus(): String { + return meshCore.getDebugStatus( + transportInfo = connectionTracker.getDebugInfo(), + deviceMap = getDeviceAddressToPeerMapping(), + extraLines = listOf("Peers: ${connectionTracker.peerSockets.keys}"), + title = "Wi-Fi Aware Mesh Debug Status" + ) + } + + override fun clearAllInternalData() { + meshCore.clearAllInternalData() + } + + override fun clearAllEncryptionData() { + meshCore.clearAllEncryptionData() + } + + /** Utility extension to safely close server sockets. */ + private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} + + + private inner class WifiAwareTransport : MeshTransport { + override val id: String = "WIFI" + + override fun broadcastPacket(routed: RoutedPacket) { + this@WifiAwareMeshService.broadcastPacket(routed) + } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { + return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun cancelTransfer(transferId: String): Boolean { + return fragmentingSender.cancelTransfer(transferId) + } + override fun getDeviceAddressForPeer(peerID: String): String? { + return connectionTracker.getSocketForPeer(peerID)?.let { resolveScopedAddress(it.rawSocket) } + } + + override fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + connectionTracker.peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock.rawSocket) ?: "unknown" + } + return map + } + override fun getTransportDebugInfo(): String { + return connectionTracker.getDebugInfo() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt new file mode 100644 index 00000000..3e7dbc58 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareSupport.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.wifiaware + +import android.content.Context +import android.content.pm.PackageManager +import android.net.wifi.aware.WifiAwareManager +import android.os.Build + +/** + * Centralized Wi-Fi Aware capability checks. + * + * "Supported" is stable device/API capability. "Available" is runtime state and can change + * when Wi-Fi, location, airplane mode, or system radio state changes. + */ +object WifiAwareSupport { + data class Status( + val supported: Boolean, + val available: Boolean, + val reason: String? = null + ) + + fun evaluate(context: Context): Status { + val appContext = context.applicationContext + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return Status( + supported = false, + available = false, + reason = "requires Android 10+" + ) + } + + val hasFeature = try { + appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE) + } catch (_: Exception) { + false + } + if (!hasFeature) { + return Status( + supported = false, + available = false, + reason = "device does not advertise Wi-Fi Aware support" + ) + } + + val manager = getManager(appContext) + ?: return Status( + supported = false, + available = false, + reason = "WifiAwareManager unavailable" + ) + + val available = try { + manager.isAvailable + } catch (_: Exception) { + false + } + + return Status( + supported = true, + available = available, + reason = if (available) null else "Wi-Fi Aware temporarily unavailable" + ) + } + + fun isSupported(context: Context): Boolean = evaluate(context).supported + + fun getManager(context: Context): WifiAwareManager? { + return try { + context.applicationContext.getSystemService(WifiAwareManager::class.java) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7ddae251..3d72337a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,6 +362,7 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + بلوتوث موصى به الشبكة تعمل — %1$d أقران verify diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 24f94630..3cda37a6 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,6 +349,7 @@ %d জন + ব্লুটুথ প্রস্তাবিত মেশ চলছে — %1$d পিয়ার verify diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c02bbb37..c6cb4aac 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -363,6 +363,7 @@ sei der Erste, der hier eine Notiz hinterlässt. schließen füge eine Notiz zu diesem Ort hinzu + Bluetooth empfohlen Mesh läuft — %1$d Peers verifizieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index eae48a0e..b8fed3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -362,6 +362,7 @@ sé el primero en añadir una para este sitio. cerrar agrega una nota para este lugar + Bluetooth recomendado Mesh en ejecución — %1$d pares verificar diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1473c5fd..8ad97fe9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,6 +349,7 @@ %d نفر + بلوتوث توصیه می شود مش در حال اجرا — %1$d همتا verify diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 0a7dc2e3..34e1d5f5 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -362,6 +362,7 @@ %d tao + Bluetooth Recommended Tumatakbo ang Mesh — %1$d na peer verify diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 01f480d2..9828a76b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -376,6 +376,7 @@ soyez le premier à en ajouter pour cet endroit. fermer ajoutez une note pour cet endroit + Bluetooth recommandé Mesh actif — %1$d pairs vérifier diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index deb22711..e18b57b8 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,6 +14,8 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + Skip + Bluetooth Recommended רשת Mesh פועלת — %1$d עמיתים verify diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index acaa54b6..02d64711 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -362,6 +362,7 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + ब्लूटूथ अनुशंसित मेश चल रहा है — %1$d पीयर्स verify diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 18c85a5b..fb9830be 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -362,6 +362,7 @@ jadilah yang pertama menambahkan catatan untuk tempat ini. tutup tambahkan catatan untuk tempat ini + Bluetooth Recommended Mesh berjalan — %1$d peer verify diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 07fb5a76..3db4e85e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,6 +396,7 @@ sii il primo ad aggiungerne una per questo posto. chiudi aggiungi una nota per questo luogo + Bluetooth consigliato Mesh in esecuzione — %1$d peer verifica diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f9c4fd64..df489157 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -362,6 +362,7 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + Bluetooth推奨 メッシュ実行中 — %1$d ピア 検証 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index db24c8a9..2d1c6693 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -349,6 +349,7 @@ %d ადამიანი + Bluetooth Recommended Mesh გაშვებულია — %1$d პირები verify diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0510fb7d..68d46136 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -362,6 +362,7 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 블루투스 권장 메시 실행 중 — %1$d 피어 verify diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 5fc53577..57676dec 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -376,6 +376,7 @@ Olona %d + Bluetooth Recommended Mandeha ny Mesh — %1$d peers verify diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 0e6dd945..b4b23655 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,6 +1,8 @@ + Skip + Bluetooth Recommended Mesh sedang berjalan — %1$d rakan verify diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 6632f0dc..252f81c4 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,6 +362,7 @@ %d जना + ब्लुटुथ सिफारिस गरिएको मेश चलिरहेको छ — %1$d पियर्स verify diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7d19b52a..59705572 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -394,6 +394,7 @@ wees de eerste die een notitie voor deze plek toevoegt. sluiten voeg een notitie toe voor deze plek + Bluetooth aanbevolen Mesh actief — %1$d peers verify diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3f08aa32..58135f7c 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -349,6 +349,7 @@ %d بندے + Bluetooth Recommended میش چل رہا ہے — %1$d ساتھی verify diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 56cd087a..cdbffdd0 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,6 +14,8 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Pomiń + Bluetooth zalecany Mesh działa — %1$d peerów verify diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e9686f99..45f41512 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -362,6 +362,7 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado Mesh rodando — %1$d pares verificar diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8f9baf72..147b5f37 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -362,6 +362,7 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Bluetooth recomendado Mesh em execução — %1$d pares verificar diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cad2a0d1..dade7d1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,6 +352,7 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Рекомендуется Bluetooth Mesh запущен — %1$d пиров проверить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ef3307e3..9ac7df67 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -350,6 +350,7 @@ var först med att lägga till en anteckning för den här platsen. stäng lägg till en anteckning för den här platsen + Bluetooth rekommenderas Mesh körs — %1$d peers verify diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 98ceef9c..1e487548 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,6 +1,8 @@ + Skip + Bluetooth Recommended மெஷ் இயங்குகிறது — %1$d பியர்ஸ் verify diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 343f6885..4a4c5021 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -349,6 +349,7 @@ %d คน + Bluetooth Recommended Mesh กำลังทำงาน — %1$d เพื่อน verify diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1209074c..a2df9dfe 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -350,6 +350,7 @@ bu yer için ilk notu ekleyen siz olun. kapat bu yer için bir not ekleyin + Bluetooth Önerilir Mesh çalışıyor — %1$d eş verify diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5545b546..2c631d04 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,6 +1,8 @@ + Пропустити + Рекомендується Bluetooth Mesh працює — %1$d пірів verify diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1626a041..291c3e9a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,6 +362,7 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + Bluetooth Recommended میش چل رہا ہے — %1$d ساتھی verify diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index afc227b6..a1315617 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -349,6 +349,7 @@ %d người + Khuyên dùng Bluetooth Mesh đang chạy — %1$d ngang hàng verify diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9f4b17d3..69bff70c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,6 +14,8 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 跳过 + 建议开启蓝牙 Mesh 运行中 — %1$d 个节点 验证 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a347d5b0..1dcf6b27 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,6 +14,8 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + 跳過 + 建議開啟藍牙 Mesh 運行中 — %1$d 個節點 验证 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3a4a628f..46caf1af 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,6 +375,7 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 建议开启蓝牙 Mesh 运行中 — %1$d 个节点 验证 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d34dce0a..27a5c5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -452,4 +452,5 @@ %d people + Bluetooth Recommended diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index f1b33d32..431d8500 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -2,7 +2,7 @@ package com.bitchat.android.ui import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -34,7 +34,7 @@ class CommandProcessorTest() { coroutineScope = testScope ) - private val meshService: BluetoothMeshService = mock() + private val meshService: MeshService = mock() @Before fun setup() { diff --git a/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt new file mode 100644 index 00000000..83802149 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/MeshPacketUtilsTest.kt @@ -0,0 +1,45 @@ +package com.bitchat + +import com.bitchat.android.mesh.MeshPacketUtils +import junit.framework.TestCase.assertEquals +import org.junit.Test + +class MeshPacketUtilsTest { + @Test + fun hexStringToByteArray_parsesFullId() { + val bytes = MeshPacketUtils.hexStringToByteArray("0011223344556677") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + assertEquals(0x11.toByte(), bytes[1]) + assertEquals(0x22.toByte(), bytes[2]) + assertEquals(0x33.toByte(), bytes[3]) + assertEquals(0x44.toByte(), bytes[4]) + assertEquals(0x55.toByte(), bytes[5]) + assertEquals(0x66.toByte(), bytes[6]) + assertEquals(0x77.toByte(), bytes[7]) + } + + @Test + fun hexStringToByteArray_parsesShortId() { + val bytes = MeshPacketUtils.hexStringToByteArray("ab") + assertEquals(8, bytes.size) + assertEquals(0xab.toByte(), bytes[0]) + assertEquals(0x00.toByte(), bytes[1]) + } + + @Test + fun hexStringToByteArray_handlesInvalidHex() { + val bytes = MeshPacketUtils.hexStringToByteArray("zz") + assertEquals(8, bytes.size) + assertEquals(0x00.toByte(), bytes[0]) + } + + @Test + fun sha256Hex_matchesKnownValue() { + val hash = MeshPacketUtils.sha256Hex("hello".toByteArray()) + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + hash + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt new file mode 100644 index 00000000..2c093353 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -0,0 +1,117 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.services.meshgraph.MeshGraphService +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class MessageHandlerTest { + private lateinit var handler: MessageHandler + private lateinit var delegate: MessageHandlerDelegate + + private val myPeerID = "1111222233334444" + private val peerID = "aaaabbbbccccdddd" + private val nickname = "peer" + private val noiseKey = ByteArray(32) { 0x0B } + private val signingKey = ByteArray(32) { 0x0A } + private val signature = ByteArray(64) { 1 } + private val announceClockSkewToleranceMs = 10 * 60 * 1000L + + @Before + fun setup() { + MeshGraphService.resetForTesting() + handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication()) + delegate = mock() + handler.delegate = delegate + + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true) + whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true) + } + + @After + fun tearDown() { + MeshGraphService.resetForTesting() + } + + @Test + fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking { + val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull()) + } + + @Test + fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Future announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + Unit + } + + @Test + fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking { + val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + + assertFalse("Announce older than clock skew tolerance should not store peer identity", result) + verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any()) + verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any()) + } + + private fun announcePacket( + ageMs: Long, + ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte() + ): BitchatPacket { + val announcement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noiseKey, + signingPublicKey = signingKey + ) + return BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = peerID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - ageMs).toULong(), + payload = announcement.encode()!!, + signature = signature, + ttl = ttl + ) + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} 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 3c680777..eb5fcd71 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -55,4 +55,45 @@ class AppStateStoreTest { assertEquals(listOf(first, second), AppStateStore.publicMessages.value) } + + @Test + fun `peer list merges transport updates instead of overwriting`() { + AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer")) + AppStateStore.setTransportPeers("BLE", emptyList()) + + assertEquals(listOf("wifi-peer"), AppStateStore.peers.value) + + AppStateStore.setTransportPeers("BLE", listOf("ble-peer")) + + assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value) + } + + @Test + fun `direct peers union across transports`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1", "shared")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "shared")) + + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.getDirectPeers() + ) + } + + @Test + fun `clearing one transport keeps the other transport direct peers`() { + AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1")) + + AppStateStore.clearTransportDirectPeers("WIFI") + + assertEquals(setOf("ble-1"), AppStateStore.getDirectPeers()) + } + + @Test + fun `latest direct peer set replaces previous set for same transport`() { + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "wifi-2")) + AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-3")) + + assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers()) + } } From ae6641270a9fa61c262e39261ed883f2bf108f3c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:45:23 +0200 Subject: [PATCH 12/32] =?UTF-8?q?feat:=20disable=20Wi=E2=80=91Fi=20Aware?= =?UTF-8?q?=20by=20default=20(#712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: CC --- app/src/main/java/com/bitchat/android/BitchatApplication.kt | 2 +- .../java/com/bitchat/android/onboarding/PermissionManager.kt | 4 ++-- .../com/bitchat/android/ui/debug/DebugPreferenceManager.kt | 2 +- .../java/com/bitchat/android/ui/debug/DebugSettingsManager.kt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 32f11d0b..282f3295 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -43,7 +43,7 @@ class BitchatApplication : Application() { // Initialize Wi‑Fi Aware controller with persisted default try { - val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true) + val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false) com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index 8fcf9c31..e7e02203 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -26,9 +26,9 @@ class PermissionManager(private val context: Context) { private fun shouldRequireWifiAwarePermission(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false val enabled = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true) + com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false) } catch (_: Exception) { - true + false } if (!enabled) return false diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index f03df3da..2d734c14 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -113,7 +113,7 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply() } - fun getWifiAwareEnabled(default: Boolean = true): Boolean = + fun getWifiAwareEnabled(default: Boolean = false): Boolean = if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default fun setWifiAwareEnabled(value: Boolean) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 82bc7fc2..80910b15 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -44,7 +44,7 @@ class DebugSettingsManager private constructor() { private val _bleEnabled = MutableStateFlow(true) val bleEnabled: StateFlow = _bleEnabled.asStateFlow() - private val _wifiAwareEnabled = MutableStateFlow(true) + private val _wifiAwareEnabled = MutableStateFlow(false) val wifiAwareEnabled: StateFlow = _wifiAwareEnabled.asStateFlow() // Master transport toggles @@ -76,7 +76,7 @@ class DebugSettingsManager private constructor() { _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) // Transport toggles _bleEnabled.value = DebugPreferenceManager.getBleEnabled(true) - _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(true) + _wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(false) _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. From 9e0a919cb8a4d9f053177c4f865e712cc260dbb1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:49:36 +0200 Subject: [PATCH 13/32] bump version (#713) Co-authored-by: CC --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 700655fe..60d53b99 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 33 - versionName = "1.7.2" + versionCode = 34 + versionName = "1.7.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From b89644f5f776cc31d9a2bdf4787d6756b6ac3470 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:59:43 +0200 Subject: [PATCH 14/32] Fix foreground service start eligibility (#714) * Fix foreground service start eligibility * Defer foreground service start when backgrounded * fix crash --------- Co-authored-by: CC --- .../java/com/bitchat/android/MainActivity.kt | 29 +++++++++++++++++-- .../android/service/MeshForegroundService.kt | 18 ++++-------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 3e6b482e..2345549e 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -59,6 +59,7 @@ class MainActivity : OrientationAwareActivity() { private lateinit var meshService: BluetoothMeshService private lateinit var unifiedMeshService: MeshService private val mainViewModel: MainViewModel by viewModels() + private var pendingMeshForegroundServiceStart = false private val chatViewModel: ChatViewModel by viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -114,8 +115,8 @@ class MainActivity : OrientationAwareActivity() { // Initialize permission management permissionManager = PermissionManager(this) - // Ensure foreground service is running and get mesh instance from holder - try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } + // Start the foreground service when allowed, then get mesh instances from the holder. + startMeshForegroundServiceBestEffort() meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext) // Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED @@ -614,6 +615,22 @@ class MainActivity : OrientationAwareActivity() { mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) } + + private fun startMeshForegroundServiceBestEffort() { + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + pendingMeshForegroundServiceStart = true + Log.i("MainActivity", "Deferring foreground mesh service start until activity is started") + return + } + + try { + com.bitchat.android.service.MeshForegroundService.start(applicationContext) + pendingMeshForegroundServiceStart = false + } catch (e: Exception) { + pendingMeshForegroundServiceStart = true + Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e) + } + } /** * Check Battery Optimization status and proceed with onboarding flow @@ -715,6 +732,7 @@ class MainActivity : OrientationAwareActivity() { // Set up unified mesh delegate and start enabled transports unifiedMeshService.delegate = chatViewModel unifiedMeshService.startServices() + startMeshForegroundServiceBestEffort() Log.d("MainActivity", "Mesh service started successfully") @@ -752,6 +770,13 @@ class MainActivity : OrientationAwareActivity() { handleVerificationIntent(intent) } } + + override fun onStart() { + super.onStart() + if (pendingMeshForegroundServiceStart) { + startMeshForegroundServiceBestEffort() + } + } override fun onResume() { super.onResume() diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 218631ea..d9ad2d7f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -38,24 +38,20 @@ class MeshForegroundService : Service() { fun start(context: Context) { val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } - // On API >= 26, avoid background-service start restrictions by using startForegroundService - // only when we can actually post a notification (Android 13+ requires runtime notif permission) - val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true) - val hasNotifPerm = hasNotificationPermissionStatic(context) + // Only launch as an FGS when onStartCommand can promote immediately. + val shouldStartForeground = shouldStartAsForeground(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (bgEnabled && hasNotifPerm) { + if (shouldStartForeground) { context.startForegroundService(intent) } else { - // Do not attempt to start a background service from headless context without notif permission - // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException. android.util.Log.i( "MeshForegroundService", - "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)" + "Not starting service on API>=26 (shouldStartForeground=$shouldStartForeground)" ) } } else { - if (bgEnabled) { + if (MeshServicePreferences.isBackgroundEnabled(true)) { context.startService(intent) } else { android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)") @@ -69,12 +65,10 @@ class MeshForegroundService : Service() { */ fun onNotificationPermissionGranted(context: Context) { // If background is enabled and permission now granted, start/promo service - val hasNotifPerm = hasNotificationPermissionStatic(context) - if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return + if (!shouldStartAsForeground(context)) return val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Safe now that we can show a notification context.startForegroundService(intent) } else { context.startService(intent) From ffe62bf1fd45fda2161d99bb36408e998b3a2652 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:27:54 +0200 Subject: [PATCH 15/32] bump to 1.7.4 (#715) Co-authored-by: CC --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60d53b99..e0b7ed00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 34 - versionName = "1.7.3" + versionCode = 35 + versionName = "1.7.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From ff15bddfc850b65e29fd59d333e081af02d48f37 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 21 Jun 2026 07:41:57 +0000 Subject: [PATCH 16/32] Automated update of relay data - Sun Jun 21 07:41:57 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 818 ++++++++++++++------------- 1 file changed, 410 insertions(+), 408 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index d2af4c45..c89c9978 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,414 +1,416 @@ Relay URL,Latitude,Longitude -relay.nostr.blockhenge.com,39.0438,-77.4874 -relay.agorist.space:443,52.3734,4.89406 -relay.zone667.com,60.1699,24.9384 -relay.typedcypher.com,51.5072,-0.127586 -wot.nostr.place,43.6532,-79.3832 -nostr.myshosholoza.co.za:443,52.3913,4.66545 -relay.homeinhk.xyz,35.694,139.754 -nostr.ps1829.com:443,33.8851,130.883 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.underorion.se,50.1109,8.68213 -nostr-relay.amethyst.name:443,39.0067,-77.4291 -relay.mitchelltribe.com:443,39.0438,-77.4874 -bitchat.nostr1.com,40.7057,-74.0136 -0x-nostr-relay.fly.dev,37.7648,-122.432 -nostr.blankfors.se,60.1699,24.9384 -relay.solife.me,43.6532,-79.3832 -relay.snotr.nl:49999,52.0195,4.42946 -nostr-relay.corb.net:443,38.8353,-104.822 -relay.klabo.world,47.2343,-119.853 -relay02.lnfi.network,35.6764,139.65 -ribo.us.nostria.app,43.6532,-79.3832 -nostr.quali.chat:443,60.1699,24.9384 -offchain.pub:443,39.1585,-94.5728 -pool.libernet.app,43.6532,-79.3832 -nostr.bond,50.1109,8.68213 -soloco.nl,43.6532,-79.3832 -relay.ru.ac.th,13.7607,100.627 -schnorr.me,43.6532,-79.3832 -ec2.f7z.io,60.1699,24.9384 -relay.gulugulu.moe:443,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.vulpem.com,49.4543,11.0746 -rilo.nostria.app,43.6532,-79.3832 -adre.su,59.9311,30.3609 -ribo.eu.nostria.app:443,43.6532,-79.3832 -vault.iris.to,43.6532,-79.3832 -strfry.shock.network:443,39.0438,-77.4874 -x.kojira.io,43.6532,-79.3832 -ribo.nostria.app:443,43.6532,-79.3832 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.chorus.community:443,48.5333,10.7 -nostr.aruku.ovh,1.27994,103.849 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -relay.getsafebox.app,43.6532,-79.3832 -relay-dev.satlantis.io:443,40.8302,-74.1299 -kasztanowa.bieda.it,43.6532,-79.3832 -relay.bullishbounty.com:443,43.6532,-79.3832 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.kilombino.com,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -relay.erybody.com,41.4513,-81.7021 -nostr.na.social,43.6532,-79.3832 -relay.wavefunc.live,41.8781,-87.6298 -nostr.tagomago.me,42.3601,-71.0589 -relay.routstr.com,59.4016,17.9455 -relay.wellorder.net,45.5201,-122.99 -node.kommonzenze.de,49.4521,11.0767 -nostr.pbfs.io,50.4754,12.3683 -fanfares.nostr1.com:443,40.7057,-74.0136 -relay.btcforplebs.com,43.6532,-79.3832 -relay.nostrian-conquest.com,41.223,-111.974 -strfry.shock.network,39.0438,-77.4874 -relay.satmaxt.xyz:443,43.6532,-79.3832 -relay.fckstate.net,59.3293,18.0686 -relayone.geektank.ai,39.1008,-94.5811 -relayrs.notoshi.win:443,43.6532,-79.3832 -wot.nostr.party,36.1659,-86.7844 -testnet.samt.st,43.6532,-79.3832 -nostr.stakey.net:443,52.3676,4.90414 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.snort.social,53.3498,-6.26031 -social.amanah.eblessing.co,48.1046,11.6002 -relay.veganostr.com:443,60.1699,24.9384 -relay2.angor.io:443,48.1046,11.6002 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.ps1829.com,33.8851,130.883 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -wot.shaving.kiwi,43.6532,-79.3832 -relay.typedcypher.com:443,51.5072,-0.127586 -relay.openfarmtools.org,60.1699,24.9384 -wot.brightbolt.net,47.6735,-116.781 -relay.libernet.app,43.6532,-79.3832 -slick.mjex.me,39.0418,-77.4744 -relay.nostr.net,43.6532,-79.3832 -nostr.chaima.info,50.1109,8.68213 -relay.wavlake.com:443,41.2619,-95.8608 -relay.cosmicbolt.net,37.3986,-121.964 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.nostr.place:443,43.6532,-79.3832 -testnet-relay.samt.st,40.8302,-74.1299 -testnet-relay.samt.st:443,40.8302,-74.1299 -relay.islandbitcoin.com,12.8498,77.6545 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -relay.ohstr.com:443,43.6532,-79.3832 -nostr.88mph.life,52.1941,-2.21905 -relay.layer.systems:443,49.0291,8.35695 -schnorr.me:443,43.6532,-79.3832 -nostr-relay.psfoundation.info:443,39.0438,-77.4874 -nostr.snowbla.de:443,60.1699,24.9384 -relay.olas.app,60.1699,24.9384 -espelho.girino.org,43.6532,-79.3832 -nostr.unkn0wn.world,46.8499,9.53287 -relayone.geektank.ai:443,39.1008,-94.5811 -nostr.easycryptosend.it,43.6532,-79.3832 -thecitadel.nostr1.com,40.7057,-74.0136 -relay.gulugulu.moe,43.6532,-79.3832 -relay.lab.rytswd.com:443,49.4543,11.0746 -nos.lol,50.4754,12.3683 -relay.plebeian.market:443,50.1109,8.68213 -relay.getsafebox.app:443,43.6532,-79.3832 -relay.mitchelltribe.com,39.0438,-77.4874 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.trotters.cc,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -relay.ditto.pub:443,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -nostr.2b9t.xyz,34.0549,-118.243 -herbstmeister.com,34.0549,-118.243 -dev.relay.edufeed.org:443,49.4521,11.0767 -relay2.veganostr.com,60.1699,24.9384 -nostr.purpura.cloud,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -relay.paulstephenborile.com,49.4543,11.0746 -relay.0xchat.com,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -nostrcity-club.fly.dev:443,37.7648,-122.432 -relay.endfiat.money:443,59.3327,18.0656 -relay.samt.st,40.8302,-74.1299 -relay.mostr.pub,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -nostrride.io,37.3986,-121.964 -mostro-p2p.tech,50.1109,8.68213 -relay.toastr.net,40.8054,-74.0241 -reraw.pbla2fish.cc,43.6532,-79.3832 -nostr.self-determined.de,53.495,10.2542 -temp.iris.to,43.6532,-79.3832 -relay.openresist.com:443,43.6532,-79.3832 -relay.mostro.network:443,40.8302,-74.1299 -srtrelay.c-stellar.net,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.chaima.info:443,50.1109,8.68213 -dev.relay.edufeed.org,49.4521,11.0767 -nostr.computingcache.com:443,34.0356,-118.442 -relay.sigit.io,50.4754,12.3683 -nostr.oxtr.dev:443,50.4754,12.3683 -relay.angor.io,48.1046,11.6002 -kotukonostr.onrender.com,37.7775,-122.397 -zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832 -shu01.shugur.net,21.4902,39.2246 -relayone.soundhsa.com:443,39.1008,-94.5811 -relay2.angor.io,48.1046,11.6002 -nittom.nostr1.com,40.7057,-74.0136 -nostr.girino.org:443,43.6532,-79.3832 -nexus.libernet.app,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -nostr2.girino.org,43.6532,-79.3832 -relaisnostr.trivaco.fr,49.4282,10.9796 -relay.edufeed.org:443,49.4521,11.0767 -relay.binaryrobot.com,43.6532,-79.3832 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -relay.endfiat.money,59.3327,18.0656 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 -nostr.liberty.fans,36.9104,-89.5875 -relay.mmwaves.de,48.8575,2.35138 -relay.inforsupports.com,43.6532,-79.3832 -dev-nostr.bityacht.io,43.6532,-79.3832 -yabu.me,35.6092,139.73 -relay.0xchat.com:443,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -relay.mrmave.work,43.6532,-79.3832 -nostr.carroarmato0.be,50.914,3.21378 -nostr.myshosholoza.co.za,52.3913,4.66545 -nostr.thebiglake.org,32.71,-96.6745 -relay.kalcafe.xyz,37.3986,-121.964 -nostr.openhoofd.nl,51.5717,3.70417 -relayone.soundhsa.com,39.1008,-94.5811 -relay.bullishbounty.com,43.6532,-79.3832 -satsage.xyz,37.3986,-121.964 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -bridge.tagomago.me,42.3601,-71.0589 -relay.sigit.io:443,50.4754,12.3683 -relay.mypathtofire.de,42.8864,-78.8784 -relay.dreamith.to,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -relay.lanacoin-eternity.com,40.8302,-74.1299 -nostr.2b9t.xyz:443,34.0549,-118.243 -nostr.notribe.net,40.8302,-74.1299 -relay.nostrdice.com,-33.8688,151.209 -nostr-relay.amethyst.name,39.0067,-77.4291 -relay.mccormick.cx:443,52.3563,4.95714 -nostr.4rs.nl,49.0291,8.35696 -relay.dwadziesciajeden.pl,52.2297,21.0122 -spookstr2.nostr1.com:443,40.7057,-74.0136 -relay.vrtmrz.net:443,43.6532,-79.3832 -relay.minibolt.info:443,43.6532,-79.3832 -nostrelites.org,41.8781,-87.6298 -relay.nostar.org,43.6532,-79.3832 -nostriches.club,43.6532,-79.3832 -rele.speyhard.fi,51.5072,-0.127586 -wot.utxo.one,43.6532,-79.3832 -relay1.orangesync.tech,44.7839,-106.941 -damus.bostr.online,43.6532,-79.3832 -relay.islandbitcoin.com:443,12.8498,77.6545 -relay.mulatta.io,37.5665,126.978 -strfry.openhoofd.nl,51.5717,3.70417 -cs-relay.nostrdev.com,50.4754,12.3683 -nostr.bitcoiner.social,47.6743,-117.112 -relay.thecryptosquid.com,50.4754,12.3683 -nostr.computingcache.com,34.0356,-118.442 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay5.bitransfer.org,43.6532,-79.3832 -relay.damus.io,43.6532,-79.3832 -strfry.ymir.cloud,43.6532,-79.3832 -dev.relay.stream,43.6532,-79.3832 -relay.vrtmrz.net,43.6532,-79.3832 -myvoiceourstory.org,37.3598,-121.981 -strfry.openhoofd.nl:443,51.5717,3.70417 -spookstr2.nostr1.com,40.7057,-74.0136 -bitcoiner.social:443,47.6743,-117.112 -aeon.libretechsystems.xyz,55.486,9.86577 -relayrs.notoshi.win,43.6532,-79.3832 -nostr.pbfs.io:443,50.4754,12.3683 -public.crostr.com,43.6532,-79.3832 -nostr.islandarea.net,35.4669,-97.6473 -rilo.nostria.app:443,43.6532,-79.3832 -blossom.gnostr.cloud,43.6532,-79.3832 -relay.aarpia.com,37.3986,-121.964 -relay.arx-ccn.com,50.4754,12.3683 -conduitl2.fly.dev,37.7648,-122.432 -relay.nostrmap.net,60.1699,24.9384 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -nostrelay.circum.space,52.6907,4.8181 -syb.lol:443,43.6532,-79.3832 -relay.artx.market,43.6548,-79.3885 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -blossom.gnostr.cloud:443,43.6532,-79.3832 -x.kojira.io:443,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -relay-rpi.edufeed.org,49.4521,11.0767 -testr.nymble.world,40.8054,-74.0241 -relay.beginningend.com:443,35.2227,-97.4786 -relay.openresist.com,43.6532,-79.3832 -relay.minibolt.info,43.6532,-79.3832 -nostr.thalheim.io,60.1699,24.9384 -relay.notoshi.win:443,13.3622,100.983 -vault.iris.to:443,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -nostrbtc.com,43.6532,-79.3832 -relay.nearhood.co.uk,51.5072,-0.127586 -nostr.overmind.lol,43.6532,-79.3832 -relay.agorist.space,52.3734,4.89406 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -relay.artx.market:443,43.6548,-79.3885 -nos.lol:443,50.4754,12.3683 -relay.illuminodes.com,43.6532,-79.3832 -relay.laantungir.net,-19.4692,-42.5315 -relay.plebchain.club,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostrja-kari.heguro.com,43.6532,-79.3832 -nostrsgp.notribe.net,1.32123,103.695 -nostr.thalheim.io:443,60.1699,24.9384 -prl.plus,55.7628,37.5983 -nostr.carroarmato0.be:443,50.914,3.21378 -nostr.nodesmap.com,59.3327,18.0656 -bucket.coracle.social,37.7775,-122.397 -relay.lightning.pub,39.0438,-77.4874 -relay.dreamith.to:443,43.6532,-79.3832 -nostr.na.social:443,43.6532,-79.3832 -nostr.debate.report,50.1109,8.68213 -relay2.orangesync.tech,40.7128,-74.006 -relay.comcomponent.com,43.6532,-79.3832 -nostr-check.me,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -nostr.21crypto.ch,47.5356,8.73209 -relay.sincensura.org,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5022,-48.7114 -antiprimal.net,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.nostr.place,43.6532,-79.3832 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -nostr.wecsats.io:443,43.6532,-79.3832 -cs-relay.nostrdev.com:443,50.4754,12.3683 -nostr.azzamo.net,52.2633,21.0283 -relay.cypherflow.ai:443,48.8575,2.35138 -nostr.hifish.org,47.4244,8.57658 -relay.wavefunc.live:443,41.8781,-87.6298 -nostr.tac.lol:443,47.4748,-122.273 -wot.dergigi.com,64.1476,-21.9392 -nostr.wecsats.io,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.sathoarder.com:443,48.5734,7.75211 -cache.trustr.ing,43.6548,-79.3885 -relay.directsponsor.net,42.8864,-78.8784 -strfry.bonsai.com:443,39.0438,-77.4874 -nostr-01.yakihonne.com,1.32123,103.695 -relay.satlantis.io,40.8054,-74.0241 -nostr-2.21crypto.ch:443,47.5356,8.73209 -offchain.bostr.online,43.6532,-79.3832 -test.thedude.cloud,50.1109,8.68213 -relay.mostr.pub:443,43.6532,-79.3832 -relay.libernet.app:443,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.plebeian.market,50.1109,8.68213 -wot.makenomistakes.ca,43.7064,-79.3986 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -ithurtswhenip.ee,51.5072,-0.127586 -relay.staging.commonshub.brussels,49.4543,11.0746 -relay.beginningend.com,35.2227,-97.4786 -strfry.bonsai.com,39.0438,-77.4874 -nostrelay.circum.space:443,52.6907,4.8181 -relay.mostro.network,40.8302,-74.1299 -dev-relay.nostreon.com,60.1699,24.9384 -relay.fundstr.me,42.3601,-71.0589 -relay.mmwaves.de:443,48.8575,2.35138 -relay.nostu.be,40.4167,-3.70329 -relay.nostx.io,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.lightning.pub:443,39.0438,-77.4874 -relay.nostr-check.me,43.6532,-79.3832 -nostr.bitcoiner.social:443,47.6743,-117.112 -ribo.nostria.app,43.6532,-79.3832 -offchain.pub,39.1585,-94.5728 -relay.layer.systems,49.0291,8.35695 -nostr.dlcdevkit.com,40.0992,-83.1141 -relay.angor.io:443,48.1046,11.6002 -relay.mwaters.net,50.9871,2.12554 -relay.nostriot.com:443,41.5695,-83.9786 -nostrcity-club.fly.dev,37.7648,-122.432 -nostr.notribe.net:443,40.8302,-74.1299 -freelay.sovbit.host,60.1699,24.9384 -syb.lol,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -wot.codingarena.top,50.4754,12.3683 -nostr-2.21crypto.ch,47.5356,8.73209 -nostr.hifish.org:443,47.4244,8.57658 -relay.zone667.com:443,60.1699,24.9384 relay.lab.rytswd.com,49.4543,11.0746 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -relay.primal.net,43.6532,-79.3832 -relay.bitmacro.cloud,43.6532,-79.3832 -chat-relay.zap-work.com:443,43.6532,-79.3832 -relay.fountain.fm:443,43.6532,-79.3832 -ribo.eu.nostria.app,43.6532,-79.3832 -nexus.libernet.app:443,43.6532,-79.3832 -nostr.sathoarder.com,48.5734,7.75211 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr.snowbla.de,60.1699,24.9384 -strfry.apps3.slidestr.net,40.4167,-3.70329 -nostr.spicyz.io:443,43.6532,-79.3832 -nostr.data.haus:443,50.4754,12.3683 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -purplerelay.com:443,43.6532,-79.3832 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.nostrmap.net:443,60.1699,24.9384 -relay.ohstr.com,43.6532,-79.3832 -r.0kb.io:443,32.789,-96.7989 -uncouth-panning-paternal.ngrok-free.dev,40.0992,-83.1141 -wot.sudocarlos.com,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -ribo.us.nostria.app:443,43.6532,-79.3832 -no.str.cr,10.6352,-85.4378 -relay.notoshi.win,13.3622,100.983 -nostr.spicyz.io,43.6532,-79.3832 -relay.veganostr.com,60.1699,24.9384 -r.0kb.io,32.789,-96.7989 -relay-testnet.k8s.layer3.news:443,37.3387,-121.885 -relay.cosmicbolt.net:443,37.3986,-121.964 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.sharegap.net,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -nostr2.girino.org:443,43.6532,-79.3832 -relay.cypherflow.ai,48.8575,2.35138 -nostr-relay.corb.net,38.8353,-104.822 -relay.staging.plebeian.market,51.5072,-0.127586 -relay.lanavault.space,60.1699,24.9384 -relay.wisp.talk,49.4543,11.0746 -relay.wisp.talk:443,49.4543,11.0746 -no.str.cr:443,10.6352,-85.4378 -nostr.hekster.org:443,37.3986,-121.964 -nostr-01.yakihonne.com:443,1.32123,103.695 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -relay.guggero.org,46.5971,9.59652 -nostr.twinkle.lol,51.902,7.6657 -nostr.janx.com,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -relay.nostriot.com,41.5695,-83.9786 -nostr.hekster.org,37.3986,-121.964 -fanfares.nostr1.com,40.7057,-74.0136 +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 -relay.olas.app:443,60.1699,24.9384 -armada.sharegap.net,43.6532,-79.3832 -nostr.islandarea.net:443,35.4669,-97.6473 -relay0.gfcom.info,13.8434,100.363 -relay.chorus.community,48.5333,10.7 -nittom.nostr1.com:443,40.7057,-74.0136 -relay.staging.plebeian.market:443,51.5072,-0.127586 -chat-relay.zap-work.com,43.6532,-79.3832 -relay-rpi.edufeed.org:443,49.4521,11.0767 -relay.lanavault.space:443,60.1699,24.9384 -relay.binaryrobot.com:443,43.6532,-79.3832 -nostr.mom:443,50.4754,12.3683 -relay.bornheimer.app,51.5072,-0.127586 +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 -nostr-dev.wellorder.net,45.5201,-122.99 -public.crostr.com:443,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -articles.layer3.news:443,37.3387,-121.885 -nostr.azzamo.net:443,52.2633,21.0283 +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 +nostr.2b9t.xyz,34.0549,-118.243 +nostr.data.haus:443,50.4754,12.3683 +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 +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 +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 +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.tagomago.me,42.3601,-71.0589 +relay.0xchat.com:443,43.6532,-79.3832 From 13c771aa2947b2bef52bcca82a49ecafb8854d97 Mon Sep 17 00:00:00 2001 From: David Carrington <32391650+dmcarrington@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:56:29 +0100 Subject: [PATCH 17/32] Pause Nostr geohash firehose in background to cut mobile data Closes: #700 (#706) * Pause Nostr geohash firehose in background to cut mobile data The Bluetooth mesh uses no mobile data, but the Nostr relay layer ran unbounded in the background: the live geohash channel subscription kept streaming across 5 relays, the presence heartbeat broadcast every ~60s, and a participant-refresh timer polled every 30s. Combined with reconnect/re-subscribe replay on public relays, this could burn gigabytes while idle. GeohashViewModel now tears down the high-volume subscriptions when the app is backgrounded (onStop) and restores them on foreground (onStart): - drop the live channel message stream (currentGeohashSubId) - drop sampling subscriptions - cancel the presence heartbeat and participant-refresh timer Gift-wrap DM subscriptions are intentionally kept alive in the background since they are filtered to our pubkey (lightweight) so DMs still arrive. The selected channel is tracked in activeChannelGeohash so the stream can be rebuilt on foreground without losing the user's selection. The channel subscription logic is extracted into subscribeChannelStream() / subscribeChannelDM() helpers. Co-Authored-By: Claude Opus 4.8 * separate the heartbeat firehose from message delivery * correct comment --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- .../com/bitchat/android/nostr/NostrFilter.kt | 27 ++++ .../android/nostr/NostrSubscriptionManager.kt | 13 +- .../bitchat/android/ui/GeohashViewModel.kt | 129 ++++++++++++++---- 3 files changed, 140 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index 247162a0..df67822f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -42,6 +42,33 @@ data class NostrFilter( limit = limit ) } + + /** + * Create filter for geohash-scoped chat messages only (kind 20000). + * Low-volume; kept subscribed in the background so messages keep arriving. + */ + fun geohashMessages(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for geohash-scoped presence heartbeats only (kind 20001). + * High-volume firehose (every participant rebroadcasts ~every 60s); only used + * to refresh the participant list, so it is paused while backgrounded. + */ + fun geohashPresence(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.GEOHASH_PRESENCE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } /** * Create filter for text notes from specific authors diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 54e6a5a7..1a8514ab 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -27,9 +27,18 @@ class NostrSubscriptionManager( } } - fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ + fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { - val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + } + } + + /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ + fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 09b32d4f..b24ddc0e 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -64,13 +64,19 @@ class GeohashViewModel( dataManager = dataManager ) - private var currentGeohashSubId: String? = null + // Live channel message stream (kind 20000). Low-volume; kept alive in the background. + private var currentGeohashMsgSubId: String? = null + // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. + private var currentGeohashPresenceSubId: String? = null private var currentDmSubId: String? = null private var geoTimer: Job? = null private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private val activeSamplingGeohashes = mutableSetOf() + // Geohash of the currently selected Location channel (null for Mesh/none). + private var activeChannelGeohash: String? = null + val geohashPeople: StateFlow> = state.geohashPeople val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts val selectedLocationChannel: StateFlow = state.selectedLocationChannel @@ -163,8 +169,10 @@ class GeohashViewModel( GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() subscriptionManager.disconnect() - currentGeohashSubId = null + currentGeohashMsgSubId = null + currentGeohashPresenceSubId = null currentDmSubId = null + activeChannelGeohash = null geoTimer?.cancel() geoTimer = null globalPresenceJob?.cancel() @@ -342,12 +350,14 @@ class GeohashViewModel( private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null - currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null } + currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null } + currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { Log.d(TAG, "📡 Switched to mesh channel") + activeChannelGeohash = null repo.setCurrentGeohash(null) notificationManager.setCurrentGeohash(null) notificationManager.clearMeshMentionNotifications() @@ -355,6 +365,7 @@ class GeohashViewModel( } is com.bitchat.android.geohash.ChannelID.Location -> { Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") + activeChannelGeohash = channel.channel.geohash repo.setCurrentGeohash(channel.channel.geohash) notificationManager.setCurrentGeohash(channel.channel.geohash) notificationManager.clearNotificationsForGeohash(channel.channel.geohash) @@ -368,28 +379,18 @@ class GeohashViewModel( } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } startGeoParticipantsTimer() - - viewModelScope.launch { - val geohash = channel.channel.geohash - val subId = "geohash-$geohash"; currentGeohashSubId = subId - subscriptionManager.subscribeGeohash( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 3600000L, - limit = 200, - id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) - val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId - subscriptionManager.subscribeGiftWraps( - pubkey = dmIdentity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = dmSubId, - handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } - ) - // Also register alias in global registry for routing convenience - GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) + + // Chat message stream (kind 20000) is low-volume; keep it alive even when + // backgrounded so geohash messages still arrive. + subscribeChannelMessages(channel.channel.geohash) + // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only + // run it in the foreground. It is restored in onStart() and torn down in onStop(). + if (isAppInForeground()) { + subscribeChannelPresence(channel.channel.geohash) } + // Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is + // kept alive in the background so geohash DMs still arrive. + subscribeChannelDM(channel.channel.geohash) } null -> { Log.d(TAG, "📡 No channel selected") @@ -399,6 +400,56 @@ class GeohashViewModel( } } + /** + * Subscribe to the chat message stream (kind 20000) for a geohash channel. + * Low-volume; kept alive in the background so messages keep arriving. + */ + private fun subscribeChannelMessages(geohash: String) { + val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId + subscriptionManager.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 200, + id = subId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + + /** + * Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel. + * High-volume; only used to refresh the participant list, so it is torn down in + * onStop() and restored in onStart() to cut background mobile data. + */ + private fun subscribeChannelPresence(geohash: String) { + val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 200, + id = subId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + + /** + * Subscribe to gift-wrap DMs for a geohash channel's derived identity. + * Lightweight (filtered to our pubkey); kept alive in the background. + */ + private fun subscribeChannelDM(geohash: String) { + viewModelScope.launch { + val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId + subscriptionManager.subscribeGiftWraps( + pubkey = dmIdentity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = dmSubId, + handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } + ) + // Also register alias in global registry for routing convenience + GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) + } + } + private fun startGeoParticipantsTimer() { geoTimer = viewModelScope.launch { while (repo.getCurrentGeohash() != null) { @@ -416,17 +467,41 @@ class GeohashViewModel( } override fun onStart(owner: LifecycleOwner) { - Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes") + Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming") + // Restore the presence heartbeat firehose for the selected geohash channel. + // (The chat message stream is kept alive in the background, so it is not restored here.) + activeChannelGeohash?.let { subscribeChannelPresence(it) } + // Resume geohash sampling subscriptions activeSamplingGeohashes.forEach { performSubscribeSampling(it) } + // Resume the participant-refresh polling timer if a geohash is selected + if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) { + startGeoParticipantsTimer() + } + // Resume the global presence heartbeat + if (globalPresenceJob?.isActive != true) { + startGlobalPresenceHeartbeat() + } } override fun onStop(owner: LifecycleOwner) { - Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes") + Log.d(TAG, "🌍 App backgrounded: pausing geohash presence firehose (keeping message + DM subscriptions)") + // Drop the high-volume presence heartbeat firehose (kind 20001). + // The chat message stream (kind 20000) is intentionally left active so messages still arrive. + currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } + // Drop geohash sampling subscriptions activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } + // Stop broadcasting presence heartbeats + globalPresenceJob?.cancel(); globalPresenceJob = null + // Stop participant-refresh polling + geoTimer?.cancel(); geoTimer = null + // NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally + // left active so direct messages still arrive while backgrounded. } private fun performSubscribeSampling(geohash: String) { - subscriptionManager.subscribeGeohash( + // Sampling only needs participant counts, never message bodies, so it subscribes to + // presence heartbeats only (kind 20001) to keep the payload small. + subscriptionManager.subscribeGeohashPresence( geohash = geohash, sinceMs = System.currentTimeMillis() - 86400000L, limit = 200, From 648ae6c1dd73c2a2b8fb6d108b43f12f2c9e2b65 Mon Sep 17 00:00:00 2001 From: bladexian94-code Date: Thu, 25 Jun 2026 21:57:17 +0200 Subject: [PATCH 18/32] Harden GeohashPickerActivity WebView security by blocking mixed content and restricting navigation to local asset only on experimental security branch. (#698) --- .../java/com/bitchat/android/ui/GeohashPickerActivity.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index b69926d0..e15f9930 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -10,6 +10,7 @@ import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebSettings import android.webkit.WebView +import android.webkit.WebResourceRequest import android.webkit.WebViewClient import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* @@ -104,10 +105,16 @@ class GeohashPickerActivity : OrientationAwareActivity() { settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW settings.allowFileAccess = true settings.allowContentAccess = true webChromeClient = WebChromeClient() webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url?.toString() ?: return true + // Block navigation away from the local geohash picker asset + return !url.startsWith("file:///android_asset/geohash_picker.html") + } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) // Initialize to last/initial geohash if provided, otherwise center From 34a6f37041b25dfee93d0691c9ebcc6ff26fca9d Mon Sep 17 00:00:00 2001 From: 5t34k <261338165+5t34k@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:04:23 +1000 Subject: [PATCH 19/32] Fix hashtags incorrectly displayed inside URLs (#692) When a URL contains a # fragment (e.g. https://example.com/page#section), the hashtag regex matches the fragment as a hashtag. The overlap removal logic already removes hashtags overlapping geohashes, and geohashes overlapping URLs, but did not remove hashtags overlapping URLs. Add the missing overlapsUrl check to the hashtag condition. --- app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index e3097eed..6dd02584 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -398,10 +398,10 @@ private fun appendIOSFormattedContent( val iterator = allMatches.listIterator() while (iterator.hasNext()) { val (range, type) = iterator.next() - // Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs + // Remove generic hashtags that overlap with geohashes or URLs, and geohashes that overlap with URLs val overlapsGeo = geoRanges.any { rangesOverlap(range, it) } val overlapsUrl = urlRanges.any { rangesOverlap(range, it) } - if ((type == "hashtag" && overlapsGeo) || (type == "geohash" && overlapsUrl)) iterator.remove() + if ((type == "hashtag" && (overlapsGeo || overlapsUrl)) || (type == "geohash" && overlapsUrl)) iterator.remove() } } From 37a20ebf34f86d11b33f5f0727407e6d45e8a8fb Mon Sep 17 00:00:00 2001 From: AleksPlekhov Date: Thu, 25 Jun 2026 16:06:05 -0400 Subject: [PATCH 20/32] [ticket 640] added nickname to note (#687) * [ticket 640] added nickname to note * [640] added check for empty value --- .../bitchat/android/ui/LocationNotesSheet.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 8eea4db7..29ead6a6 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -202,6 +202,7 @@ fun LocationNotesSheet( onDraftChange = { draft = it }, sendButtonEnabled = sendButtonEnabled, accentGreen = accentGreen, + nickname = nickname, onSend = { val content = draft.trim() if (content.isNotEmpty()) { @@ -451,19 +452,38 @@ private fun LocationNotesInputSection( onDraftChange: (String) -> Unit, sendButtonEnabled: Boolean, accentGreen: Color, + nickname: String?, onSend: () -> Unit ) { val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme - - Row( + + Column( modifier = Modifier .fillMaxWidth() .background(color = colorScheme.background) - .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing + .padding(horizontal = 12.dp, vertical = 8.dp) ) { + if (!nickname.isNullOrBlank()) { + val baseName = nickname.split("#", limit = 2).firstOrNull() ?: nickname + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "@$baseName", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Spacer(modifier = Modifier.height(4.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { // Text input with placeholder overlay (matches main chat exactly) Box( modifier = Modifier.weight(1f) @@ -532,6 +552,7 @@ private fun LocationNotesInputSection( } } } + } } /** From 8607ce84ba196ae0cb0f519a92f592a397b5f5d1 Mon Sep 17 00:00:00 2001 From: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:06:46 +0200 Subject: [PATCH 21/32] Fix geohash block case sensitivity issue (#645) Co-authored-by: a1denvalu3 <> --- .../android/nostr/GeohashMessageHandler.kt | 21 +++++++++++-------- .../nostr/NostrDirectMessageHandler.kt | 3 ++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index ec3af4e6..b318ef69 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -59,22 +59,25 @@ class GeohashMessageHandler( } } + // Normalize pubkey to lowercase for consistent blocking and storage + val pubkey = event.pubkey.lowercase() + // Blocked users check (use injected DataManager which has loaded state) - if (dataManager.isGeohashUserBlocked(event.pubkey)) return@launch + if (dataManager.isGeohashUserBlocked(pubkey)) return@launch // Update repository (participants, nickname, teleport) // Update repository on a background-safe path; repository will post updates to LiveData // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { - repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L)) + repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L)) } - event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) } - event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) } + event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(pubkey, it[1]) } + event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(pubkey) } // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr try { - com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey) + com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${pubkey.take(16)}", pubkey) } catch (_: Exception) { } // Stop here for presence events - they don't produce chat messages @@ -82,13 +85,13 @@ class GeohashMessageHandler( // Skip our own events for message emission val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) - if (my.publicKeyHex.equals(event.pubkey, true)) return@launch + if (my.publicKeyHex.equals(pubkey, true)) return@launch val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && event.content.trim().isEmpty() if (isTeleportPresence) return@launch - val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey) + val senderName = repo.displayNameForNostrPubkeyUI(pubkey) val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false } val msg = BitchatMessage( id = event.id, @@ -96,8 +99,8 @@ class GeohashMessageHandler( content = event.content, timestamp = Date(event.createdAt * 1000L), isRelay = false, - originalSender = repo.displayNameForNostrPubkey(event.pubkey), - senderPeerID = "nostr:${event.pubkey.take(8)}", + originalSender = repo.displayNameForNostrPubkey(pubkey), + senderPeerID = "nostr:${pubkey.take(8)}", mentions = null, channel = "#$subscribedGeohash", powDifficulty = try { 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 18c12e4d..b2526810 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -62,7 +62,8 @@ class NostrDirectMessageHandler( return@launch } - val (content, senderPubkey, rumorTimestamp) = decryptResult + val (content, rawSenderPubkey, rumorTimestamp) = decryptResult + val senderPubkey = rawSenderPubkey.lowercase() // If sender is blocked for geohash contexts, drop any events from this pubkey // Applies to both geohash DMs (geohash != "") and account DMs (geohash == "") From f0d312827eb6c30efc756ddcbcabfc014284238f Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:07:09 +0300 Subject: [PATCH 22/32] feat: Disable system screenshots in recents screen (#608) This change prevents the app's content from being visible in the recents screen on Android 13 (Tiramisu) and newer devices. It achieves this by calling `setRecentsScreenshotEnabled(false)` in the `onCreate` method of `MainActivity` after checking the device's SDK version. This enhances user privacy by hiding potentially sensitive information from the system's app overview. --- app/src/main/java/com/bitchat/android/MainActivity.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2345549e..ce3beb24 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -1,6 +1,7 @@ package com.bitchat.android import android.content.Intent +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.OnBackPressedCallback @@ -80,7 +81,9 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.setRecentsScreenshotEnabled(false) + } // Register receiver for force finish signal from shutdown coordinator val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) if (android.os.Build.VERSION.SDK_INT >= 33) { From 793b21545776a2e70809fbf7ff7355a53f9e9684 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:07:59 +0300 Subject: [PATCH 23/32] fix: geohash online count accuracy and channel switch latency (#672) - Keep max lastSeen per user so relay's newest-first delivery doesn't overwrite a fresh timestamp with a stale one - Serialize event processing on Main dispatcher to eliminate ConcurrentModificationException on geohashParticipants maps - Immediately refresh people list on channel switch so sampling data is reflected before the first relay response arrives --- .../bitchat/android/nostr/GeohashMessageHandler.kt | 9 ++------- .../com/bitchat/android/nostr/GeohashRepository.kt | 11 ++++++++++- .../java/com/bitchat/android/ui/GeohashViewModel.kt | 1 + 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index b318ef69..1c4896f3 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -6,9 +6,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.MessageManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.Date /** @@ -44,7 +42,7 @@ class GeohashMessageHandler( } fun onEvent(event: NostrEvent, subscribedGeohash: String) { - scope.launch(Dispatchers.Default) { + scope.launch { try { if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) @@ -65,9 +63,6 @@ class GeohashMessageHandler( // Blocked users check (use injected DataManager which has loaded state) if (dataManager.isGeohashUserBlocked(pubkey)) return@launch - // Update repository (participants, nickname, teleport) - // Update repository on a background-safe path; repository will post updates to LiveData - // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L)) @@ -107,7 +102,7 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) - withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) } + messageManager.addChannelMessage("geo:$subscribedGeohash", msg) } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index f6fbca78..3a7ced32 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -103,7 +103,16 @@ class GeohashRepository( fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } - participants[participantId] = lastSeen + // Cap to now: prevents future-timestamped events (clock skew / malicious created_at) + // from pinning lastSeen and blocking subsequent normal heartbeats. + // Also keeps max: relays send events newest-first, so subsequent older events for + // the same user must not overwrite a fresher lastSeen. + val now = Date() + val effective = if (lastSeen.after(now)) now else lastSeen + val existing = participants[participantId] + if (existing == null || effective.after(existing)) { + participants[participantId] = effective + } if (currentGeohash == geohash) refreshGeohashPeople() updateReactiveParticipantCounts() } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index b24ddc0e..0504aa11 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -367,6 +367,7 @@ class GeohashViewModel( Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") activeChannelGeohash = channel.channel.geohash repo.setCurrentGeohash(channel.channel.geohash) + repo.refreshGeohashPeople() notificationManager.setCurrentGeohash(channel.channel.geohash) notificationManager.clearNotificationsForGeohash(channel.channel.geohash) try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { } From c26460ae02359b212a351766bc6d8a1bb302b8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hamza=20=C3=96zt=C3=BCrk?= Date: Thu, 25 Jun 2026 23:45:03 +0300 Subject: [PATCH 24/32] feat: Implement iOS-compatible selective padding for BLE messages (#501) * feat: Implement iOS-compatible selective padding for BLE messages * regression tests for padding --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- .../android/mesh/BLEPacketPaddingPolicy.kt | 18 +++ .../mesh/BluetoothPacketBroadcaster.kt | 8 +- .../android/protocol/BinaryProtocol.kt | 16 ++- .../mesh/BLEPacketPaddingPolicyTest.kt | 133 ++++++++++++++++++ .../android/protocol/BinaryProtocolTest.kt | 58 ++++++++ 5 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt create mode 100644 app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt new file mode 100644 index 00000000..279fe527 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.MessageType + +/** + * iOS-compatible BLE padding policy. + * + * Keep this aligned with iOS BLEOutboundPacketPolicy.padsBLEFrame(for:): + * only Noise frames are padded over BLE. + */ +object BLEPacketPaddingPolicy { + fun shouldPadForBLE(type: UByte): Boolean { + return when (MessageType.fromValue(type)) { + MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true + else -> false + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index efb3c4df..3c49315e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -170,7 +170,9 @@ class BluetoothPacketBroadcaster( characteristic: BluetoothGattCharacteristic? ): Boolean { val packet = routed.packet - val data = packet.toBinaryData() ?: return false + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return false val isFile = packet.type == MessageType.FILE_TRANSFER.value if (isFile) { Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") @@ -261,7 +263,9 @@ class BluetoothPacketBroadcaster( characteristic: BluetoothGattCharacteristic? ) { val packet = routed.packet - val data = packet.toBinaryData() ?: return + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 88e5f783..a952d5fa 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -79,8 +79,8 @@ data class BitchatPacket( ttl = ttl ) - fun toBinaryData(): ByteArray? { - return BinaryProtocol.encode(this) + fun toBinaryData(padding: Boolean = true): ByteArray? { + return BinaryProtocol.encode(this, padding = padding) } /** @@ -198,7 +198,7 @@ object BinaryProtocol { } } - fun encode(packet: BitchatPacket): ByteArray? { + fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? { try { // Try to compress payload if beneficial var payload = packet.payload @@ -310,11 +310,13 @@ object BinaryProtocol { buffer.rewind() buffer.get(result) - // Apply padding to standard block sizes for traffic analysis resistance - val optimalSize = MessagePadding.optimalBlockSize(result.size) - val paddedData = MessagePadding.pad(result, optimalSize) + // Apply padding if requested (iOS-compatible: selective padding for privacy) + if (padding) { + val optimalSize = MessagePadding.optimalBlockSize(result.size) + return MessagePadding.pad(result, optimalSize) + } - return paddedData + return result } catch (e: Exception) { Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}") diff --git a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt new file mode 100644 index 00000000..96aba61c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessagePadding +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BLEPacketPaddingPolicyTest { + private val senderID = byteArrayOf(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x00) + private val timestamp = 1_709_600_000_000uL + + @Test + fun `BLE padding policy matches iOS packet type table`() { + val expected = mapOf( + MessageType.ANNOUNCE to false, + MessageType.MESSAGE to false, + MessageType.LEAVE to false, + MessageType.REQUEST_SYNC to false, + MessageType.FRAGMENT to false, + MessageType.FILE_TRANSFER to false, + MessageType.NOISE_ENCRYPTED to true, + MessageType.NOISE_HANDSHAKE to true + ) + + expected.forEach { (type, shouldPad) -> + assertEquals( + "${type.name} BLE padding policy must match iOS", + shouldPad, + BLEPacketPaddingPolicy.shouldPadForBLE(type.value) + ) + } + + assertFalse( + "Unknown packet types should be sent unpadded, matching iOS default", + BLEPacketPaddingPolicy.shouldPadForBLE(0x7Fu) + ) + } + + @Test + fun `public BLE packet types are encoded without PKCS7 padding tails`() { + val publicTypes = listOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.LEAVE, + MessageType.REQUEST_SYNC, + MessageType.FRAGMENT, + MessageType.FILE_TRANSFER + ) + + publicTypes.forEach { type -> + val packet = packet(type.value, payload = payloadEndingWithoutPaddingShape(type)) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertArrayEquals( + "${type.name} BLE encoding should be the unpadded frame", + raw, + encodedForBLE + ) + assertFalse( + "${type.name} BLE encoding must not leave iOS-visible PKCS#7 tail bytes", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertNotNull( + "${type.name} unpadded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + @Test + fun `Noise BLE packet types remain padded and decodable`() { + val noiseTypes = listOf(MessageType.NOISE_HANDSHAKE, MessageType.NOISE_ENCRYPTED) + + noiseTypes.forEach { type -> + val packet = packet(type.value, payload = "noise-${type.name}".toByteArray()) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertTrue("${type.name} BLE frame should be padded", encodedForBLE.size > raw.size) + assertTrue( + "${type.name} BLE frame should end with valid PKCS#7 padding", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertArrayEquals( + "${type.name} padding should strip back to the raw frame", + raw, + MessagePadding.unpad(encodedForBLE) + ) + assertNotNull( + "${type.name} padded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + private fun packet(type: UByte, payload: ByteArray): BitchatPacket { + val version = if (type == MessageType.FILE_TRANSFER.value) 2u.toUByte() else 1u.toUByte() + return BitchatPacket( + version = version, + type = type, + senderID = senderID, + recipientID = null, + timestamp = timestamp, + payload = payload, + signature = null, + ttl = 7u, + route = null + ) + } + + private fun payloadEndingWithoutPaddingShape(type: MessageType): ByteArray { + return "ios-ble-policy-${type.name}-z".toByteArray() + } + + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } +} diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index 8945088b..c1b2327b 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1,6 +1,8 @@ package com.bitchat.android.protocol import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -368,6 +370,54 @@ class BinaryProtocolTest { medPayload.contentEquals(medDecoded.payload)) } + @Test + fun `encoding with padding false returns exact unpadded frame`() { + val payload = "ios-compatible-unpadded".toByteArray() + val packet = makePacket(payload = payload) + + val unpadded = BinaryProtocol.encode(packet, padding = false)!! + val padded = BinaryProtocol.encode(packet, padding = true)!! + + assertEquals( + "v1 unpadded frame size must be header + sender + payload", + 14 + 8 + payload.size, + unpadded.size + ) + assertEquals("small padded frame should pad to 256 bytes", 256, padded.size) + assertFalse( + "padding=false must not append PKCS#7 bytes", + hasPkcs7PaddingTail(unpadded) + ) + assertArrayEquals( + "padded frame must unpad to the exact raw frame", + unpadded, + MessagePadding.unpad(padded) + ) + assertPacketEquals(packet, BinaryProtocol.decode(unpadded)!!) + assertPacketEquals(packet, BinaryProtocol.decode(padded)!!) + } + + @Test + fun `BitchatPacket toBinaryData propagates padding flag`() { + val packet = makePacket(payload = "packet-helper-padding-flag".toByteArray()) + + val defaultEncoded = packet.toBinaryData()!! + val explicitlyPadded = packet.toBinaryData(padding = true)!! + val unpadded = packet.toBinaryData(padding = false)!! + + assertArrayEquals( + "default helper must remain padded for backward compatibility", + explicitlyPadded, + defaultEncoded + ) + assertTrue("default helper should produce a padded frame", defaultEncoded.size > unpadded.size) + assertArrayEquals( + "helper padding=false must match BinaryProtocol padding=false", + BinaryProtocol.encode(packet, padding = false)!!, + unpadded + ) + } + /** * Oversized packet bypasses padding * @@ -1141,6 +1191,14 @@ class BinaryProtocolTest { return decoded!! } + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } + private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) { assertEquals("version", expected.version, actual.version) assertEquals("type", expected.type, actual.type) From b7f0b33d3a267c770d3d5a65ee2d8c7e755450db Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:48:26 +0200 Subject: [PATCH 25/32] bump to 1.7.5 (#723) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e0b7ed00..02330d6c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 35 - versionName = "1.7.4" + versionCode = 36 + versionName = "1.7.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From c8f45408b1984753a4df7f05aff03ceaa17bbba6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 26 Jul 2026 06:56:54 +0000 Subject: [PATCH 26/32] Automated update of relay data - Sun Jul 26 06:56:54 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 840 ++++++++++++++------------- 1 file changed, 433 insertions(+), 407 deletions(-) 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 From 5a38aaf3e8381c53b15cfedc7d73e413ed7466d6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:06:22 +0200 Subject: [PATCH 27/32] feat: stabilize private chat identity across mesh and Nostr (#745) * feat: stabilize private chat identity across mesh and Nostr Introduce ContactDirectory and ContactIdentityResolver so DMs and favorites use stable contact_* conversation IDs instead of ephemeral mesh peer IDs. * fix: preserve canonical private message routing * test: complete private chat regression fixtures --- .../favorites/FavoriteControlMessage.kt | 33 +++ .../favorites/FavoritesPersistenceService.kt | 116 +++++---- .../android/mesh/BluetoothMeshService.kt | 11 +- .../java/com/bitchat/android/mesh/MeshCore.kt | 7 + .../bitchat/android/mesh/MessageHandler.kt | 27 +-- .../android/mesh/UnifiedMeshService.kt | 3 +- .../nostr/NostrDirectMessageHandler.kt | 104 +++++++- .../android/nostr/NostrEmbeddedBitChat.kt | 52 +--- .../bitchat/android/nostr/NostrIdentity.kt | 17 +- .../bitchat/android/nostr/NostrTransport.kt | 96 +++----- .../bitchat/android/services/AppStateStore.kt | 58 ++++- .../android/services/ContactDirectory.kt | 201 ++++++++++++++++ .../services/ContactIdentityResolver.kt | 83 +++++++ .../services/ConversationAliasResolver.kt | 44 ++-- .../bitchat/android/services/MessageRouter.kt | 141 ++++++----- .../com/bitchat/android/ui/ChatViewModel.kt | 72 +++--- .../bitchat/android/ui/MeshDelegateHandler.kt | 125 ++++------ .../bitchat/android/ui/MeshPeerListSheet.kt | 183 +++++++------- .../com/bitchat/android/ui/MessageManager.kt | 43 ++-- .../bitchat/android/ui/NotificationManager.kt | 33 ++- .../bitchat/android/ui/PrivateChatManager.kt | 224 ++++++++++-------- .../android/services/AppStateStoreTest.kt | 41 ++++ .../android/ui/PrivateChatManagerTest.kt | 107 +++++++++ gradle.properties | 4 +- 24 files changed, 1183 insertions(+), 642 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/favorites/FavoriteControlMessage.kt create mode 100644 app/src/main/java/com/bitchat/android/services/ContactDirectory.kt create mode 100644 app/src/main/java/com/bitchat/android/services/ContactIdentityResolver.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt 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 c6c10473..1480bd61 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -385,6 +385,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Store fingerprint for the peer via centralized fingerprint manager val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + try { + com.bitchat.android.identity.SecureIdentityStateManager(context) + .cachePeerNoiseKey(newPeerID, publicKey.toHexString()) + } catch (_: Exception) { } // Index existing Nostr mapping by the new peerID if we have it try { @@ -952,9 +956,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}") @@ -964,8 +966,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 46518428..0ba1b31b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -288,6 +288,13 @@ class MeshCore( ) { peerManager.addOrUpdatePeer(newPeerID, nickname) val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + try { + com.bitchat.android.identity.SecureIdentityStateManager(context) + .cachePeerNoiseKey(newPeerID, publicKey.toHexString()) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + } + } catch (_: Exception) { } previousPeerID?.let { peerManager.removePeer(it) } Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}") hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID) 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 d6daa547..dbccaabb 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.IdentityAnnouncement @@ -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 /** * Handles processing of different message types @@ -66,7 +66,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") @@ -84,7 +83,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) @@ -102,7 +101,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 @@ -549,24 +548,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 { @@ -576,8 +570,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/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 46d36a8f..1b3e51af 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 b2526810..cf2e038c 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, @@ -195,6 +220,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 2dd82cdb..5d5641d7 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 @@ -205,6 +203,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 -> @@ -220,14 +220,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) @@ -402,17 +402,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) { } } @@ -466,12 +467,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 } @@ -510,21 +510,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) @@ -543,9 +541,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 @@ -609,25 +609,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) @@ -638,7 +636,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) @@ -845,7 +842,8 @@ class ChatViewModel( } fun showPrivateChatSheet(peerID: String) { - state.setPrivateChatSheetPeer(peerID) + val conversationID = ContactDirectory.canonicalConversationId(peerID) + state.setPrivateChatSheetPeer(conversationID) } fun hidePrivateChatSheet() { @@ -934,8 +932,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/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/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 From 84b24e347f7f99577675218fff5722e4da544fce Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:29:57 +0100 Subject: [PATCH 28/32] Bind Noise sessions to claimed peer identities (#730) * Bind Noise sessions to claimed peer identities * Make Wi-Fi peer rebinding atomic * Migrate private media without silent downgrades (#728) * Migrate private media without silent downgrades * Accept prerelease private media payloads * Authenticate private media capability per Noise session * updates --------- Co-authored-by: jack Co-authored-by: jack Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> * Authenticate BLE links before peer binding (#749) * Authenticate BLE links before peer binding * Fix BLE link authentication races --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> * Use canonical Maven repository for Robolectric --------- Co-authored-by: jack Co-authored-by: jack Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> --- app/build.gradle.kts | 9 + .../android/crypto/EncryptionService.kt | 54 +- .../identity/SecureIdentityStateManager.kt | 112 +++- .../mesh/AnnouncementIdentityValidator.kt | 37 ++ .../mesh/AuthenticatedBleLinkPolicy.kt | 14 + .../mesh/AuthenticatedPeerStateCoordinator.kt | 235 +++++++ .../mesh/BluetoothConnectionManager.kt | 47 +- .../mesh/BluetoothConnectionTracker.kt | 62 +- .../mesh/BluetoothGattClientManager.kt | 23 +- .../mesh/BluetoothGattServerManager.kt | 32 +- .../android/mesh/BluetoothMeshService.kt | 469 +++++++++----- .../mesh/BluetoothPacketBroadcaster.kt | 26 +- .../bitchat/android/mesh/FragmentManager.kt | 176 ++--- .../android/mesh/FragmentingPacketSender.kt | 28 +- .../java/com/bitchat/android/mesh/MeshCore.kt | 349 +++++++--- .../com/bitchat/android/mesh/MeshDelegate.kt | 2 + .../com/bitchat/android/mesh/MeshService.kt | 6 + .../com/bitchat/android/mesh/MeshTransport.kt | 10 + .../bitchat/android/mesh/MessageHandler.kt | 128 ++-- .../bitchat/android/mesh/PacketProcessor.kt | 23 +- .../com/bitchat/android/mesh/PeerManager.kt | 108 +++- .../android/mesh/PrivateMediaSecurity.kt | 64 ++ .../android/mesh/PrivateMediaTransfer.kt | 215 +++++++ .../bitchat/android/mesh/SecurityManager.kt | 180 ++++-- .../android/mesh/UnifiedMeshService.kt | 39 ++ .../android/model/AuthenticatedPeerState.kt | 86 +++ .../bitchat/android/model/FragmentPayload.kt | 3 + .../android/model/IdentityAnnouncement.kt | 56 +- .../bitchat/android/model/NoiseEncrypted.kt | 15 +- .../bitchat/android/model/PeerCapabilities.kt | 67 ++ .../com/bitchat/android/model/RoutedPacket.kt | 7 +- .../android/noise/NoiseEncryptionService.kt | 57 +- .../android/noise/NoisePeerIdentity.kt | 30 + .../com/bitchat/android/noise/NoiseSession.kt | 41 +- .../android/noise/NoiseSessionManager.kt | 315 +++++++-- .../nostr/NostrDirectMessageHandler.kt | 3 +- .../android/service/TransportBridgeService.kt | 10 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 28 + .../com/bitchat/android/ui/ChatViewModel.kt | 25 +- .../bitchat/android/ui/MediaSendingManager.kt | 604 +++++++++++++++--- .../AuthenticatedIngressLinkPolicy.kt | 39 ++ .../wifi-aware/WifiAwareConnectionTracker.kt | 80 ++- .../wifi-aware/WifiAwareMeshService.kt | 216 ++++++- app/src/main/res/values/strings.xml | 7 +- .../android/mesh/FragmentManagerTest.kt | 64 ++ app/src/test/kotlin/android/util/Log.kt | 7 +- .../kotlin/com/bitchat/PeerManagerTest.kt | 68 +- ...rivateMediaCapabilityPinPersistenceTest.kt | 73 +++ .../mesh/AuthenticatedBleLinkPolicyTest.kt | 40 ++ .../AuthenticatedPeerStateCoordinatorTest.kt | 299 +++++++++ .../mesh/BluetoothConnectionManagerTest.kt | 45 ++ ...etoothConnectionTrackerLinkIdentityTest.kt | 53 ++ .../android/mesh/MessageHandlerTest.kt | 344 +++++++++- .../PacketProcessorAnnounceSideEffectTest.kt | 143 +++++ .../android/mesh/PrivateMediaSecurityTest.kt | 69 ++ .../mesh/PrivateMediaTransferPreparerTest.kt | 349 ++++++++++ .../android/mesh/SecurityManagerTest.kt | 303 ++++++++- .../model/AuthenticatedPeerStateTest.kt | 68 ++ .../android/model/IdentityAnnouncementTest.kt | 75 +++ .../NoiseSessionManagerIdentityBindingTest.kt | 384 +++++++++++ .../service/TransportBridgeServiceTest.kt | 68 ++ .../ui/MediaSendingManagerMigrationTest.kt | 303 +++++++++ .../AuthenticatedIngressLinkPolicyTest.kt | 93 +++ .../WifiAwareConnectionTrackerTest.kt | 81 +++ docs/ANNOUNCEMENT_GOSSIP.md | 22 +- docs/NOISE_PEER_ID_BINDING.md | 62 ++ docs/PRIVATE_MEDIA_V1.md | 168 +++++ docs/file_transfer.md | 90 ++- 68 files changed, 6671 insertions(+), 737 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt create mode 100644 app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt create mode 100644 app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt create mode 100644 app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt create mode 100644 app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt create mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt create mode 100644 docs/NOISE_PEER_ID_BINDING.md create mode 100644 docs/PRIVATE_MEDIA_V1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02330d6c..697b91a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -151,3 +151,12 @@ dependencies { androidTestImplementation(libs.bundles.compose.testing) debugImplementation(libs.androidx.compose.ui.tooling) } + +// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution). +// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403. +tasks.withType().configureEach { + systemProperty( + "robolectric.dependency.repo.url", + "https://repo.maven.apache.org/maven2" + ) +} diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 4b51fd31..b15a6254 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -7,6 +7,9 @@ import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.bitchat.android.noise.NoiseEncryptionService +import com.bitchat.android.noise.NoiseHandshakeProcessingResult +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult import org.bouncycastle.crypto.AsymmetricCipherKeyPair import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters @@ -190,6 +193,13 @@ open class EncryptionService(private val context: Context) { } return encrypted } + + @Throws(Exception::class) + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession) /** * Decrypt data from a specific peer using Noise transport encryption @@ -202,6 +212,12 @@ open class EncryptionService(private val context: Context) { } return decrypted } + + @Throws(Exception::class) + fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult { + return noiseService.decryptWithSession(data, peerID) + ?: throw Exception("Failed generation-bound decryption from $peerID") + } /** * Sign data using our static identity key @@ -254,6 +270,25 @@ open class EncryptionService(private val context: Context) { fun getPeerFingerprint(peerID: String): String? { return noiseService.getPeerFingerprint(peerID) } + + /** + * Return the remote static key authenticated by the live Noise handshake. + * This deliberately bypasses announcement and PeerFingerprintManager + * caches; callers making downgrade decisions must bind to live channel + * authentication, not a self-certified identity payload. + */ + fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? { + return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf() + } + + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = + noiseService.getAuthenticatedSession(peerID) + + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action) /** * Get current peer ID for a fingerprint (for peer ID rotation) @@ -265,9 +300,9 @@ open class EncryptionService(private val context: Context) { /** * Initiate a Noise handshake with a peer */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { Log.d(TAG, "🤝 Initiating Noise handshake with $peerID") - return noiseService.initiateHandshake(peerID) + return noiseService.initiateHandshake(peerID, replaceEstablished) } /** @@ -277,11 +312,24 @@ open class EncryptionService(private val context: Context) { Log.d(TAG, "🤝 Processing handshake message from $peerID") return noiseService.processHandshakeMessage(data, peerID) } + + /** + * Process one Noise handshake frame while preserving whether this exact call authenticated a + * new session. Unlike the response-only compatibility API, binding failures are propagated. + */ + @Throws(Exception::class) + open fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + Log.d(TAG, "🤝 Processing typed handshake message from $peerID") + return noiseService.processHandshakeMessageWithResult(data, peerID) + } /** * Remove a peer session (called when peer disconnects) */ - fun removePeer(peerID: String) { + open fun removePeer(peerID: String) { establishedSessions.remove(peerID) noiseService.removePeer(peerID) onSessionLost?.invoke(peerID) diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 012ea3c4..ab00b137 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -1,5 +1,6 @@ package com.bitchat.android.identity +import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences @@ -7,6 +8,8 @@ import androidx.security.crypto.MasterKey import java.security.MessageDigest import android.util.Base64 import android.util.Log +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.util.hexEncodedString import androidx.core.content.edit @@ -18,7 +21,7 @@ import androidx.core.content.edit * - Secure storage using Android EncryptedSharedPreferences * - Fingerprint calculation and identity validation */ -class SecureIdentityStateManager(private val context: Context) { +class SecureIdentityStateManager { companion object { private const val TAG = "SecureIdentityStateManager" @@ -32,12 +35,25 @@ class SecureIdentityStateManager(private val context: Context) { private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys" private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints" private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" + private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1" + private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1" + + // BLE, Wi-Fi Aware, and Noise services each hold their own manager + // instance over the same encrypted preferences. Serialize pin updates + // process-wide so concurrent promotions cannot lose one another or + // race a panic wipe. + private val privateMediaPinsLock = Any() + private var privateMediaPinsEpoch = 0L } private val prefs: SharedPreferences private val lock = Any() - - init { + private var privateMediaPinsEpochAtCreation: Long + + constructor(context: Context) { + privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch + } // Create master key for encryption val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -52,6 +68,15 @@ class SecureIdentityStateManager(private val context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } + + /** Test-only storage injection; production always uses encrypted prefs. */ + internal constructor(prefs: SharedPreferences, testOnly: Boolean) { + require(testOnly) { "Plain SharedPreferences are test-only" } + privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch + } + this.prefs = prefs + } // MARK: - Static Key Management @@ -293,6 +318,78 @@ class SecureIdentityStateManager(private val context: Context) { prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } } } + + // MARK: - Authenticated private-media capability pins + + fun isPrivateMediaCapable(fingerprint: String): Boolean { + if (!isValidFingerprint(fingerprint)) return false + return synchronized(privateMediaPinsLock) { + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) { + return@synchronized false + } + prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) + ?.any { it.equals(fingerprint, ignoreCase = true) } == true + } + } + + /** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */ + @SuppressLint("UseKtx") + fun storeAuthenticatedPeerState( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit = {} + ): Boolean { + if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false + val normalizedFingerprint = fingerprint.lowercase() + return synchronized(privateMediaPinsLock) { + // A controller that survived panic must not republish pre-wipe proof state. + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false + val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) + ?.toMutableSet() ?: mutableSetOf() + records.removeAll { it.startsWith("$normalizedFingerprint:") } + val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16) + records.add( + "$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}" + ) + + val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records) + if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) { + val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet()) + ?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf() + pins.add(normalizedFingerprint) + editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins) + } + // This result is a security boundary: do not publish the Ed key in memory unless the + // encrypted identity record and its HSTS pin were durably committed together. + editor.commit().also { committed -> + if (committed) onCommitted() + } + } + } + + fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? { + if (!isValidFingerprint(fingerprint)) return null + return synchronized(privateMediaPinsLock) { + if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null + val prefix = "${fingerprint.lowercase()}:" + val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) + ?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null + val fields = record.split(':', limit = 3) + if (fields.size != 3) return@synchronized null + val capabilities = runCatching { + PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16)) + }.getOrNull() ?: return@synchronized null + val signingKeyHex = fields[2] + if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null + val signingKey = runCatching { + signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + }.getOrNull() ?: return@synchronized null + AuthenticatedPeerState(capabilities, signingKey) + } + } + + fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? = + getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf() // MARK: - Peer ID Rotation Management (removed) // Android now derives peer ID from the persisted Noise identity fingerprint. @@ -368,9 +465,16 @@ class SecureIdentityStateManager(private val context: Context) { /** * Clear all identity data (for panic mode) */ + @SuppressLint("UseKtx") fun clearIdentityData() { try { - prefs.edit().clear().apply() + synchronized(privateMediaPinsLock) { + privateMediaPinsEpoch += 1 + privateMediaPinsEpochAtCreation = privateMediaPinsEpoch + if (!prefs.edit().clear().commit()) { + Log.e(TAG, "Identity preference wipe could not be committed") + } + } Log.w(TAG, "All identity data cleared") } catch (e: Exception) { Log.e(TAG, "Failed to clear identity data: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt new file mode 100644 index 00000000..89b833b1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt @@ -0,0 +1,37 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.util.toHexString + +/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */ +object AnnouncementIdentityValidator { + private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L + + fun verify( + packet: BitchatPacket, + claimedPeerID: String, + nowMs: Long = System.currentTimeMillis(), + verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean + ): IdentityAnnouncement? { + if (packet.type != MessageType.ANNOUNCE.value) return null + val now = nowMs.coerceAtLeast(0).toULong() + val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp + if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null + val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null + if (announcement.signingPublicKey.size != 32) return null + + val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null + if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null + + val signature = packet.signature ?: return null + val canonicalData = packet.toBinaryDataForSigning() ?: return null + return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) { + announcement + } else { + null + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt new file mode 100644 index 00000000..f2aa1db2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.mesh + +/** + * Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that + * authentication attempt. + */ +internal object AuthenticatedBleLinkPolicy { + data class Claim(val deviceAddress: String, val linkID: String) + + fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean = + claim != null && + claim.deviceAddress == authenticatedAddress && + claim.linkID == authenticatedLinkID +} diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt new file mode 100644 index 00000000..90ef4176 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt @@ -0,0 +1,235 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoisePeerIdentity +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal interface AuthenticatedPeerStateStore { + fun load(fingerprint: String): AuthenticatedPeerState? + fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean + fun isPrivateMediaPinned(fingerprint: String): Boolean +} + +internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore { + private val identityState = SecureIdentityStateManager(context.applicationContext) + + override fun load(fingerprint: String): AuthenticatedPeerState? = + identityState.getAuthenticatedPeerState(fingerprint) + + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted) + + override fun isPrivateMediaPinned(fingerprint: String): Boolean = + identityState.isPrivateMediaCapable(fingerprint) +} + +internal sealed interface AuthenticatedPeerStateStatus { + data object Missing : AuthenticatedPeerStateStatus + data object Awaiting : AuthenticatedPeerStateStatus + data object TimedOut : AuthenticatedPeerStateStatus + data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus +} + +/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */ +internal class AuthenticatedPeerStateCoordinator( + private val scope: CoroutineScope, + private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?, + private val withAuthenticatedSession: ( + String, + AuthenticatedNoiseSession, + () -> Boolean + ) -> Boolean, + private val store: AuthenticatedPeerStateStore, + private val localStateProvider: () -> AuthenticatedPeerState, + private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit, + private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean, + private val onResolution: (String) -> Unit, + private val proofTimeoutMs: Long = 5_000L +) { + private data class SessionState( + val authenticatedSession: AuthenticatedNoiseSession, + val fingerprint: String, + var status: AuthenticatedPeerStateStatus, + var echoSent: Boolean, + var timeoutJob: Job? = null + ) + + private val lock = Any() + private val sessions = ConcurrentHashMap() + + fun onSessionAuthenticated( + peerID: String, + authenticatedRemoteStatic: ByteArray, + authenticatedSessionToken: ByteArray + ) { + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return + if (authenticatedSessionToken.size != 32 || + authenticatedSessionToken.all { it == 0.toByte() } + ) return + val authenticatedSession = AuthenticatedNoiseSession( + authenticatedRemoteStatic.copyOf(), + authenticatedSessionToken.copyOf() + ) + ensureSession(peerID, authenticatedSession) + } + + /** Install one watchdog/exchange for this exact live generation, without resetting it. */ + private fun ensureSession( + peerID: String, + authenticatedSession: AuthenticatedNoiseSession + ): SessionState? { + val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null + if (authenticatedSession.sessionToken.size != 32 || + authenticatedSession.sessionToken.all { it == 0.toByte() } + ) return null + // Ignore a delayed callback or policy snapshot if a later generation is already active. + if (authenticatedSessionProvider(peerID) != authenticatedSession) return null + val session = SessionState( + authenticatedSession = authenticatedSession, + fingerprint = fingerprint(authenticatedRemoteStatic), + status = AuthenticatedPeerStateStatus.Awaiting, + echoSent = false + ) + val installed = withAuthenticatedSession(peerID, authenticatedSession) { + synchronized(lock) { + val existing = sessions[peerID] + if (existing?.authenticatedSession == authenticatedSession) { + return@synchronized false + } + sessions.put(peerID, session)?.timeoutJob?.cancel() + true + } + } + if (!installed) return synchronized(lock) { sessions[peerID] } + + // Emit for every authenticated generation/rekey. Failure does not relax the watchdog. + runCatching { sendState(peerID, localStateProvider(), authenticatedSession) } + + val timeout = scope.launch { + delay(proofTimeoutMs) + val resolved = synchronized(lock) { + val current = sessions[peerID] + if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) { + false + } else { + current.status = AuthenticatedPeerStateStatus.TimedOut + true + } + } + if (resolved) onResolution(peerID) + } + synchronized(lock) { + if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) { + session.timeoutJob = timeout + } else { + timeout.cancel() + } + } + return session + } + + /** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */ + fun receive( + peerID: String, + state: AuthenticatedPeerState, + decryptedSession: AuthenticatedNoiseSession + ): Boolean { + val remoteStatic = decryptedSession.remoteStaticKey + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false + if (decryptedSession.sessionToken.size != 32 || + decryptedSession.sessionToken.all { it == 0.toByte() } + ) return false + val currentFingerprint = fingerprint(remoteStatic) + + var shouldEcho = false + var echoSession: AuthenticatedNoiseSession? = null + val accepted = withAuthenticatedSession(peerID, decryptedSession) { + synchronized(lock) { + val current = sessions[peerID] ?: return@synchronized false + if (current.fingerprint != currentFingerprint || + current.authenticatedSession != decryptedSession + ) return@synchronized false + val proven = current.status as? AuthenticatedPeerStateStatus.Proven + if (proven != null) return@synchronized proven.state == state + try { + // Persist before publishing the replacement Ed key in memory, so a restart + // cannot reopen copied-static first-announce poisoning. The Noise manager + // lease prevents this generation from being replaced during the transition. + if (!store.persist(currentFingerprint, state) { + // Publish while the persistence epoch lock is still held. A panic wipe + // can therefore happen before both operations or after both, never between. + applyAuthenticatedState(peerID, remoteStatic, state) + } + ) return@synchronized false + current.timeoutJob?.cancel() + current.status = AuthenticatedPeerStateStatus.Proven(state) + if (!current.echoSent) { + current.echoSent = true + shouldEcho = true + echoSession = current.authenticatedSession + } + true + } catch (_: Exception) { + false + } + } + } + if (!accepted) return false + if (shouldEcho) { + val exactSession = echoSession ?: return false + runCatching { sendState(peerID, localStateProvider(), exactSession) } + } + onResolution(peerID) + return true + } + + fun status( + peerID: String, + authenticatedSession: AuthenticatedNoiseSession + ): AuthenticatedPeerStateStatus { + ensureSession(peerID, authenticatedSession) + val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey) + return synchronized(lock) { + sessions[peerID]?.takeIf { + it.fingerprint == currentFingerprint && + it.authenticatedSession == authenticatedSession + }?.status + ?: AuthenticatedPeerStateStatus.Missing + } + } + + fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? { + if (noisePublicKey.size != 32) return null + return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf() + } + + fun isPrivateMediaPinned(peerID: String): Boolean { + val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false + return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey)) + } + + fun clear(peerID: String) { + synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() } + } + + private fun fingerprint(publicKey: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(publicKey) + .joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 328d361f..58923847 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -42,7 +42,12 @@ class BluetoothConnectionManager( // Delegate for component managers to call back to main manager private val componentDelegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) { Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)") device?.let { bluetoothDevice -> // Get current RSSI for this device and update if available @@ -54,7 +59,7 @@ class BluetoothConnectionManager( if (peerID == myPeerID) return // Ignore messages from self - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, ingressLinkID) } override fun onDeviceConnected(device: BluetoothDevice) { @@ -63,8 +68,8 @@ class BluetoothConnectionManager( delegate?.onDeviceConnected(device) } - override fun onDeviceDisconnected(device: BluetoothDevice) { - delegate?.onDeviceDisconnected(device) + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) { + delegate?.onDeviceDisconnected(device, linkID) } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -88,6 +93,12 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID) + + fun getCurrentLinkID(deviceAddress: String): String? = + connectionTracker.getCurrentLinkID(deviceAddress) + private fun isBleTransportEnabled(): Boolean { return try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value @@ -323,10 +334,10 @@ class BluetoothConnectionManager( * Broadcast packet to connected devices with connection limit enforcement * Automatically fragments large packets to fit within BLE MTU limits */ - fun broadcastPacket(routed: RoutedPacket) { - if (!isActive || !isBleTransportEnabled()) return - - packetBroadcaster.broadcastPacket( + fun broadcastPacket(routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + + return packetBroadcaster.broadcastPacket( routed, serverManager.getGattServer(), serverManager.getCharacteristic() @@ -359,6 +370,17 @@ class BluetoothConnectionManager( serverManager.getCharacteristic() ) } + + fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToLink( + RoutedPacket(packet), + deviceAddress, + linkID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } // Expose role controls for debug UI @@ -501,8 +523,13 @@ class BluetoothConnectionManager( * Delegate interface for Bluetooth connection manager callbacks */ interface BluetoothConnectionManagerDelegate { - fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) + fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: BluetoothDevice?, + ingressLinkID: String + ) fun onDeviceConnected(device: BluetoothDevice) - fun onDeviceDisconnected(device: BluetoothDevice) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index f139b9d3..f37d8574 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +import java.util.UUID /** * Tracks all Bluetooth connections and handles cleanup @@ -31,6 +32,7 @@ class BluetoothConnectionTracker( private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() + private val peerBindingLock = Any() /** * Consolidated device connection information @@ -42,7 +44,9 @@ class BluetoothConnectionTracker( val rssi: Int = Int.MIN_VALUE, val isClient: Boolean = false, val connectedAt: Long = System.currentTimeMillis(), - val peerID: String? = null + val peerID: String? = null, + /** Unique to this GATT connection, even when Android reuses the device address. */ + val linkID: String = UUID.randomUUID().toString() ) override fun start() { @@ -73,7 +77,11 @@ class BluetoothConnectionTracker( */ fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") - connectedDevices[deviceAddress] = deviceConn + synchronized(peerBindingLock) { + connectedDevices[deviceAddress] = deviceConn + // A mapping authenticates a GATT connection, not a reusable Bluetooth address. + addressPeerMap.remove(deviceAddress) + } removePendingConnection(deviceAddress) // Mark as awaiting first ANNOUNCE on this connection firstAnnounceSeen[deviceAddress] = false @@ -83,7 +91,20 @@ class BluetoothConnectionTracker( * Update a device connection */ fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) { - connectedDevices[deviceAddress] = deviceConn + synchronized(peerBindingLock) { + connectedDevices[deviceAddress] = deviceConn + } + } + + fun updateDeviceConnectionIfCurrent( + deviceAddress: String, + linkID: String, + update: (DeviceConnection) -> DeviceConnection + ): Boolean = synchronized(peerBindingLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != linkID) return@synchronized false + connectedDevices[deviceAddress] = update(current) + true } /** @@ -92,6 +113,17 @@ class BluetoothConnectionTracker( fun getDeviceConnection(deviceAddress: String): DeviceConnection? { return connectedDevices[deviceAddress] } + + fun getCurrentLinkID(deviceAddress: String): String? = + connectedDevices[deviceAddress]?.linkID + + fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean = + synchronized(peerBindingLock) { + if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false + addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress } + addressPeerMap[deviceAddress] = peerID + true + } /** * Get all connected devices @@ -233,13 +265,33 @@ class BluetoothConnectionTracker( * Clean up a specific device connection */ fun cleanupDeviceConnection(deviceAddress: String) { - connectedDevices.remove(deviceAddress)?.let { deviceConn -> + synchronized(peerBindingLock) { + connectedDevices.remove(deviceAddress) subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) } - firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } + + fun cleanupDeviceConnectionIfCurrent( + deviceAddress: String, + expectedLinkID: String + ): Boolean = synchronized(peerBindingLock) { + val current = connectedDevices[deviceAddress] ?: return@synchronized false + if (current.linkID != expectedLinkID) { + return@synchronized false + } + if (connectedDevices.remove(deviceAddress, current)) { + subscribedDevices.removeAll { it.address == deviceAddress } + addressPeerMap.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) + Log.d(TAG, "Cleaned up device connection for $deviceAddress") + true + } else { + false + } + } /** * Clean up all connections diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 3885a523..96a351ec 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -532,6 +532,7 @@ class BluetoothGattClientManager( if (!permissionManager.hasBluetoothPermissions()) return val deviceAddress = device.address + val linkID = UUID.randomUUID().toString() Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)") val gattCallback = object : BluetoothGattCallback() { @@ -553,11 +554,11 @@ class BluetoothGattClientManager( } } else { Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress") - connectionTracker.cleanupDeviceConnection(deviceAddress) } + connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) // Notify higher layers about device disconnection to update direct flags - delegate?.onDeviceDisconnected(gatt.device) + delegate?.onDeviceDisconnected(gatt.device, linkID) connectionScope.launch { delay(500) // CLEANUP_DELAY @@ -583,7 +584,8 @@ class BluetoothGattClientManager( gatt = gatt, rssi = rssi, isClient = true, - peerID = peerID // Store the peerID discovered during scan + peerID = peerID, // Store the peerID discovered during scan + linkID = linkID ) connectionTracker.addDeviceConnection(deviceAddress, deviceConn) @@ -602,9 +604,11 @@ class BluetoothGattClientManager( if (service != null) { val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) if (characteristic != null) { - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(characteristic = characteristic) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) + if (connectionTracker.updateDeviceConnectionIfCurrent( + deviceAddress, + linkID + ) { it.copy(characteristic = characteristic) } + ) { Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress") } @@ -644,7 +648,7 @@ class BluetoothGattClientManager( if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, gatt.device) + delegate?.onPacketReceived(packet, peerID, gatt.device, linkID) } else { Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes") Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") @@ -657,9 +661,8 @@ class BluetoothGattClientManager( Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm") // Update the connection tracker with new RSSI value - connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> - val updatedConn = deviceConn.copy(rssi = rssi) - connectionTracker.updateDeviceConnection(deviceAddress, updatedConn) + connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) { + it.copy(rssi = rssi) } } else { Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status") diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 0c7aabc3..d9ae8b17 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* +import java.util.concurrent.ConcurrentHashMap /** * Manages GATT server operations, advertising, and server-side connections @@ -43,6 +44,7 @@ class BluetoothGattServerManager( // GATT server for peripheral mode private var gattServer: BluetoothGattServer? = null + private val serverLinkIDs = ConcurrentHashMap() private var characteristic: BluetoothGattCharacteristic? = null private var advertiseCallback: AdvertiseCallback? = null private var advertiseRetryCount = 0 @@ -124,6 +126,7 @@ class BluetoothGattServerManager( // Ensure server is closed if present gattServer?.close() gattServer = null + serverLinkIDs.clear() Log.i(TAG, "GATT server stopped (already inactive)") return } @@ -145,6 +148,7 @@ class BluetoothGattServerManager( // Close GATT server gattServer?.close() gattServer = null + serverLinkIDs.clear() Log.i(TAG, "GATT server stopped") } @@ -178,6 +182,8 @@ class BluetoothGattServerManager( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "Server: Device connected ${device.address}") + val linkID = UUID.randomUUID().toString() + serverLinkIDs[device.address] = linkID // Get best available RSSI (scan RSSI for server connections) val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE @@ -185,7 +191,8 @@ class BluetoothGattServerManager( val deviceConn = BluetoothConnectionTracker.DeviceConnection( device = device, rssi = rssi, - isClient = false + isClient = false, + linkID = linkID ) connectionTracker.addDeviceConnection(device.address, deviceConn) @@ -198,9 +205,12 @@ class BluetoothGattServerManager( } BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Server: Device disconnected ${device.address}") - connectionTracker.cleanupDeviceConnection(device.address) + val linkID = serverLinkIDs.remove(device.address) + if (linkID != null) { + connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) + } // Notify delegate about device disconnection so higher layers can update direct flags - delegate?.onDeviceDisconnected(device) + delegate?.onDeviceDisconnected(device, linkID) } } } @@ -236,11 +246,25 @@ class BluetoothGattServerManager( if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) { Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") + val linkID = serverLinkIDs[device.address] + if (linkID == null) { + Log.w(TAG, "Server: Dropping packet from stale connection ${device.address}") + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_FAILURE, + 0, + null + ) + } + return + } val packet = BitchatPacket.fromBinaryData(value) if (packet != null) { val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) } Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID") - delegate?.onPacketReceived(packet, peerID, device) + delegate?.onPacketReceived(packet, peerID, device, linkID) } else { Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes") Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}") 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 1480bd61..9a6dff60 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -4,6 +4,8 @@ import android.content.Context import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.IdentityAnnouncement @@ -19,6 +21,7 @@ import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import java.util.* +import java.util.concurrent.ConcurrentHashMap import kotlin.math.sign import kotlin.random.Random @@ -40,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" + private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS } @@ -50,6 +54,58 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val peerManager = PeerManager() private val fragmentManager = FragmentManager() + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) + private val authenticatedPeerState by lazy { + AuthenticatedPeerStateCoordinator( + scope = serviceScope, + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + withAuthenticatedSession = encryptionService::withAuthenticatedSession, + store = authenticatedPeerStateStore, + localStateProvider = { + AuthenticatedPeerState( + PeerCapabilities.LOCAL_SUPPORTED, + requireNotNull(encryptionService.getSigningPublicKey()) + ) + }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = ::sendAuthenticatedPeerState, + onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + ) + } + private val privateMediaSecurity by lazy { PrivateMediaSecurityController( + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + peerStateStatusProvider = authenticatedPeerState::status, + isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned + ) } + private val privateMediaPreparer by lazy { + PrivateMediaTransferPreparer( + senderID = hexStringToByteArray(myPeerID), + ttl = MAX_TTL, + policyProvider = privateMediaSecurity::sendPolicy, + encrypt = { plaintext, peerID, authenticatedSession -> + try { + PrivateMediaEncryptionResult.Success( + encryptionService.encryptForSession( + plaintext, + peerID, + authenticatedSession + ) + ) + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + }, + finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict, + fragment = fragmentManager::createFragments + ) + } private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID, context.applicationContext) @@ -70,10 +126,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic var delegate: BluetoothMeshDelegate? = null // Coroutines - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false + private val provisionalBleClaims = + ConcurrentHashMap() init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") @@ -127,10 +184,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic connectionManager.sendPacketToPeer(peerID, packet) } - private fun broadcastRoutedPacket(routed: RoutedPacket) { - if (!isBleTransportEnabled()) return - connectionManager.broadcastPacket(routed) + private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean { + if (!isBleTransportEnabled()) return false + val queued = connectionManager.broadcastPacket(routed) + if (!queued) return false TransportBridgeService.broadcast("BLE", routed) + return true } private fun isBleTransportEnabled(): Boolean { @@ -201,6 +260,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { + provisionalBleClaims.remove(peerID) + authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } // Remove from mesh graph topology to prevent routing through stale peers try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { } @@ -217,7 +278,34 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // SecurityManager delegate for key exchange notifications securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + override fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) { + authenticatedPeerState.onSessionAuthenticated( + peerID, + authenticatedRemoteStaticKey, + authenticatedSessionToken + ) + val expectedClaim = provisionalBleClaims.remove(peerID) + if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) { + val authenticatedClaim = checkNotNull(expectedClaim) + if (connectionManager.bindPeerIfCurrent( + authenticatedClaim.deviceAddress, + authenticatedClaim.linkID, + peerID + ) + ) { + Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID") + try { peerManager.refreshPeerList() } catch (_: Exception) { } + try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { } + } else { + Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress") + } + } // Send announcement and cached messages after key exchange serviceScope.launch { Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") @@ -249,6 +337,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun getPeerInfo(peerID: String): PeerInfo? { return peerManager.getPeerInfo(peerID) } + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) } // StoreForwardManager delegates @@ -297,10 +388,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return peerManager.getPeerInfo(peerID) } - override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + override fun updatePeerInfoFromVerifiedAnnouncement(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities?): Boolean { + return peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified, + capabilities + ) } - + // Packet operations override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting @@ -325,13 +423,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return securityManager.encryptForPeer(data, recipientPeerID) } - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? { return securityManager.decryptFromPeer(encryptedData, senderPeerID) } override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { return encryptionService.verifyEd25519Signature(signature, data, publicKey) } + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) // Noise protocol operations override fun hasNoiseSession(peerID: String): Boolean { @@ -375,34 +479,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic null } } - - override fun updatePeerIDBinding(newPeerID: String, nickname: String, - publicKey: ByteArray, previousPeerID: String?) { - Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...") - // Update peer mapping in the PeerManager for peer ID rotation support - peerManager.addOrUpdatePeer(newPeerID, nickname) - - // Store fingerprint for the peer via centralized fingerprint manager - val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) - try { - com.bitchat.android.identity.SecureIdentityStateManager(context) - .cachePeerNoiseKey(newPeerID, publicKey.toHexString()) - } catch (_: Exception) { } - - // Index existing Nostr mapping by the new peerID if we have it - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) - } - } catch (_: Exception) { } - - // If there was a previous peer ID, remove it to avoid duplicates - previousPeerID?.let { oldPeerID -> - peerManager.removePeer(oldPeerID) - } - - Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...") + override fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + authenticatedPeerState.receive(peerID, state, authenticatedSession) } // Message operations @@ -496,35 +579,48 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } } - override fun handleAnnounce(routed: RoutedPacket) { - serviceScope.launch { - // Process the announce - val isFirst = messageHandler.handleAnnounce(routed) + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + val result = messageHandler.handleAnnounceWithResult(routed) + if (result !is AnnounceHandlingResult.Accepted) return false - // Map device address -> peerID based on TTL (max TTL = direct neighbor) - // Matches iOS logic: any announce with max TTL on a link defines the direct peer - val deviceAddress = routed.relayAddress - val pid = routed.peerID - if (deviceAddress != null && pid != null) { - // Check if this is a direct connection (MAX TTL) - // Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS - val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - - if (isDirect) { - // Bind or rebind this device address to the announcing peer - connectionManager.addressPeerMap[deviceAddress] = pid - Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})") - - // Mark as directly connected - refresh UI state - try { peerManager.refreshPeerList() } catch (_: Exception) { } - - // Initial sync for this direct peer - try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + val deviceAddress = routed.relayAddress + val pid = routed.peerID + val linkID = routed.ingressLinkID + val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + val alreadyAuthenticated = deviceAddress != null && + pid != null && + connectionManager.addressPeerMap[deviceAddress] == pid + if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) { + try { + val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID) + registerProvisionalBleClaim(pid, claim) + val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true) + if (handshakeData != null) { + val handshake = signPacketBeforeBroadcast( + BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(pid), + timestamp = System.currentTimeMillis().toULong(), + payload = handshakeData, + ttl = MAX_TTL + ) + ) + if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) { + provisionalBleClaims.remove(pid, claim) + Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress") + } + } else { + provisionalBleClaims.remove(pid, claim) } + } catch (e: Exception) { + provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)) + Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}") } - // Track for sync - try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } } + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + return true } override fun handleMessage(routed: RoutedPacket) { @@ -582,7 +678,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { - override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + override fun onPacketReceived( + packet: BitchatPacket, + peerID: String, + device: android.bluetooth.BluetoothDevice?, + ingressLinkID: String + ) { // Log incoming for debug graphs (do not double-count anywhere else) try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( @@ -593,7 +694,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic myPeerID = myPeerID ) } catch (_: Exception) { } - packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) + packetProcessor.processPacket( + RoutedPacket(packet, peerID, device?.address, ingressLinkID = ingressLinkID) + ) } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { @@ -613,25 +716,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } catch (_: Exception) { } } - override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { + override fun onDeviceDisconnected( + device: android.bluetooth.BluetoothDevice, + linkID: String? + ) { Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address - // Remove mapping and, if that was the last direct path for the peer, clear direct flag - val peer = connectionManager.addressPeerMap[addr] - // ConnectionTracker has already removed the address mapping; be defensive either way - connectionManager.addressPeerMap.remove(addr) + clearProvisionalBleClaimsForLink(addr, linkID) // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } - if (peer != null) { - // Verbose debug: device disconnected - try { - val nick = peerManager.getPeerNickname(peer) ?: "unknown" - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - .logPeerDisconnection(peer, nick, addr) - } catch (_: Exception) { } - } + // ConnectionTracker already removes an authenticated mapping only when this exact + // link is still current. Do not remove by reusable address here: this may be a late + // disconnect callback from a replaced GATT connection. } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -642,6 +740,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } } + + private fun registerProvisionalBleClaim( + peerID: String, + claim: AuthenticatedBleLinkPolicy.Claim + ) { + provisionalBleClaims[peerID] = claim + serviceScope.launch { + delay(BLE_AUTHENTICATION_TIMEOUT_MS) + if (provisionalBleClaims.remove(peerID, claim)) { + Log.d(TAG, "Expired provisional BLE authentication claim for $peerID") + } + } + } + + private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) { + if (linkID == null) return + provisionalBleClaims.entries.removeIf { (_, claim) -> + claim.deviceAddress == deviceAddress && claim.linkID == linkID + } + } /** * Start the mesh service @@ -792,6 +910,32 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic /** * Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels). */ + private fun sendAuthenticatedPeerState( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + val ciphertext = securityManager.encryptForPeer( + plaintext, + peerID, + authenticatedSession + ) ?: return false + val packet = BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + if (signed.signature?.size != 64) return false + broadcastRoutedPacket(RoutedPacket(signed)) + return true + } + fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) { try { Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}") @@ -824,70 +968,65 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - /** - * Send a file as an encrypted private message using Noise protocol - */ + /** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */ fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) { - try { - Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}") - - serviceScope.launch { - // Check if we have an established Noise session - if (encryptionService.hasEstablishedSession(recipientPeerID)) { - try { - // Encode the file packet as TLV - val filePayload = file.encode() - if (filePayload == null) { - Log.e(TAG, "❌ Failed to encode file packet for private send") - return@launch - } - Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes") - - // Create NoisePayload wrapper (type byte + file TLV data) - same as iOS - val noisePayload = com.bitchat.android.model.NoisePayload( - type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER, - data = filePayload - ) - - // Encrypt the payload using Noise - val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID) - if (encrypted == null) { - Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID") - return@launch - } - Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes") - - // Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!) - val packet = BitchatPacket( - version = if (encrypted.size > 0xFFFF) 2u else 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = encrypted, - signature = null, - ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - ) - - // Sign and send the encrypted packet - val signed = signPacketBeforeBroadcast(packet) - // Use a stable transferId based on the unencrypted file TLV payload for progress tracking - val transferId = sha256Hex(filePayload) - broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId)) - Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID") - - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e) - } - } else { - // No session - initiate handshake but don't queue file - Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake") - messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) - } + val payload = file.encode() ?: return + when (val prepared = prepareFilePrivate( + recipientPeerID, + file, + sha256Hex(payload), + allowLegacyFallback = false + )) { + is PrivateMediaPreparation.Ready -> prepared.transfer.commit() + is PrivateMediaPreparation.RequiresLegacyConsent -> + Log.w(TAG, "Private media requires explicit one-shot legacy consent") + PrivateMediaPreparation.NeedsHandshake -> { + Log.i(TAG, "Private media needs a Noise handshake; initiating without sending") + initiateNoiseHandshake(recipientPeerID) + } + PrivateMediaPreparation.AwaitingPeerState -> Unit + is PrivateMediaPreparation.Rejected -> + Log.w(TAG, "Private media blocked: ${prepared.reason}") + } + } + + fun prepareFilePrivate( + recipientPeerID: String, + file: com.bitchat.android.model.BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when (val outcome = privateMediaPreparer.prepare( + recipientPeerID = recipientPeerID, + recipientID = hexStringToByteArray(recipientPeerID), + file = file, + allowLegacyFallback = allowLegacyFallback + )) { + is PrivateMediaBuildOutcome.RequiresLegacyConsent -> + PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) + PrivateMediaBuildOutcome.NeedsHandshake -> + PrivateMediaPreparation.NeedsHandshake + PrivateMediaBuildOutcome.AwaitingPeerState -> + PrivateMediaPreparation.AwaitingPeerState + is PrivateMediaBuildOutcome.Rejected -> + PrivateMediaPreparation.Rejected(outcome.reason) + is PrivateMediaBuildOutcome.Ready -> { + val built = outcome.built + val routed = RoutedPacket( + packet = built.packet, + transferId = transferId, + preparedPackets = built.fragments + ) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer(transferId, built.wireMode) { + if (!isActive || terminated || !isBleTransportEnabled()) { + false + } else { + broadcastRoutedPacket(routed) + } + } + ) } - } catch (e: Exception) { - Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e) - Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}") } } @@ -1100,7 +1239,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") @@ -1163,7 +1302,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) var tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") @@ -1404,24 +1543,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic /** * Sign packet before broadcasting using our signing private key */ + private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket { + return try { + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.joinToString("") { byte -> "%02x".format(byte) } + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath( + myPeerID, + destination + ) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map(::hexStringToByteArray), + version = 2u + ) + } else { + packet.copy(route = null) + } + } else { + packet + } + } catch (_: Exception) { + packet + } + } + + /** Private media must never fall back to an unsigned packet. */ + private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? { + val routed = applyRouteIfAvailable(packet) + val signingBytes = routed.toBinaryDataForSigning() ?: return null + val signature = encryptionService.signData(signingBytes) ?: return null + return routed.copy(signature = signature) + } + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { return try { // Optionally compute and attach a source route for addressed packets - val withRoute = try { - val rec = packet.recipientID - if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) { - val dest = rec.joinToString("") { b -> "%02x".format(b) } - val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest) - if (path != null && path.size >= 3) { - // Exclude first (sender) and last (recipient); only intermediates - val intermediates = path.subList(1, path.size - 1) - val hopsBytes = intermediates.map { hexStringToByteArray(it) } - Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)") - // Attach route and upgrade to v2 (required for HAS_ROUTE flag) - packet.copy(route = hopsBytes, version = 2u) - } else packet.copy(route = null) - } else packet - } catch (_: Exception) { packet } + val withRoute = applyRouteIfAvailable(packet) // Get the canonical packet data for signing (without signature) val packetDataForSigning = withRoute.toBinaryDataForSigning() diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 3c49315e..3e1cf721 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -136,8 +136,8 @@ class BluetoothPacketBroadcaster( routed: RoutedPacket, gattServer: BluetoothGattServer?, characteristic: BluetoothGattCharacteristic? - ) { - fragmentingSender.send(routed, "BLE broadcast") { packet -> + ): Boolean { + return fragmentingSender.send(routed, "BLE broadcast") { packet -> broadcastSinglePacket(packet, gattServer, characteristic) true } @@ -163,6 +163,28 @@ class BluetoothPacketBroadcaster( } } + fun sendPacketToLink( + routed: RoutedPacket, + deviceAddress: String, + linkID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean = fragmentingSender.send(routed, "BLE link $deviceAddress") { packet -> + val data = packet.packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.packet.type) + ) ?: return@send false + val currentLink = connectionTracker.getDeviceConnection(deviceAddress) + ?.takeIf { it.linkID == linkID } + ?: return@send false + if (currentLink.isClient) { + return@send writeToDeviceConn(currentLink, data) + } + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { it.address == deviceAddress } + ?: return@send false + notifyDevice(serverTarget, data, gattServer, characteristic) + } + private fun sendSinglePacketToPeer( routed: RoutedPacket, targetPeerID: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 2be19841..489ead2a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -51,97 +51,123 @@ class FragmentManager { * Create fragments from a large packet - 100% iOS Compatible * Matches iOS sendFragmentedPacket() implementation exactly */ - fun createFragments(packet: BitchatPacket): List { + /** Generic/public packets retain the full UInt16 fragment-count range. */ + fun createFragments(packet: BitchatPacket): List = + createFragments(packet, 0xFFFF) + + /** + * Create a fragment plan with a caller-selected bound. Private media uses + * 256 for cross-platform admission; generic/public traffic retains the + * UInt16 wire limit. + */ + fun createFragments(packet: BitchatPacket, maxFragments: Int): List { try { + if (maxFragments !in 1..0xFFFF) { + Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments") + return emptyList() + } Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes") - val encoded = packet.toBinaryData() + val encoded = packet.toBinaryData() if (encoded == null) { Log.e(TAG, "❌ Failed to encode packet to binary data") return emptyList() } Log.d(TAG, "📦 Encoded to ${encoded.size} bytes") - - // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix - val fullData = try { + + // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix + val fullData = try { MessagePadding.unpad(encoded) } catch (e: Exception) { Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e) return emptyList() } Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes") - - // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue - if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) { - return listOf(packet) // No fragmentation needed - } - - val fragments = mutableListOf() - - // iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) - val fragmentID = FragmentPayload.generateFragmentID() - - // iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize) - // Calculate dynamic fragment size to fit in MTU (512) - // Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer - val hasRoute = packet.route != null - val version = if (hasRoute) 2 else 1 - val headerSize = if (version == 2) 15 else 13 - val senderSize = 8 - val recipientSize = if (packet.recipientID != null) 8 else 0 - // Route: 1 byte count + 8 bytes per hop - val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0 - val fragmentHeaderSize = 13 // FragmentPayload header - val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead - // 512 - Overhead - val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer - val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE) - - if (maxDataSize <= 0) { - Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?") - return emptyList() - } + // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue + if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) { + return listOf(packet) // No fragmentation needed + } - Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)") + val fragments = mutableListOf() - val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset -> - val endOffset = minOf(offset + maxDataSize, fullData.size) - fullData.sliceArray(offset.. maxFragments) { + Log.w( + TAG, + "Rejecting outbound packet requiring $requiredFragments fragments " + + "(caller cap: $maxFragments)" + ) + return emptyList() + } + + // Do not allocate chunk copies until the plan passes the hard bound. + val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset -> + val endOffset = minOf(offset + maxDataSize, fullData.size) + fullData.sliceArray(offset.. Boolean ): Boolean { val transferId = transferIdFor(routed) - val packets = packetsForTransport(routed.packet) ?: return false + val packets = packetsForTransport(routed) ?: return false val total = packets.size if (total <= 1) { if (transferId != null) { TransferProgressManager.start(transferId, 1) } - val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId)) + val sent = sendSingle( + routed.copy( + packet = packets.first(), + transferId = transferId, + preparedPackets = null + ) + ) if (sent && transferId != null) { TransferProgressManager.progress(transferId, 1, 1) TransferProgressManager.complete(transferId, 1) @@ -57,7 +63,11 @@ class FragmentingPacketSender( if (!isActive) return@launch if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch - val fragment = routed.copy(packet = packet, transferId = transferId) + val fragment = routed.copy( + packet = packet, + transferId = transferId, + preparedPackets = null + ) val delivered = try { sendSingle(fragment) } catch (e: Exception) { @@ -98,7 +108,17 @@ class FragmentingPacketSender( return true } - private fun packetsForTransport(packet: BitchatPacket): List? { + private fun packetsForTransport(routed: RoutedPacket): List? { + routed.preparedPackets?.let { prepared -> + if (prepared.isEmpty() || + prepared.size > com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) { + Log.e(logTag, "Rejected invalid prepared fragment plan (${prepared.size} packets)") + return null + } + return prepared + } + + val packet = routed.packet if (packet.type == MessageType.FRAGMENT.value) { return listOf(packet) } 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 0ba1b31b..375e6531 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -5,6 +5,8 @@ import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType @@ -41,8 +43,8 @@ class MeshCore( ) { data class Hooks( val onMessageReceived: ((BitchatMessage) -> Unit)? = null, - val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null, val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null, + val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null, val readReceiptInterceptor: ((String, String) -> Boolean)? = null, val onReadReceiptSent: ((String) -> Unit)? = null, val announcementNicknameProvider: (() -> String?)? = null, @@ -51,6 +53,57 @@ class MeshCore( private val peerManager = PeerManager() val fragmentManager = FragmentManager() + private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context) + private val authenticatedPeerState by lazy { + AuthenticatedPeerStateCoordinator( + scope = scope, + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + withAuthenticatedSession = encryptionService::withAuthenticatedSession, + store = authenticatedPeerStateStore, + localStateProvider = { + AuthenticatedPeerState( + PeerCapabilities.LOCAL_SUPPORTED, + requireNotNull(encryptionService.getSigningPublicKey()) + ) + }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = ::sendAuthenticatedPeerState, + onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) } + ) + } + private val privateMediaSecurity by lazy { PrivateMediaSecurityController( + authenticatedSessionProvider = encryptionService::getAuthenticatedSession, + peerStateStatusProvider = authenticatedPeerState::status, + isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned + ) } + private val privateMediaPreparer by lazy { + PrivateMediaTransferPreparer( + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + ttl = maxTtl, + policyProvider = privateMediaSecurity::sendPolicy, + encrypt = { plaintext, peerID, authenticatedSession -> + try { + PrivateMediaEncryptionResult.Success( + encryptionService.encryptForSession( + plaintext, + peerID, + authenticatedSession + ) + ) + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) { + PrivateMediaEncryptionResult.GenerationChanged + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + }, + finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict, + fragment = fragmentManager::createFragments + ) + } private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() private val messageHandler = MessageHandler(myPeerID, context.applicationContext) @@ -117,8 +170,20 @@ class MeshCore( packetProcessor.shutdown() } - fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) { - packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress)) + fun processIncoming( + packet: BitchatPacket, + peerID: String?, + relayAddress: String?, + ingressLinkID: String? = null + ) { + packetProcessor.processPacket( + RoutedPacket( + packet = packet, + peerID = peerID, + relayAddress = relayAddress, + ingressLinkID = ingressLinkID + ) + ) } fun sendFromBridge(packet: RoutedPacket) { @@ -150,6 +215,7 @@ class MeshCore( } override fun onPeerRemoved(peerID: String) { + authenticatedPeerState.clear(peerID) try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } try { encryptionService.removePeer(peerID) } catch (_: Exception) { } try { peerManager.refreshPeerList() } catch (_: Exception) { } @@ -157,7 +223,26 @@ class MeshCore( } securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + override fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) { + authenticatedPeerState.onSessionAuthenticated( + peerID, + authenticatedRemoteStaticKey, + authenticatedSessionToken + ) + if (directRelayAddress != null && ingressLinkID != null) { + hooks.onDirectNoiseAuthenticated?.invoke( + peerID, + directRelayAddress, + ingressLinkID, + authenticatedRemoteStaticKey + ) + } scope.launch { delay(100) sendAnnouncementToPeer(peerID) @@ -180,6 +265,9 @@ class MeshCore( } override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) } storeForwardManager.delegate = object : StoreForwardManagerDelegate { @@ -225,14 +313,22 @@ class MeshCore( return peerManager.getPeerInfo(peerID) } - override fun updatePeerInfo( + override fun updatePeerInfoFromVerifiedAnnouncement( peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, - isVerified: Boolean + isVerified: Boolean, + capabilities: com.bitchat.android.model.PeerCapabilities? ): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + return peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + nickname, + noisePublicKey, + signingPublicKey, + isVerified, + capabilities + ) } override fun sendPacket(packet: BitchatPacket) { @@ -256,7 +352,10 @@ class MeshCore( return securityManager.encryptForPeer(data, recipientPeerID) } - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? { return securityManager.decryptFromPeer(encryptedData, senderPeerID) } @@ -264,6 +363,9 @@ class MeshCore( return encryptionService.verifyEd25519Signature(signature, data, publicKey) } + override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } @@ -280,24 +382,12 @@ class MeshCore( } } - override fun updatePeerIDBinding( - newPeerID: String, - nickname: String, - publicKey: ByteArray, - previousPeerID: String? + override fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession ) { - peerManager.addOrUpdatePeer(newPeerID, nickname) - val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) - try { - com.bitchat.android.identity.SecureIdentityStateManager(context) - .cachePeerNoiseKey(newPeerID, publicKey.toHexString()) - com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) - } - } catch (_: Exception) { } - previousPeerID?.let { peerManager.removePeer(it) } - Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}") - hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID) + authenticatedPeerState.receive(peerID, state, authenticatedSession) } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { @@ -359,12 +449,12 @@ class MeshCore( scope.launch { messageHandler.handleNoiseEncrypted(routed) } } - override fun handleAnnounce(routed: RoutedPacket) { - scope.launch { - val isFirst = messageHandler.handleAnnounce(routed) - hooks.onAnnounceProcessed?.invoke(routed, isFirst) - try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } - } + override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + val result = messageHandler.handleAnnounceWithResult(routed) + if (result !is AnnounceHandlingResult.Accepted) return false + hooks.onAnnounceProcessed?.invoke(routed, result.isFirst) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + return true } override fun handleMessage(routed: RoutedPacket) { @@ -437,6 +527,32 @@ class MeshCore( } } + private fun sendAuthenticatedPeerState( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + val ciphertext = securityManager.encryptForPeer( + plaintext, + peerID, + authenticatedSession + ) ?: return false + val packet = BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + ttl = maxTtl + ) + val signed = signPacketBeforeBroadcast(packet) + if (signed.signature?.size != 64) return false + dispatchGlobal(RoutedPacket(signed)) + return true + } + fun sendFileBroadcast(file: BitchatFilePacket) { try { val payload = file.encode() ?: return @@ -462,31 +578,64 @@ class MeshCore( } fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { - try { - scope.launch { - if (!encryptionService.hasEstablishedSession(recipientPeerID)) { - initiateNoiseHandshake(recipientPeerID) - return@launch - } - val tlv = file.encode() ?: return@launch - val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode() - val enc = encryptionService.encrypt(np, recipientPeerID) - val packet = BitchatPacket( - version = if (enc.size > 0xFFFF) 2u else 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), - recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = maxTtl - ) - val signed = signPacketBeforeBroadcast(packet) - val transferId = MeshPacketUtils.sha256Hex(tlv) - dispatchGlobal(RoutedPacket(signed, transferId = transferId)) + val payload = file.encode() ?: return + when (val prepared = prepareFilePrivate( + recipientPeerID, + file, + MeshPacketUtils.sha256Hex(payload), + allowLegacyFallback = false + )) { + is PrivateMediaPreparation.Ready -> prepared.transfer.commit() + is PrivateMediaPreparation.RequiresLegacyConsent -> + Log.w("MeshCore", "Private media requires explicit one-shot legacy consent") + PrivateMediaPreparation.NeedsHandshake -> { + Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending") + initiateNoiseHandshake(recipientPeerID) + } + PrivateMediaPreparation.AwaitingPeerState -> Unit + is PrivateMediaPreparation.Rejected -> + Log.w("MeshCore", "Private media blocked: ${prepared.reason}") + } + } + + fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when (val outcome = privateMediaPreparer.prepare( + recipientPeerID = recipientPeerID, + recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID), + file = file, + allowLegacyFallback = allowLegacyFallback + )) { + is PrivateMediaBuildOutcome.RequiresLegacyConsent -> + PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning) + PrivateMediaBuildOutcome.NeedsHandshake -> + PrivateMediaPreparation.NeedsHandshake + PrivateMediaBuildOutcome.AwaitingPeerState -> + PrivateMediaPreparation.AwaitingPeerState + is PrivateMediaBuildOutcome.Rejected -> + PrivateMediaPreparation.Rejected(outcome.reason) + is PrivateMediaBuildOutcome.Ready -> { + val built = outcome.built + val routed = RoutedPacket( + packet = built.packet, + transferId = transferId, + preparedPackets = built.fragments + ) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer(transferId, built.wireMode) { + if (!isActive) { + false + } else { + dispatchGlobal(routed) + true + } + } + ) } - } catch (e: Exception) { - Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e) } } @@ -607,7 +756,7 @@ class MeshCore( Log.e("MeshCore", "No signing public key available for announcement") return@launch } - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch val announcePacket = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -628,7 +777,7 @@ class MeshCore( ?: myPeerID val staticKey = encryptionService.getStaticPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return val packet = BitchatPacket( type = MessageType.ANNOUNCE.value, @@ -749,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) @@ -813,28 +1000,42 @@ class MeshCore( encryptionService.clearPersistentIdentity() } - private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket { return try { - val withRoute = try { - val recipient = packet.recipientID - if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { - val destination = recipient.toHexString() - val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination) - if (path != null && path.size >= 3) { - val intermediates = path.subList(1, path.size - 1) - packet.copy( - route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, - version = 2u - ) - } else { - packet.copy(route = null) - } + val recipient = packet.recipientID + if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) { + val destination = recipient.toHexString() + val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath( + myPeerID, + destination + ) + if (path != null && path.size >= 3) { + val intermediates = path.subList(1, path.size - 1) + packet.copy( + route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) }, + version = 2u + ) } else { - packet + packet.copy(route = null) } - } catch (_: Exception) { + } else { packet } + } catch (_: Exception) { + packet + } + } + + private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? { + val routed = applyRouteIfAvailable(packet) + val signingBytes = routed.toBinaryDataForSigning() ?: return null + val signature = encryptionService.signData(signingBytes) ?: return null + return routed.copy(signature = signature) + } + + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + return try { + val withRoute = applyRouteIfAvailable(packet) val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute val signature = encryptionService.signData(packetDataForSigning) diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index de662384..b54b1152 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -13,6 +13,8 @@ interface MeshDelegate { fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + /** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */ + fun didResolvePrivateMediaPolicy(peerID: String) {} fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun getNickname(): String? fun isFavorite(peerID: String): Boolean diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index c612e8ed..063fe4f8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -21,6 +21,12 @@ interface MeshService { fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) fun sendFileBroadcast(file: BitchatFilePacket) fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) + fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation fun cancelFileTransfer(transferId: String): Boolean fun sendBroadcastAnnounce() 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 dbccaabb..beedd857 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -4,7 +4,7 @@ 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.IdentityAnnouncement +import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType @@ -13,6 +13,11 @@ import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* +sealed class AnnounceHandlingResult { + data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult() + object Rejected : AnnounceHandlingResult() +} + /** * Handles processing of different message types * Extracted from BluetoothMeshService for better separation of concerns @@ -55,11 +60,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro try { // Decrypt the message using the Noise service - val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) - if (decryptedData == null) { + val decryption = delegate?.decryptFromPeer(packet.payload, peerID) + if (decryption == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") return } + val decryptedData = decryption.plaintext if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") @@ -140,6 +146,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID") } } + + com.bitchat.android.model.NoisePayloadType.PEER_STATE -> { + val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data) + if (authenticatedState == null) { + Log.w(TAG, "Dropping malformed authenticated peer state from ${peerID.take(8)}") + } else { + delegate?.onAuthenticatedPeerStateReceived( + peerID, + authenticatedState, + decryption.authenticatedSession + ) + } + } com.bitchat.android.model.NoisePayloadType.DELIVERED -> { // Handle delivery ACK exactly like iOS @@ -215,10 +234,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro * Handle announce message with TLV decoding and signature verification - exactly like iOS */ suspend fun handleAnnounce(routed: RoutedPacket): Boolean { + return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false + } + + suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - if (peerID == myPeerID) return false + if (peerID == myPeerID) return AnnounceHandlingResult.Rejected // Peers use wall-clock packet timestamps; tolerate moderate device clock skew // during identity learning, or later signed messages cannot be verified. @@ -226,28 +249,29 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong()) if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) { Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)") - return false + return AnnounceHandlingResult.Rejected } else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) { Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)") } - // Try to decode as iOS-compatible IdentityAnnouncement with TLV format - val announcement = IdentityAnnouncement.decode(packet.payload) + val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key -> + delegate?.verifyEd25519Signature(signature, data, key) ?: false + } if (announcement == null) { - Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format") - return false + Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}") + return AnnounceHandlingResult.Rejected } - - // Verify packet signature using the announced signing public key - var verified = false - if (packet.signature != null) { - // Verify that the packet was signed by the signing private key corresponding to the announced signing public key - verified = delegate?.verifyEd25519Signature(packet.signature!!, packet.toBinaryDataForSigning()!!, announcement.signingPublicKey) ?: false - if (!verified) { - Log.w(TAG, "⚠️ Signature verification for announce failed ${peerID.take(8)}") - } + + val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey) + if (persistedSigningKey != null && + !persistedSigningKey.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state") + return AnnounceHandlingResult.Rejected } + var verified = true + // Check for existing peer with different noise public key // If existing peer has a different noise public key, do not consider this verified val existingPeer = delegate?.getPeerInfo(peerID) @@ -257,10 +281,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro verified = false } + if ( + existingPeer?.signingPublicKey != null && + !existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey) + ) { + Log.w( + TAG, + "Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof" + ) + verified = false + } + // Require verified announce; ignore otherwise (no backward compatibility) if (!verified) { Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...") - return false + return AnnounceHandlingResult.Rejected } // Successfully decoded TLV format exactly like iOS @@ -274,22 +309,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val signingPublicKey = announcement.signingPublicKey // Update peer info with verification status through new method - val isFirstAnnounce = delegate?.updatePeerInfo( + val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement( peerID = peerID, nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, - isVerified = true + isVerified = true, + capabilities = announcement.capabilities ) ?: false - // Update peer ID binding with noise public key for identity management - delegate?.updatePeerIDBinding( - newPeerID = peerID, - nickname = nickname, - publicKey = noisePublicKey, - previousPeerID = null - ) - // Update mesh graph from gossip neighbors (only if TLV present) try { val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload) @@ -298,7 +326,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } catch (_: Exception) { } Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID") - return isFirstAnnounce + return AnnounceHandlingResult.Accepted(isFirstAnnounce) } /** @@ -437,14 +465,26 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro */ private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) { try { - // Verify signature if present - if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) { + val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == + com.bitchat.android.protocol.MessageType.FILE_TRANSFER + val signatureIsValid = packet.signature != null && + delegate?.verifySignature(packet, peerID) == true + + // Migration fallback is visible to relays, so sender authenticity + // is mandatory. Never accept an unsigned directed raw file. + if (isFileTransfer && !signatureIsValid) { + Log.w(TAG, "Unsigned or invalid signed private file from $peerID") + return + } + + // Preserve prior behavior for other directed packet types: verify + // a signature whenever one is present. + if (!isFileTransfer && packet.signature != null && !signatureIsValid) { Log.w(TAG, "Invalid signature for private message from $peerID") return } // Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER - val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload) if (file != null) { if (isFileTransfer) { @@ -597,7 +637,14 @@ interface MessageHandlerDelegate { fun getNetworkSize(): Int fun getMyNickname(): String? fun getPeerInfo(peerID: String): PeerInfo? - fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean + fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: com.bitchat.android.model.PeerCapabilities? = null + ): Boolean // Packet operations fun sendPacket(packet: BitchatPacket) @@ -607,15 +654,22 @@ interface MessageHandlerDelegate { // Cryptographic operations fun verifySignature(packet: BitchatPacket, peerID: String): Boolean fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? - fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? + fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult? fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean + fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? - fun updatePeerIDBinding(newPeerID: String, nickname: String, - publicKey: ByteArray, previousPeerID: String?) + fun onAuthenticatedPeerStateReceived( + peerID: String, + state: AuthenticatedPeerState, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) {} // Message operations fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? 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 fb47f40f..0fdae3b0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -143,7 +143,7 @@ class PacketProcessor(private val myPeerID: String) { // Handle public packet types (no address check needed) when (messageType) { - MessageType.ANNOUNCE -> handleAnnounce(routed) + MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed) MessageType.MESSAGE -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler MessageType.LEAVE -> handleLeave(routed) @@ -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 } /** @@ -197,10 +197,10 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle announce message */ - private suspend fun handleAnnounce(routed: RoutedPacket) { + private suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val peerID = routed.peerID ?: "unknown" Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}") - delegate?.handleAnnounce(routed) + return delegate?.handleAnnounce(routed) ?: false } /** @@ -231,7 +231,14 @@ class PacketProcessor(private val myPeerID: String) { val reassembledPacket = delegate?.handleFragment(routed.packet) if (reassembledPacket != null) { Log.d(TAG, "Fragment reassembled, processing complete message") - handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress)) + handleReceivedPacket( + RoutedPacket( + packet = reassembledPacket, + peerID = routed.peerID, + relayAddress = routed.relayAddress, + ingressLinkID = routed.ingressLinkID + ) + ) } // Fragment relay is now handled by centralized PacketRelayManager @@ -314,7 +321,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseEncrypted(routed: RoutedPacket) - fun handleAnnounce(routed: RoutedPacket) + suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket) fun handleFragment(packet: BitchatPacket): BitchatPacket? diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 01132ae7..078e2f31 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -1,6 +1,8 @@ package com.bitchat.android.mesh import android.util.Log +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -17,7 +19,11 @@ data class PeerInfo( var noisePublicKey: ByteArray?, var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification var isVerifiedNickname: Boolean, // NEW: Verification status flag - var lastSeen: Long // Using Long instead of Date for simplicity + var lastSeen: Long, // Using Long instead of Date for simplicity + var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05 + var hasVerifiedAnnouncement: Boolean = false, + /** Noise key that the preserved capability state was actually signed alongside. */ + var verifiedAnnouncementNoisePublicKey: ByteArray? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -39,6 +45,14 @@ data class PeerInfo( } else if (other.signingPublicKey != null) return false if (isVerifiedNickname != other.isVerifiedNickname) return false if (lastSeen != other.lastSeen) return false + if (capabilities != other.capabilities) return false + if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false + val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey + val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey + if (thisVerifiedAnnouncementKey != null) { + if (otherVerifiedAnnouncementKey == null) return false + if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false + } else if (otherVerifiedAnnouncementKey != null) return false return true } @@ -52,6 +66,9 @@ data class PeerInfo( result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0) result = 31 * result + isVerifiedNickname.hashCode() result = 31 * result + lastSeen.hashCode() + result = 31 * result + (capabilities?.hashCode() ?: 0) + result = 31 * result + hasVerifiedAnnouncement.hashCode() + result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0) return result } } @@ -108,6 +125,84 @@ class PeerManager { noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean + ): Boolean { + val existing = peers[peerID] + return updatePeerInfoInternal( + peerID = peerID, + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + isVerified = isVerified, + capabilities = existing?.capabilities, + hasVerifiedAnnouncement = existing?.hasVerifiedAnnouncement == true, + verifiedAnnouncementNoisePublicKey = existing?.verifiedAnnouncementNoisePublicKey + ) + } + + /** + * Apply the exact capability state from a signature-verified announce. + * A null value is meaningful: the peer signed an old-format announce that + * omitted TLV 0x05. Normal peer refreshes use [updatePeerInfo] and retain + * the last signed capability state instead of accidentally erasing it. + */ + fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: PeerCapabilities? + ): Boolean = updatePeerInfoInternal( + peerID = peerID, + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + isVerified = isVerified, + capabilities = capabilities, + hasVerifiedAnnouncement = true, + verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf() + ) + + /** Replace capability/Ed identity from Noise 0x21 in one peer-map mutation. */ + @Synchronized + fun applyAuthenticatedPeerState( + peerID: String, + authenticatedNoisePublicKey: ByteArray, + state: AuthenticatedPeerState + ) { + val existing = peers[peerID] + val announcementMatchesAuthenticatedState = existing?.hasVerifiedAnnouncement == true && + existing.verifiedAnnouncementNoisePublicKey?.contentEquals(authenticatedNoisePublicKey) == true && + existing.signingPublicKey?.contentEquals(state.signingPublicKey) == true + val replacement = PeerInfo( + id = peerID, + // A copied-static preannouncement cannot retain its attacker-chosen display name once + // authenticated peer state proves a different Ed key. + nickname = existing?.nickname?.takeIf { announcementMatchesAuthenticatedState } ?: peerID, + isConnected = true, + isDirectConnection = existing?.isDirectConnection ?: false, + noisePublicKey = authenticatedNoisePublicKey.copyOf(), + signingPublicKey = state.signingPublicKey.copyOf(), + isVerifiedNickname = existing?.isVerifiedNickname == true && announcementMatchesAuthenticatedState, + lastSeen = System.currentTimeMillis(), + capabilities = state.capabilities, + hasVerifiedAnnouncement = announcementMatchesAuthenticatedState, + verifiedAnnouncementNoisePublicKey = authenticatedNoisePublicKey.copyOf() + .takeIf { announcementMatchesAuthenticatedState } + ) + peers[peerID] = replacement + if (existing == null || existing != replacement) notifyPeerListUpdate() + } + + private fun updatePeerInfoInternal( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: PeerCapabilities?, + hasVerifiedAnnouncement: Boolean, + verifiedAnnouncementNoisePublicKey: ByteArray? ): Boolean { if (peerID == "unknown") return false @@ -125,6 +220,9 @@ class PeerManager { val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey) val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey) val connectedChanged = existingPeer != null && existingPeer.isConnected != true + val capabilitiesChanged = existingPeer != null && existingPeer.capabilities != capabilities + val announcementStateChanged = existingPeer != null && + existingPeer.hasVerifiedAnnouncement != hasVerifiedAnnouncement // Update or create peer info val peerInfo = PeerInfo( @@ -135,7 +233,10 @@ class PeerManager { noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, isVerifiedNickname = isVerified, - lastSeen = now + lastSeen = now, + capabilities = capabilities, + hasVerifiedAnnouncement = hasVerifiedAnnouncement, + verifiedAnnouncementNoisePublicKey = verifiedAnnouncementNoisePublicKey?.copyOf() ) peers[peerID] = peerInfo @@ -147,7 +248,8 @@ class PeerManager { val shouldNotify = when { isNewPeer && isVerified -> true wasVerified != isVerified -> true - nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true + nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged || + capabilitiesChanged || announcementStateChanged -> true else -> false } diff --git a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt new file mode 100644 index 00000000..a17d2cad --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaSecurity.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession + +internal sealed interface PrivateMediaPolicyDecision { + data class Encrypted( + val authenticatedSession: AuthenticatedNoiseSession + ) : PrivateMediaPolicyDecision + data object RequiresLegacyConsent : PrivateMediaPolicyDecision + data object NeedsHandshake : PrivateMediaPolicyDecision + data object AwaitingPeerState : PrivateMediaPolicyDecision + data class Blocked(val reason: String) : PrivateMediaPolicyDecision +} + +/** + * Binds an advertised private-media capability to a live Noise remote-static + * key and persists an HSTS-style pin by that authenticated key's SHA-256 + * fingerprint. Announcements alone can never create a pin. + */ +internal class PrivateMediaSecurityController( + private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?, + private val peerStateStatusProvider: ( + String, + AuthenticatedNoiseSession + ) -> AuthenticatedPeerStateStatus, + private val isPrivateMediaPinned: (String) -> Boolean +) { + fun sendPolicy(peerID: String): PrivateMediaPolicyDecision { + val authenticatedSession = authenticatedSessionProvider(peerID) + ?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 } + ?: return PrivateMediaPolicyDecision.NeedsHandshake + + return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) { + AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState + is AuthenticatedPeerStateStatus.Proven -> { + if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) { + PrivateMediaPolicyDecision.Encrypted(authenticatedSession) + } else if (isPrivateMediaPinned(peerID)) { + PrivateMediaPolicyDecision.Blocked( + "Encrypted private media was previously pinned, but this session proved no support; send blocked" + ) + } else { + PrivateMediaPolicyDecision.RequiresLegacyConsent + } + } + AuthenticatedPeerStateStatus.Missing -> + // A live crypto session can become visible just before its authenticated callback + // installs the coordinator generation. Never treat that gap as a watchdog timeout. + PrivateMediaPolicyDecision.AwaitingPeerState + AuthenticatedPeerStateStatus.TimedOut -> { + if (isPrivateMediaPinned(peerID)) { + PrivateMediaPolicyDecision.Blocked( + "Encrypted private media was previously pinned, but this session did not provide authenticated peer state" + ) + } else { + // A Noise-capable old client that does not know 0x21 may use explicit one-shot + // legacy consent after the five-second generation watchdog. + PrivateMediaPolicyDecision.RequiresLegacyConsent + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt new file mode 100644 index 00000000..65f21acb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/PrivateMediaTransfer.kt @@ -0,0 +1,215 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import java.util.concurrent.atomic.AtomicBoolean + +enum class PrivateMediaWireMode { + ENCRYPTED_NOISE_0X20, + SIGNED_DIRECTED_RAW_0X22 +} + +class PreparedPrivateMediaTransfer internal constructor( + val transferId: String, + val wireMode: PrivateMediaWireMode, + private val commitAction: () -> Boolean +) { + private val committed = AtomicBoolean(false) + + /** A prepared transfer is single-use, including after a failed commit. */ + fun commit(): Boolean { + if (!committed.compareAndSet(false, true)) return false + return commitAction() + } +} + +sealed interface PrivateMediaPreparation { + data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation + data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation + data object NeedsHandshake : PrivateMediaPreparation + data object AwaitingPeerState : PrivateMediaPreparation + data class Rejected(val reason: String) : PrivateMediaPreparation +} + +internal data class BuiltPrivateMediaTransfer( + val packet: BitchatPacket, + val fragments: List, + val wireMode: PrivateMediaWireMode +) + +internal sealed interface PrivateMediaBuildOutcome { + data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome + data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome + data object NeedsHandshake : PrivateMediaBuildOutcome + data object AwaitingPeerState : PrivateMediaBuildOutcome + data class Rejected(val reason: String) : PrivateMediaBuildOutcome +} + +internal sealed interface PrivateMediaEncryptionResult { + data class Success(val ciphertext: ByteArray) : PrivateMediaEncryptionResult + data object GenerationChanged : PrivateMediaEncryptionResult + data object Failed : PrivateMediaEncryptionResult +} + +/** Builds, routes, signs, and fragments exactly once before UI local echo. */ +internal class PrivateMediaTransferPreparer( + private val senderID: ByteArray, + private val ttl: UByte, + private val policyProvider: (String) -> PrivateMediaPolicyDecision, + private val encrypt: ( + ByteArray, + String, + AuthenticatedNoiseSession + ) -> PrivateMediaEncryptionResult, + private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?, + private val fragment: (BitchatPacket, Int) -> List, + private val now: () -> ULong = { System.currentTimeMillis().toULong() } +) { + fun prepare( + recipientPeerID: String, + recipientID: ByteArray, + file: BitchatFilePacket, + allowLegacyFallback: Boolean + ): PrivateMediaBuildOutcome = prepare( + recipientPeerID, + recipientID, + file, + allowLegacyFallback, + generationRetriesRemaining = 1 + ) + + private fun prepare( + recipientPeerID: String, + recipientID: ByteArray, + file: BitchatFilePacket, + allowLegacyFallback: Boolean, + generationRetriesRemaining: Int + ): PrivateMediaBuildOutcome { + val maxPrivateFragments = + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + val absolutePayloadUpperBound = + maxPrivateFragments.toLong() * + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE.toLong() + // The file content alone cannot exceed the total bytes carried by every + // possible fragment. Reject before TLV encoding, encryption, signing, + // and packet serialization make additional full-size copies. + if (file.content.size.toLong() > absolutePayloadUpperBound) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + + val policy = policyProvider(recipientPeerID) + val mode = when (policy) { + is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + PrivateMediaPolicyDecision.RequiresLegacyConsent -> { + if (!allowLegacyFallback) { + return PrivateMediaBuildOutcome.RequiresLegacyConsent( + "This older client cannot receive encrypted private media. " + + "Sending this one file will expose its contents to mesh relays, " + + "although the directed packet will still be signed." + ) + } + PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 + } + PrivateMediaPolicyDecision.NeedsHandshake -> + return PrivateMediaBuildOutcome.NeedsHandshake + PrivateMediaPolicyDecision.AwaitingPeerState -> + return PrivateMediaBuildOutcome.AwaitingPeerState + is PrivateMediaPolicyDecision.Blocked -> + return PrivateMediaBuildOutcome.Rejected(policy.reason) + } + + val filePayload = file.encode() + ?: return PrivateMediaBuildOutcome.Rejected("Failed to encode private media") + + val packet = when (mode) { + PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> { + val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode() + val encryption = try { + encrypt( + plaintext, + recipientPeerID, + (policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession + ) + } catch (_: Exception) { + PrivateMediaEncryptionResult.Failed + } + val ciphertext = when (encryption) { + is PrivateMediaEncryptionResult.Success -> encryption.ciphertext + PrivateMediaEncryptionResult.GenerationChanged -> { + if (generationRetriesRemaining > 0) { + return prepare( + recipientPeerID, + recipientID, + file, + allowLegacyFallback, + generationRetriesRemaining - 1 + ) + } + return PrivateMediaBuildOutcome.AwaitingPeerState + } + PrivateMediaEncryptionResult.Failed -> + return PrivateMediaBuildOutcome.Rejected( + "The authenticated Noise session could not encrypt this file" + ) + } + BitchatPacket( + version = if (ciphertext.size > 0xFFFF) 2u else 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = senderID.copyOf(), + recipientID = recipientID.copyOf(), + timestamp = now(), + payload = ciphertext, + signature = null, + ttl = ttl + ) + } + + PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 -> BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = senderID.copyOf(), + recipientID = recipientID.copyOf(), + timestamp = now(), + payload = filePayload, + signature = null, + ttl = ttl + ) + } + + val finalized = finalizeRoutedAndSigned(packet) + ?: return PrivateMediaBuildOutcome.Rejected( + if (mode == PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22) { + "Could not sign the legacy private-media packet; nothing was sent" + } else { + "Could not sign the encrypted private-media packet; nothing was sent" + } + ) + if (finalized.signature?.size != 64) { + return PrivateMediaBuildOutcome.Rejected( + "Could not produce a valid Ed25519 private-media signature; nothing was sent" + ) + } + + val fragments = fragment(finalized, maxPrivateFragments) + if (fragments.isEmpty()) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + if (fragments.size > maxPrivateFragments) { + return PrivateMediaBuildOutcome.Rejected( + "File exceeds the private-media v1 limit of 256 final mesh fragments" + ) + } + + return PrivateMediaBuildOutcome.Ready( + BuiltPrivateMediaTransfer(finalized, fragments, mode) + ) + } +} 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 5beb3384..45e668e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -5,6 +5,8 @@ import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* @@ -54,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) @@ -71,15 +89,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID") } - // Add to processed messages - processedMessages.add(messageID) - messageTimestamps[messageID] = currentTime - // Enforce mandatory signature verification if (!verifyPacketSignature(packet, peerID)) { Log.w(TAG, "Dropping packet from $peerID due to signature verification failure") return false } + + // Record only authenticated packets. Recording an attacker-controlled + // invalid packet first would let it poison duplicate detection for a + // later legitimate packet with the same timestamp and payload. + processedMessages.add(messageID) + messageTimestamps[messageID] = currentTime Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID") return true @@ -102,19 +122,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Skip our own handshake messages if (peerID == myPeerID) return false - // If we already have an established session but the peer is initiating a new handshake, - // drop the existing session so we can re-establish cleanly. - var forcedRehandshake = false - if (encryptionService.hasEstablishedSession(peerID)) { - Log.d(TAG, "Received new Noise handshake from $peerID with an existing session. Dropping old session to re-handshake.") - try { - encryptionService.removePeer(peerID) - forcedRehandshake = true - } catch (e: Exception) { - Log.w(TAG, "Failed to remove existing Noise session for $peerID: ${e.message}") - } - } - if (packet.payload.isEmpty()) { Log.w(TAG, "Noise handshake packet has empty payload") return false @@ -123,26 +130,46 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Prevent duplicate handshake processing val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}" - if (!forcedRehandshake && processedKeyExchanges.contains(exchangeKey)) { + if (processedKeyExchanges.contains(exchangeKey)) { Log.d(TAG, "Already processed handshake: $exchangeKey") return false } Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)") - processedKeyExchanges.add(exchangeKey) try { - // Process the Noise handshake through the updated EncryptionService - val response = encryptionService.processHandshakeMessage(packet.payload, peerID) + // The session manager preserves an existing transport in a separate responder-candidate + // flow and reports whether this exact frame completed authentication. Never infer that + // from ambient session state: a rejected replacement may leave the old session active. + val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) + processedKeyExchanges.add(exchangeKey) - if (response != null) { + if (result.response != null) { Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response") // Send handshake response through delegate - delegate?.sendHandshakeResponse(peerID, response) + delegate?.sendHandshakeResponse(peerID, result.response) } - // Check if session is now established (handshake complete) - if (encryptionService.hasEstablishedSession(peerID)) { + if (result.establishedNow) { + val authenticatedRemoteStaticKey = result.authenticatedRemoteStaticKey + if (authenticatedRemoteStaticKey == null) { + Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key") + return false + } + val authenticatedSessionToken = result.authenticatedSessionToken + if (authenticatedSessionToken?.size != 32 || + authenticatedSessionToken.all { it == 0.toByte() } + ) { + Log.e(TAG, "Bound Noise completion for $peerID omitted its generation token") + return false + } + val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS Log.d(TAG, "✅ Noise handshake completed with $peerID") - delegate?.onKeyExchangeCompleted(peerID, packet.payload) + delegate?.onKeyExchangeCompleted( + peerID = peerID, + authenticatedRemoteStaticKey = authenticatedRemoteStaticKey, + authenticatedSessionToken = authenticatedSessionToken, + directRelayAddress = routed.relayAddress.takeIf { isDirectIngress }, + ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress } + ) } return true @@ -154,21 +181,13 @@ class SecurityManager(private val encryptionService: EncryptionService, private } /** - * Verify packet signature + * Verify a packet signature against the signing key learned from the + * peer's verified announcement. Signatures cover the canonical packet, + * not only its payload; otherwise routing and recipient fields could be + * changed without invalidating the signature. */ fun verifySignature(packet: BitchatPacket, peerID: String): Boolean { - return packet.signature?.let { signature -> - try { - val isValid = encryptionService.verify(signature, packet.payload, peerID) - if (!isValid) { - Log.w(TAG, "Invalid signature for packet from $peerID") - } - isValid - } catch (e: Exception) { - Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}") - false - } - } ?: true // No signature means verification passes + return verifyPacketSignature(packet, peerID) } /** @@ -194,13 +213,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private null } } + + fun encryptForPeer( + data: ByteArray, + recipientPeerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray? = try { + encryptionService.encryptForSession(data, recipientPeerID, expectedSession) + } catch (e: Exception) { + Log.e(TAG, "Noise generation changed before encrypting for $recipientPeerID: ${e.message}") + null + } /** * Decrypt payload from specific peer */ - fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? { + fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? { return try { - encryptionService.decrypt(encryptedData, senderPeerID) + encryptionService.decryptWithSession(encryptedData, senderPeerID) } catch (e: Exception) { Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}") null @@ -237,14 +267,52 @@ class SecurityManager(private val encryptionService: EncryptionService, private */ private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { try { - // only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER + // Public packets that mutate identity, presence, or user-visible state must prove the + // signing key learned from a verified announcement. LEAVE is included so an attacker + // cannot evict a claimed peer or amplify a forged departure through relay. if (MessageType.fromValue(packet.type) !in setOf( MessageType.ANNOUNCE, MessageType.MESSAGE, - MessageType.FILE_TRANSFER + MessageType.FILE_TRANSFER, + MessageType.LEAVE )) { return true } + + if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) { + val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key -> + encryptionService.verifyEd25519Signature(signature, data, key) + } ?: run { + Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from $peerID") + return false + } + + val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey) + if (persistedSigningKey != null && + !persistedSigningKey.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state for $peerID") + return false + } + + val existingPeer = delegate?.getPeerInfo(peerID) + if ( + existingPeer?.noisePublicKey != null && + !existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE Noise-key replacement for $peerID") + return false + } + if ( + existingPeer?.signingPublicKey != null && + !existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey) + ) { + Log.w(TAG, "Rejecting ANNOUNCE signing-key replacement without authenticated peer state for $peerID") + return false + } + return true + } + // 1. Mandatory Signature Check if (packet.signature == null) { Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") @@ -252,21 +320,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private } // 2. Get Signing Public Key - var signingPublicKey: ByteArray? = null - - if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) { - // Special Case: ANNOUNCE packets carry their own signing key - try { - val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload) - signingPublicKey = announcement?.signingPublicKey - } catch (e: Exception) { - Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}") - } - } else { - // Standard Case: Get key from known peer info - val peerInfo = delegate?.getPeerInfo(peerID) - signingPublicKey = peerInfo?.signingPublicKey - } + val peerInfo = delegate?.getPeerInfo(peerID) + val signingPublicKey = peerInfo?.signingPublicKey if (signingPublicKey == null) { // If we don't have a key (and it's not an announce), we can't verify. @@ -416,7 +471,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private * Delegate interface for security manager callbacks */ interface SecurityManagerDelegate { - fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) + fun onKeyExchangeCompleted( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + authenticatedSessionToken: ByteArray, + directRelayAddress: String?, + ingressLinkID: String? + ) fun sendHandshakeResponse(peerID: String, response: ByteArray) fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification + fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null } 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 1b3e51af..ca49def9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -130,6 +130,41 @@ class UnifiedMeshService( } } + override fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): PrivateMediaPreparation { + return when { + isBleReady(recipientPeerID) -> bluetooth.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + isWifiReady(recipientPeerID) -> wifiService()?.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) ?: PrivateMediaPreparation.Rejected("Wi-Fi Aware transport is unavailable") + isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) -> + bluetooth.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + else -> wifiService()?.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) ?: PrivateMediaPreparation.Rejected("No local transport is available for this peer") + } + } + override fun cancelFileTransfer(transferId: String): Boolean { val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false } val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false } @@ -324,6 +359,10 @@ class UnifiedMeshService( delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + override fun didResolvePrivateMediaPolicy(peerID: String) { + delegate?.didResolvePrivateMediaPolicy(peerID) + } + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return delegate?.decryptChannelMessage(encryptedContent, channel) } diff --git a/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt new file mode 100644 index 00000000..0dcb23fc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt @@ -0,0 +1,86 @@ +package com.bitchat.android.model + +/** + * Canonical payload carried inside Noise payload type 0x21. + * + * Wire format: + * `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]` + * `[type=0x02][len=32][Ed25519 public key]` + * + * Unknown TLVs are skipped. Both known fields must occur exactly once. + */ +data class AuthenticatedPeerState( + val capabilities: PeerCapabilities, + val signingPublicKey: ByteArray +) { + init { + require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) { + "Ed25519 public key must be 32 bytes" + } + } + + fun encode(): ByteArray { + val capabilityBytes = capabilities.encoded() + return buildList(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) { + add(VERSION.toByte()) + add(CAPABILITIES_TLV.toByte()) + add(capabilityBytes.size.toByte()) + addAll(capabilityBytes.toList()) + add(SIGNING_PUBLIC_KEY_TLV.toByte()) + add(SIGNING_PUBLIC_KEY_SIZE.toByte()) + addAll(signingPublicKey.toList()) + }.toByteArray() + } + + companion object { + const val VERSION = 0x01 + private const val CAPABILITIES_TLV = 0x01 + private const val SIGNING_PUBLIC_KEY_TLV = 0x02 + private const val SIGNING_PUBLIC_KEY_SIZE = 32 + + fun decode(data: ByteArray): AuthenticatedPeerState? { + if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null + var offset = 1 + var capabilities: PeerCapabilities? = null + var signingPublicKey: ByteArray? = null + + while (offset < data.size) { + if (offset + 2 > data.size) return null + val type = data[offset].toInt() and 0xFF + val length = data[offset + 1].toInt() and 0xFF + offset += 2 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + CAPABILITIES_TLV -> { + if (capabilities != null || length !in 1..8) return null + val decoded = PeerCapabilities.decode(value) + if (!decoded.encoded().contentEquals(value)) return null + capabilities = decoded + } + + SIGNING_PUBLIC_KEY_TLV -> { + if (signingPublicKey != null || length != SIGNING_PUBLIC_KEY_SIZE) return null + signingPublicKey = value + } + + else -> Unit + } + } + + val decodedCapabilities = capabilities ?: return null + val decodedSigningKey = signingPublicKey ?: return null + return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey) + } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is AuthenticatedPeerState && + capabilities == other.capabilities && + signingPublicKey.contentEquals(other.signingPublicKey)) + + override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt index b902222b..70e550a6 100644 --- a/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt +++ b/app/src/main/java/com/bitchat/android/model/FragmentPayload.kt @@ -83,6 +83,9 @@ data class FragmentPayload( * Matches iOS implementation exactly */ fun encode(): ByteArray { + require(index in 0..0xFFFF) { "Fragment index would truncate UInt16: $index" } + require(total in 1..0xFFFF) { "Fragment total would truncate UInt16: $total" } + require(index < total) { "Fragment index $index must be below total $total" } val payload = ByteArray(HEADER_SIZE + data.size) // Fragment ID (8 bytes) diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c2..c48bd6cc 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -2,7 +2,6 @@ package com.bitchat.android.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import com.bitchat.android.util.* /** * Identity announcement structure with TLV encoding @@ -12,7 +11,9 @@ import com.bitchat.android.util.* data class IdentityAnnouncement( val nickname: String, val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) - val signingPublicKey: ByteArray // Ed25519 public key for signing + val signingPublicKey: ByteArray, // Ed25519 public key for signing + val capabilities: PeerCapabilities? = null, + val unknownTLVs: List = emptyList() ) : Parcelable { /** @@ -21,7 +22,8 @@ data class IdentityAnnouncement( private enum class TLVType(val value: UByte) { NICKNAME(0x01u), NOISE_PUBLIC_KEY(0x02u), - SIGNING_PUBLIC_KEY(0x03u); // NEW: Ed25519 signing public key + SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key + CAPABILITIES(0x05u); companion object { fun fromValue(value: UByte): TLVType? { @@ -37,7 +39,8 @@ data class IdentityAnnouncement( val nicknameData = nickname.toByteArray(Charsets.UTF_8) // Check size limits - if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) { + if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 || + unknownTLVs.any { it.value.size > 255 }) { return null } @@ -57,6 +60,21 @@ data class IdentityAnnouncement( result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte()) result.add(signingPublicKey.size.toByte()) result.addAll(signingPublicKey.toList()) + + // Optional little-endian feature bitfield. Old clients skip this TLV. + capabilities?.encoded()?.let { capabilityBytes -> + result.add(TLVType.CAPABILITIES.value.toByte()) + result.add(capabilityBytes.size.toByte()) + result.addAll(capabilityBytes.toList()) + } + + // Preserve extensions this build does not understand. This includes + // gossip TLV 0x04 when an announcement is decoded through this model. + unknownTLVs.forEach { tlv -> + result.add(tlv.type.toByte()) + result.add(tlv.value.size.toByte()) + result.addAll(tlv.value.toList()) + } return result.toByteArray() } @@ -73,6 +91,8 @@ data class IdentityAnnouncement( var nickname: String? = null var noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null + var capabilities: PeerCapabilities? = null + val unknownTLVs = mutableListOf() while (offset + 2 <= dataCopy.size) { // Read TLV type @@ -102,20 +122,36 @@ data class IdentityAnnouncement( TLVType.SIGNING_PUBLIC_KEY -> { signingPublicKey = value } + TLVType.CAPABILITIES -> { + capabilities = PeerCapabilities.decode(value) + } null -> { - // Unknown TLV; skip (tolerant decoder for forward compatibility) - continue + // Retain unknown extensions so callers can forward or + // re-encode the announcement without erasing them. + unknownTLVs += UnknownAnnouncementTLV(typeValue.toInt(), value) } } } // All three fields are required return if (nickname != null && noisePublicKey != null && signingPublicKey != null) { - IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey) + IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs) } else { null } } + + /** Construct the announcement emitted by this Android build. */ + fun forLocalPeer( + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray + ): IdentityAnnouncement = IdentityAnnouncement( + nickname = nickname, + noisePublicKey = noisePublicKey, + signingPublicKey = signingPublicKey, + capabilities = PeerCapabilities.LOCAL_SUPPORTED + ) } // Override equals and hashCode since we use ByteArray @@ -128,6 +164,8 @@ data class IdentityAnnouncement( if (nickname != other.nickname) return false if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false + if (capabilities != other.capabilities) return false + if (unknownTLVs != other.unknownTLVs) return false return true } @@ -136,10 +174,12 @@ data class IdentityAnnouncement( var result = nickname.hashCode() result = 31 * result + noisePublicKey.contentHashCode() result = 31 * result + signingPublicKey.contentHashCode() + result = 31 * result + (capabilities?.hashCode() ?: 0) + result = 31 * result + unknownTLVs.hashCode() return result } override fun toString(): String { - return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)" + return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})" } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index c46a114f..cba647a7 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -23,12 +23,23 @@ enum class NoisePayloadType(val value: UByte) { DELIVERED(0x03u), // Message was delivered VERIFY_CHALLENGE(0x10u), // Verification challenge VERIFY_RESPONSE(0x11u), // Verification response - FILE_TRANSFER(0x20u); + FILE_TRANSFER(0x20u), + /** Authenticated capabilities + Ed25519 binding for the current Noise generation. */ + PEER_STATE(0x21u); companion object { + // #1434 prerelease iOS builds briefly emitted private files as 0x09. Keep this + // decode-only: every NoisePayload constructed by Android still encodes FILE_TRANSFER as + // its canonical 0x20 value, so the compatibility alias cannot leak into new traffic. + private val PRERELEASE_FILE_TRANSFER_RAW_VALUE = 0x09u.toUByte() + fun fromValue(value: UByte): NoisePayloadType? { - return values().find { it.value == value } + return if (value == PRERELEASE_FILE_TRANSFER_RAW_VALUE) { + FILE_TRANSFER + } else { + values().find { it.value == value } + } } } } diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt new file mode 100644 index 00000000..d53f3d20 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Feature bits advertised in IdentityAnnouncement TLV 0x05. + * + * The wire representation matches iOS: a minimal little-endian bitfield that + * always contains at least one byte. Unknown bits in the low 64 bits are kept + * so a decode/re-encode cycle does not erase capabilities added by newer + * clients. + */ +@Parcelize +data class PeerCapabilities(val rawValue: Long) : Parcelable { + fun contains(capability: PeerCapabilities): Boolean = + (rawValue and capability.rawValue) == capability.rawValue + + fun encoded(): ByteArray { + var remaining = rawValue + val bytes = mutableListOf() + do { + bytes += remaining.toByte() + remaining = remaining ushr 8 + } while (remaining != 0L) + return bytes.toByteArray() + } + + companion object { + val NONE = PeerCapabilities(0) + + /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ + val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + + /** Capabilities implemented by this Android build. */ + val LOCAL_SUPPORTED = PRIVATE_MEDIA + + /** + * Decode the low 64 bits and ignore any future extension bytes, which + * is the same forward-compatible behavior used by iOS. + */ + fun decode(data: ByteArray): PeerCapabilities { + var rawValue = 0L + data.take(8).forEachIndexed { index, byte -> + rawValue = rawValue or ((byte.toLong() and 0xFF) shl (8 * index)) + } + return PeerCapabilities(rawValue) + } + } +} + +/** An announcement TLV not understood by this build, retained verbatim. */ +@Parcelize +class UnknownAnnouncementTLV( + val type: Int, + val value: ByteArray +) : Parcelable { + init { + require(type in 0..0xFF) { "TLV type must fit in one byte" } + } + + override fun equals(other: Any?): Boolean = + this === other || + (other is UnknownAnnouncementTLV && type == other.type && value.contentEquals(other.value)) + + override fun hashCode(): Int = 31 * type + value.contentHashCode() +} diff --git a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt index b2c0ef45..a139d6df 100644 --- a/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RoutedPacket.kt @@ -10,5 +10,10 @@ data class RoutedPacket( val packet: BitchatPacket, val peerID: String? = null, // Who sent it (parsed from packet.senderID) val relayAddress: String? = null, // Address it came from (for avoiding loopback) - val transferId: String? = null // Optional stable transfer ID for progress tracking + val transferId: String? = null, // Optional stable transfer ID for progress tracking + /** Exact fragments admitted during private-media prepare; never rebuild them at commit. */ + val preparedPackets: List? = null, + // Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement + // sockets for the same provisional peer and must never be serialized onto the mesh. + val ingressLinkID: String? = null ) diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 5ca8df28..ecb25d45 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -149,6 +149,15 @@ class NoiseEncryptionService(private val context: Context) { fun getPeerPublicKeyData(peerID: String): ByteArray? { return sessionManager.getRemoteStaticKey(peerID) } + + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? = + sessionManager.getAuthenticatedSession(peerID) + + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean = sessionManager.withAuthenticatedSession(peerID, expectedSession, action) /** * Clear persistent identity (for panic mode) @@ -179,9 +188,9 @@ class NoiseEncryptionService(private val context: Context) { * Initiate a Noise handshake with a peer * Returns the first handshake message to send */ - fun initiateHandshake(peerID: String): ByteArray? { + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { return try { - sessionManager.initiateHandshake(peerID) + sessionManager.initiateHandshake(peerID, replaceEstablished) } catch (e: Exception) { Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}") null @@ -194,12 +203,25 @@ class NoiseEncryptionService(private val context: Context) { */ fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? { return try { - sessionManager.processHandshakeMessage(peerID, data) + processHandshakeMessageWithResult(data, peerID).response } catch (e: Exception) { Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}") null } } + + /** + * Typed handshake result for security-sensitive callers that must distinguish a null response + * from a newly authenticated session or a rejected handshake. Identity mismatch exceptions are + * intentionally propagated to the caller. + */ + @Throws(Exception::class) + fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + return sessionManager.processHandshakeMessageWithResult(peerID, data) + } /** * Check if we have an established session with a peer @@ -234,6 +256,13 @@ class NoiseEncryptionService(private val context: Context) { null } } + + @Throws(Exception::class) + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray = sessionManager.encryptForSession(data, peerID, expectedSession) /** * Decrypt data from a specific peer using established Noise session @@ -251,6 +280,14 @@ class NoiseEncryptionService(private val context: Context) { null } } + + fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult? = + try { + sessionManager.decryptWithSession(encryptedData, peerID) + } catch (e: Exception) { + Log.e(TAG, "Failed generation-bound decryption from $peerID: ${e.message}") + null + } // MARK: - Peer Management @@ -382,6 +419,20 @@ class NoiseEncryptionService(private val context: Context) { // Store fingerprint mapping via centralized manager // This is the ONLY place where fingerprints are stored - after successful Noise handshake fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey) + + // Preserve the canonical peerID -> npub index, but only after Noise proves possession of + // the static key. Announcement-time indexing was unsafe because a peer can copy another + // party's public Noise key without possessing its private key. + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .findNostrPubkey(remoteStaticKey) + ?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .updateNostrPublicKeyForPeerID(peerID, npub) + } + } catch (_: Exception) { + // Favorites may not be initialized in isolated/background crypto tests. + } // Calculate fingerprint for logging and callback val fingerprint = calculateFingerprint(remoteStaticKey) diff --git a/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt new file mode 100644 index 00000000..1a9b4da0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/noise/NoisePeerIdentity.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.noise + +import java.security.MessageDigest + +/** + * Canonical binding between an authenticated Noise static key and its mesh wire identity. + * + * Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase + * hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not + * sufficient on its own: the claimed wire ID must also match this derivation. + */ +object NoisePeerIdentity { + const val STATIC_PUBLIC_KEY_SIZE = 32 + const val WIRE_PEER_ID_LENGTH = 16 + + private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$") + + fun derivePeerID(staticPublicKey: ByteArray): String? { + if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null + return MessageDigest.getInstance("SHA-256") + .digest(staticPublicKey) + .take(8) + .joinToString("") { "%02x".format(it) } + } + + fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean { + if (!wirePeerIDPattern.matches(claimedPeerID)) return false + return derivePeerID(staticPublicKey) == claimedPeerID + } +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt index 4eab42bb..cb2654dd 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSession.kt @@ -451,27 +451,36 @@ class NoiseSession( Log.d(TAG, "Completing XX handshake with $peerID") try { - // Split handshake state into transport ciphers - val cipherPair = handshakeState?.split() - - sendCipher = cipherPair?.getSender() - receiveCipher = cipherPair?.getReceiver() - - // Extract remote static key if available - if (handshakeState?.hasRemotePublicKey() == true) { - val remoteDH = handshakeState?.getRemotePublicKey() - if (remoteDH != null) { - remoteStaticPublicKey = ByteArray(32) - remoteDH.getPublicKey(remoteStaticPublicKey!!, 0) - Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}") - } + val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed + + // Authenticate the remote static key's claimed mesh identity before split creates + // transport ciphers or the session can become observable as Established. + if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed + val remoteDH = activeHandshake.getRemotePublicKey() + ?: throw NoiseSessionError.HandshakeFailed + val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE) + remoteDH.getPublicKey(authenticatedRemoteKey, 0) + val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey) + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) { + authenticatedRemoteKey.fill(0) + throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID) } + remoteStaticPublicKey = authenticatedRemoteKey + Log.d(TAG, "Remote static public key is bound to $peerID") + + // Only a bound remote identity may derive transport ciphers. + val cipherPair = activeHandshake.split() + sendCipher = cipherPair.getSender() + receiveCipher = cipherPair.getReceiver() // Extract handshake hash for channel binding - handshakeHash = handshakeState?.getHandshakeHash() + // getHandshakeHash() exposes the handshake state's backing array. Clone it before + // destroy() zeroizes that state, or every completed session appears to have the same + // all-zero channel-binding token. + handshakeHash = activeHandshake.getHandshakeHash().clone() // Clean up handshake state - handshakeState?.destroy() + activeHandshake.destroy() handshakeState = null messagesSent = 0 diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index 618f26be..fa577ff3 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -3,6 +3,42 @@ package com.bitchat.android.noise import android.util.Log import java.util.concurrent.ConcurrentHashMap +data class NoiseHandshakeProcessingResult( + val response: ByteArray?, + val establishedNow: Boolean, + /** The bound remote static key only when this exact call completed authentication. */ + val authenticatedRemoteStaticKey: ByteArray? = null, + /** Handshake hash identifying that exact authenticated Noise generation. */ + val authenticatedSessionToken: ByteArray? = null +) + +/** Atomic snapshot of the live authenticated Noise generation. */ +class AuthenticatedNoiseSession( + remoteStaticKey: ByteArray, + sessionToken: ByteArray +) { + private val remoteStaticKeyBytes = remoteStaticKey.copyOf() + private val sessionTokenBytes = sessionToken.copyOf() + + val remoteStaticKey: ByteArray get() = remoteStaticKeyBytes.copyOf() + val sessionToken: ByteArray get() = sessionTokenBytes.copyOf() + + override fun equals(other: Any?): Boolean = + this === other || + (other is AuthenticatedNoiseSession && + remoteStaticKeyBytes.contentEquals(other.remoteStaticKeyBytes) && + sessionTokenBytes.contentEquals(other.sessionTokenBytes)) + + override fun hashCode(): Int = + 31 * remoteStaticKeyBytes.contentHashCode() + sessionTokenBytes.contentHashCode() +} + +/** Plaintext and generation binding captured atomically from the session that decrypted it. */ +data class NoiseDecryptionResult( + val plaintext: ByteArray, + val authenticatedSession: AuthenticatedNoiseSession +) + /** * SIMPLIFIED Noise session manager - focuses on core functionality only */ @@ -16,9 +52,13 @@ class NoiseSessionManager( private const val TAG = "NoiseSessionManager" private const val HANDSHAKE_TIMEOUT_MS = 20_000L private const val HANDSHAKE_MESSAGE_1_SIZE = 32 + private const val SESSION_TOKEN_SIZE = 32 } private val sessions = ConcurrentHashMap() + // An inbound replacement handshake must prove its authenticated static-key binding before it + // can evict a working transport session. Keep responder candidates outside the active map. + private val responderCandidates = ConcurrentHashMap() // Callbacks var onSessionEstablished: ((String, ByteArray) -> Unit)? = null @@ -29,8 +69,10 @@ class NoiseSessionManager( /** * Add new session for a peer */ + @Synchronized fun addSession(peerID: String, session: NoiseSession) { - sessions[peerID] = session + val previous = sessions.put(peerID, session) + if (previous != null && previous !== session) previous.destroy() Log.d(TAG, "Added new session for $peerID") } @@ -45,16 +87,18 @@ class NoiseSessionManager( /** * Remove session for a peer */ + @Synchronized fun removeSession(peerID: String) { - sessions[peerID]?.destroy() - sessions.remove(peerID) + sessions.remove(peerID)?.destroy() + responderCandidates.remove(peerID)?.destroy() Log.d(TAG, "Removed session for $peerID") } /** * SIMPLIFIED: Initiate handshake - no tie breaker, just start */ - fun initiateHandshake(peerID: String): ByteArray? { + @Synchronized + fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? { Log.d(TAG, "initiateHandshake($peerID)") val now = System.currentTimeMillis() @@ -62,8 +106,20 @@ class NoiseSessionManager( if (existing != null) { when { existing.isEstablished() -> { - Log.d(TAG, "Handshake already established with $peerID, skipping initiate") - return null + if (!replaceEstablished) { + Log.d(TAG, "Handshake already established with $peerID, skipping initiate") + return null + } + val candidate = createSession(peerID, isInitiator = true) + responderCandidates.remove(peerID)?.destroy() + responderCandidates[peerID] = candidate + return try { + candidate.startHandshake() + } catch (e: Exception) { + responderCandidates.remove(peerID, candidate) + candidate.destroy() + throw e + } } existing.isHandshaking() -> { if (!isHandshakeStale(existing, now)) { @@ -94,7 +150,7 @@ class NoiseSessionManager( Log.d(TAG, "Started handshake with $peerID as INITIATOR") return handshakeData } catch (e: Exception) { - sessions.remove(peerID) + if (sessions.remove(peerID, session)) session.destroy() throw e } } @@ -103,62 +159,156 @@ class NoiseSessionManager( * Handle incoming handshake message */ fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? { - Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)") - - try { - var session = getSession(peerID) + return processHandshakeMessageWithResult(peerID, message).response + } - // Collision handling: both sides initiated and we received message 1 - if (session != null && - session.isHandshaking() && - session.isInitiatorRole() && - message.size == HANDSHAKE_MESSAGE_1_SIZE - ) { - val shouldYield = localPeerID > peerID - if (shouldYield) { - Log.d(TAG, "Handshake collision with $peerID; yielding to responder role") - removeSession(peerID) - session = null + @Synchronized + fun processHandshakeMessageWithResult( + peerID: String, + message: ByteArray + ): NoiseHandshakeProcessingResult { + Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)") + + var activeSession: NoiseSession? = null + var isReplacementCandidate = false + var establishedRemoteKey: ByteArray? = null + var establishedSessionToken: ByteArray? = null + var response: ByteArray? = null + + try { + val existingCandidate = responderCandidates[peerID] + if (existingCandidate != null) { + activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) { + if (existingCandidate.isInitiatorRole()) { + val shouldYield = localPeerID > peerID + if (!shouldYield) { + Log.d( + TAG, + "Replacement handshake collision with $peerID; keeping initiator role" + ) + return NoiseHandshakeProcessingResult( + response = null, + establishedNow = false + ) + } + Log.d( + TAG, + "Replacement handshake collision with $peerID; yielding to responder role" + ) + } + responderCandidates.remove(peerID, existingCandidate) + existingCandidate.destroy() + createSession(peerID, isInitiator = false).also { + responderCandidates[peerID] = it + } } else { - Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") - return null + existingCandidate + } + isReplacementCandidate = true + } else { + var session = getSession(peerID) + + // Collision handling: both sides initiated and we received message 1. + if (session != null && + session.isHandshaking() && + session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE + ) { + val shouldYield = localPeerID > peerID + if (shouldYield) { + Log.d(TAG, "Handshake collision with $peerID; yielding to responder role") + if (sessions.remove(peerID, session)) session.destroy() + session = null + } else { + Log.d(TAG, "Handshake collision with $peerID; keeping initiator role") + return NoiseHandshakeProcessingResult(response = null, establishedNow = false) + } + } + + activeSession = when { + session == null -> { + Log.d(TAG, "Creating new RESPONDER session for $peerID") + createSession(peerID, isInitiator = false).also { sessions[peerID] = it } + } + session.isEstablished() -> { + Log.d( + TAG, + "Validating replacement handshake for $peerID while preserving active session" + ) + isReplacementCandidate = true + createSession(peerID, isInitiator = false).also { + responderCandidates[peerID] = it + } + } + session.isHandshaking() && + !session.isInitiatorRole() && + message.size == HANDSHAKE_MESSAGE_1_SIZE -> { + // A restarted responder handshake can replace an incomplete session because + // there is no working transport state to preserve. + if (sessions.remove(peerID, session)) session.destroy() + createSession(peerID, isInitiator = false).also { sessions[peerID] = it } + } + else -> session } } - - // If no session exists, create one as responder - if (session == null) { - Log.d(TAG, "Creating new RESPONDER session for $peerID") - session = NoiseSession( - peerID = peerID, - isInitiator = false, - localStaticPrivateKey = localStaticPrivateKey, - localStaticPublicKey = localStaticPublicKey - ) - addSession(peerID, session) - } - - // Process handshake message - val response = session.processHandshakeMessage(message) - - // Check if session is established + + val session = activeSession ?: throw NoiseSessionError.InvalidState + + response = session.processHandshakeMessage(message) + if (session.isEstablished()) { - Log.d(TAG, "✅ Session ESTABLISHED with $peerID") val remoteStaticKey = session.getRemoteStaticPublicKey() - if (remoteStaticKey != null) { - onSessionEstablished?.invoke(peerID, remoteStaticKey) + ?: throw NoiseSessionError.HandshakeFailed + val derivedPeerID = NoisePeerIdentity.derivePeerID(remoteStaticKey) + if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStaticKey)) { + throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID) } + + val sessionToken = session.getHandshakeHash() + ?.takeIf { it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } } + ?: throw NoiseSessionError.HandshakeFailed + + if (isReplacementCandidate) { + responderCandidates.remove(peerID, session) + val previous = sessions.put(peerID, session) + if (previous != null && previous !== session) previous.destroy() + } + + establishedRemoteKey = remoteStaticKey + establishedSessionToken = sessionToken + Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID") } - - return response - } catch (e: Exception) { + val session = activeSession + if (session != null) { + if (isReplacementCandidate) { + responderCandidates.remove(peerID, session) + } else { + sessions.remove(peerID, session) + } + session.destroy() + } Log.e(TAG, "Handshake failed with $peerID: ${e.message}") - sessions.remove(peerID) - onSessionFailed?.invoke(peerID, e) + runCatching { onSessionFailed?.invoke(peerID, e) } throw e } + + establishedRemoteKey?.let { onSessionEstablished?.invoke(peerID, it) } + return NoiseHandshakeProcessingResult( + response = response, + establishedNow = establishedRemoteKey != null, + authenticatedRemoteStaticKey = establishedRemoteKey?.clone(), + authenticatedSessionToken = establishedSessionToken?.clone() + ) } + private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession = NoiseSession( + peerID = peerID, + isInitiator = isInitiator, + localStaticPrivateKey = localStaticPrivateKey, + localStaticPublicKey = localStaticPublicKey + ) + private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean { val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs() if (lastActivity == null) return false @@ -168,6 +318,7 @@ class NoiseSessionManager( /** * SIMPLIFIED: Encrypt data */ + @Synchronized fun encrypt(data: ByteArray, peerID: String): ByteArray { val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID") if (!session.isEstablished()) { @@ -175,11 +326,29 @@ class NoiseSessionManager( } return session.encrypt(data) } + + /** Encrypt only if the exact generation that authorized the operation is still active. */ + @Synchronized + fun encryptForSession( + data: ByteArray, + peerID: String, + expectedSession: AuthenticatedNoiseSession + ): ByteArray { + val session = getSession(peerID) ?: throw NoiseSessionError.SessionNotFound + if (!session.isEstablished()) throw NoiseSessionError.SessionNotEstablished + val current = authenticatedSession(session) ?: throw NoiseSessionError.SessionNotEstablished + if (current != expectedSession) throw NoiseSessionError.SessionGenerationChanged + return session.encrypt(data) + } /** * SIMPLIFIED: Decrypt data */ - fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray { + fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray = + decryptWithSession(encryptedData, peerID).plaintext + + @Synchronized + fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult { val session = getSession(peerID) if (session == null) { Log.e(TAG, "No session found for $peerID when trying to decrypt") @@ -189,7 +358,10 @@ class NoiseSessionManager( Log.e(TAG, "Session not established with $peerID when trying to decrypt") throw IllegalStateException("Session not established with $peerID") } - return session.decrypt(encryptedData) + val plaintext = session.decrypt(encryptedData) + val authenticatedSession = authenticatedSession(session) + ?: throw IllegalStateException("Established session for $peerID has no channel binding") + return NoiseDecryptionResult(plaintext, authenticatedSession) } /** @@ -211,15 +383,42 @@ class NoiseSessionManager( /** * Get remote static public key for a peer (if session established) */ - fun getRemoteStaticKey(peerID: String): ByteArray? { - return getSession(peerID)?.getRemoteStaticPublicKey() + fun getRemoteStaticKey(peerID: String): ByteArray? = + getAuthenticatedSession(peerID)?.remoteStaticKey + + @Synchronized + fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? { + val session = getSession(peerID) ?: return null + if (!session.isEstablished()) return null + return authenticatedSession(session) + } + + /** Execute a state transition while preventing replacement/removal of the expected session. */ + @Synchronized + fun withAuthenticatedSession( + peerID: String, + expectedSession: AuthenticatedNoiseSession, + action: () -> Boolean + ): Boolean { + val session = getSession(peerID) ?: return false + if (!session.isEstablished()) return false + if (authenticatedSession(session) != expectedSession) return false + return action() } /** * Get handshake hash for channel binding (if session established) */ fun getHandshakeHash(peerID: String): ByteArray? { - return getSession(peerID)?.getHandshakeHash() + return getAuthenticatedSession(peerID)?.sessionToken + } + + private fun authenticatedSession(session: NoiseSession): AuthenticatedNoiseSession? { + val remoteStaticKey = session.getRemoteStaticPublicKey()?.takeIf { it.size == 32 } ?: return null + val sessionToken = session.getHandshakeHash()?.takeIf { + it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } + } ?: return null + return AuthenticatedNoiseSession(remoteStaticKey, sessionToken) } /** @@ -239,6 +438,7 @@ class NoiseSessionManager( fun getDebugInfo(): String = buildString { appendLine("=== Noise Session Manager Debug ===") appendLine("Active sessions: ${sessions.size}") + appendLine("Responder candidates: ${responderCandidates.size}") appendLine("") if (sessions.isNotEmpty()) { @@ -252,9 +452,12 @@ class NoiseSessionManager( /** * Shutdown manager and clean up all sessions */ + @Synchronized fun shutdown() { sessions.values.forEach { it.destroy() } + responderCandidates.values.forEach { it.destroy() } sessions.clear() + responderCandidates.clear() Log.d(TAG, "Noise session manager shut down") } } @@ -268,4 +471,8 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce object InvalidState : NoiseSessionError("Session in invalid state") object HandshakeFailed : NoiseSessionError("Handshake failed") object AlreadyEstablished : NoiseSessionError("Session already established") + object SessionGenerationChanged : NoiseSessionError("Noise session generation changed") + class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( + "Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID" + ) } 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 cf2e038c..9d8ae8af 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -216,7 +216,8 @@ class NostrDirectMessageHandler( } } NoisePayloadType.VERIFY_CHALLENGE, - NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages + NoisePayloadType.VERIFY_RESPONSE, + NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index 9422be0e..f8b10766 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -75,7 +75,15 @@ object TransportBridgeService { val targets = transports.filterKeys { it != sourceId } if (targets.isEmpty()) return val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return - val forwarded = packet.copy(packet = forwardedPacket) + // Prepared private-media fragments must remain the admitted plan when + // crossing transports, but relay TTL still has to advance on every + // hop. TTL is excluded from the signature and does not affect size. + val forwarded = packet.copy( + packet = forwardedPacket, + preparedPackets = packet.preparedPackets?.map { prepared -> + prepared.copy(ttl = forwardedPacket.ttl) + } + ) // Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}") diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index bc83a9f9..78d5b571 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -59,6 +59,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() + val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -344,6 +345,33 @@ fun ChatScreen(viewModel: ChatViewModel) { showMeshPeerListSheet = showMeshPeerListSheet, onMeshPeerListDismiss = viewModel::hideMeshPeerList, ) + + legacyPrivateMediaConsent?.let { request -> + AlertDialog( + onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) }, + title = { Text(stringResource(com.bitchat.android.R.string.private_media_legacy_title)) }, + text = { + Text( + stringResource( + com.bitchat.android.R.string.private_media_legacy_body, + request.fileName, + request.recipientNickname, + request.warning + ) + ) + }, + confirmButton = { + TextButton(onClick = { viewModel.approveLegacyPrivateMedia(request.requestId) }) { + Text(stringResource(com.bitchat.android.R.string.private_media_legacy_send_once)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.cancelLegacyPrivateMedia(request.requestId) }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } } @Composable 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 5d5641d7..4eb32739 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -65,6 +65,14 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + fun approveLegacyPrivateMedia(requestId: String) { + mediaSendingManager.approveLegacyPrivateMedia(requestId) + } + + fun cancelLegacyPrivateMedia(requestId: String) { + mediaSendingManager.cancelLegacyPrivateMedia(requestId) + } + fun getCurrentNpub(): String? { return try { NostrIdentityBridge @@ -121,7 +129,12 @@ class ChatViewModel( val verifiedFingerprints = verificationHandler.verifiedFingerprints // Media file sending manager - private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh } + private val mediaSendingManager = MediaSendingManager( + state, + messageManager, + channelManager, + viewModelScope + ) { mesh } // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -182,6 +195,8 @@ class ChatViewModel( val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer val showVerificationSheet: StateFlow = state.showVerificationSheet val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet + val legacyPrivateMediaConsent: StateFlow = + mediaSendingManager.legacyPrivateMediaConsent val selectedLocationChannel: StateFlow = state.selectedLocationChannel val isTeleported: StateFlow = state.isTeleported val geohashPeople: StateFlow> = state.geohashPeople @@ -919,6 +934,10 @@ class ChatViewModel( override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { verificationHandler.didReceiveVerifyResponse(peerID, payload) } + + override fun didResolvePrivateMediaPolicy(peerID: String) { + mediaSendingManager.retryPendingPrivateMedia(peerID) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -936,6 +955,10 @@ class ChatViewModel( fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + + // A pending one-shot downgrade confirmation must not survive panic or + // become actionable against the fresh post-wipe identity. + mediaSendingManager.clearPendingPrivateMediaConsent() // Clear all UI managers messageManager.clearAllMessages() diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index b7e87f91..d6c9d7e2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -5,8 +5,26 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.mesh.PrivateMediaPreparation import java.util.Date import java.security.MessageDigest +import java.util.UUID +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class LegacyPrivateMediaConsentRequest( + val requestId: String, + val recipientNickname: String, + val fileName: String, + val warning: String +) /** * Handles media file sending operations (voice notes, images, generic files) @@ -16,6 +34,8 @@ class MediaSendingManager( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, + private val scope: CoroutineScope, + private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO, private val getMeshService: () -> MeshService ) { // Helper to get current mesh service (may change after panic clear) @@ -24,35 +44,78 @@ class MediaSendingManager( companion object { private const val TAG = "MediaSendingManager" private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit + private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L } // Track in-flight transfer progress: transferId -> messageId and reverse private val transferMessageMap = mutableMapOf() private val messageTransferMap = mutableMapOf() + private val pendingConsentLock = Any() + private val _legacyPrivateMediaConsent = MutableStateFlow(null) + val legacyPrivateMediaConsent: StateFlow = + _legacyPrivateMediaConsent.asStateFlow() + + private data class PendingPrivateMedia( + val request: LegacyPrivateMediaConsentRequest, + val peerID: String, + val filePacket: BitchatFilePacket, + val filePath: String, + val messageType: BitchatMessageType, + val transferId: String + ) + + private var pendingPrivateMedia: PendingPrivateMedia? = null + + private data class PendingAutomaticPrivateMedia( + val requestId: String, + val peerID: String, + val filePacket: BitchatFilePacket, + val filePath: String, + val messageType: BitchatMessageType, + val transferId: String, + val allowLegacyFallback: Boolean + ) + + private var pendingAutomaticPrivateMedia: PendingAutomaticPrivateMedia? = null + private var evaluatingAutomaticRequestId: String? = null + private var automaticRetryRequestedFor: String? = null + private var pendingAutomaticTimeoutRequestId: String? = null /** * Send a voice note (audio file) */ fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { - try { - val file = java.io.File(filePath) - if (!file.exists()) { - Log.e(TAG, "❌ File does not exist: $filePath") - return - } - Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") - - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") - return - } + scope.launch { + sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } - val filePacket = BitchatFilePacket( - fileName = file.name, - fileSize = file.length(), - mimeType = "audio/mp4", - content = file.readBytes() - ) + private suspend fun sendVoiceNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { + try { + val filePacket = withContext(mediaWorkDispatcher) { + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "❌ File does not exist: $filePath") + return@withContext null + } + Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") + + if (file.length() > MAX_FILE_SIZE) { + Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + return@withContext null + } + + BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "audio/mp4", + content = file.readBytes() + ) + } ?: return if (toPeerIDOrNull != null) { sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio) @@ -68,26 +131,38 @@ class MediaSendingManager( * Send an image file */ fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { - try { - Log.d(TAG, "🔄 Starting image send: $filePath") - val file = java.io.File(filePath) - if (!file.exists()) { - Log.e(TAG, "❌ File does not exist: $filePath") - return - } - Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") - - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") - return - } + scope.launch { + sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } - val filePacket = BitchatFilePacket( - fileName = file.name, - fileSize = file.length(), - mimeType = "image/jpeg", - content = file.readBytes() - ) + private suspend fun sendImageNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { + try { + val filePacket = withContext(mediaWorkDispatcher) { + Log.d(TAG, "🔄 Starting image send: $filePath") + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "❌ File does not exist: $filePath") + return@withContext null + } + Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") + + if (file.length() > MAX_FILE_SIZE) { + Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + return@withContext null + } + + BitchatFilePacket( + fileName = file.name, + fileSize = file.length(), + mimeType = "image/jpeg", + content = file.readBytes() + ) + } ?: return if (toPeerIDOrNull != null) { sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image) @@ -106,49 +181,67 @@ class MediaSendingManager( * Send a generic file */ fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { + scope.launch { + sendFileNoteAsync(toPeerIDOrNull, channelOrNull, filePath) + } + } + + private suspend fun sendFileNoteAsync( + toPeerIDOrNull: String?, + channelOrNull: String?, + filePath: String + ) { try { - Log.d(TAG, "🔄 Starting file send: $filePath") - val file = java.io.File(filePath) - if (!file.exists()) { - Log.e(TAG, "❌ File does not exist: $filePath") - return - } - Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") - - if (file.length() > MAX_FILE_SIZE) { - Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") - return - } + val filePacket = withContext(mediaWorkDispatcher) { + Log.d(TAG, "🔄 Starting file send: $filePath") + val file = java.io.File(filePath) + if (!file.exists()) { + Log.e(TAG, "❌ File does not exist: $filePath") + return@withContext null + } + Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}") - // Use the real MIME type based on extension; fallback to octet-stream - val mimeType = try { - com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name) - } catch (_: Exception) { - "application/octet-stream" - } - Log.d(TAG, "🏷️ MIME type: $mimeType") + if (file.length() > MAX_FILE_SIZE) { + Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)") + return@withContext null + } - // Try to preserve the original file name if our copier prefixed it earlier - val originalName = run { - val name = file.name - val base = name.substringBeforeLast('.') - val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" } - val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base - stripped + ext - } - Log.d(TAG, "📝 Original filename: $originalName") + // Use the real MIME type based on extension; fallback to octet-stream + val mimeType = try { + com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name) + } catch (_: Exception) { + "application/octet-stream" + } + Log.d(TAG, "🏷️ MIME type: $mimeType") - val filePacket = BitchatFilePacket( - fileName = originalName, - fileSize = file.length(), - mimeType = mimeType, - content = file.readBytes() - ) + // Try to preserve the original file name if our copier prefixed it earlier + val originalName = run { + val name = file.name + val base = name.substringBeforeLast('.') + val ext = name.substringAfterLast('.', "").let { + if (it.isNotBlank()) ".${it}" else "" + } + val stripped = Regex("^send_\\d+_(.+)$") + .matchEntire(base) + ?.groupValues + ?.getOrNull(1) + ?: base + stripped + ext + } + Log.d(TAG, "📝 Original filename: $originalName") + + BitchatFilePacket( + fileName = originalName, + fileSize = file.length(), + mimeType = mimeType, + content = file.readBytes() + ) + } ?: return Log.d(TAG, "📦 Created file packet successfully") val messageType = when { - mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image - mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio + filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image + filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio else -> BitchatMessageType.File } @@ -168,26 +261,324 @@ class MediaSendingManager( /** * Send a file privately (encrypted) */ - private fun sendPrivateFile( + private suspend fun sendPrivateFile( toPeerID: String, filePacket: BitchatFilePacket, filePath: String, messageType: BitchatMessageType ) { - val payload = filePacket.encode() - if (payload == null) { - Log.e(TAG, "❌ Failed to encode file packet for private send") - return - } + val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } + ?: run { + Log.e(TAG, "❌ Failed to encode file packet for private send") + return + } Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes") - val transferId = sha256Hex(payload) - val contentHash = sha256Hex(filePacket.content) + val (transferId, contentHash) = withContext(mediaWorkDispatcher) { + sha256Hex(payload) to sha256Hex(filePacket.content) + } Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…") + val pending = PendingAutomaticPrivateMedia( + requestId = UUID.randomUUID().toString(), + peerID = toPeerID, + filePacket = filePacket, + filePath = filePath, + messageType = messageType, + transferId = transferId, + allowLegacyFallback = false + ) + if (!reserveAutomaticPending(pending)) { + addPrivateMediaSystemMessage( + toPeerID, + "Private media was not sent because another secure media send is still pending." + ) + return + } + evaluateAutomaticPending(pending) + } + + /** + * Consume consent exactly once, then re-run policy and final-packet + * admission. A capability pin appearing while the dialog was open upgrades + * this send to encrypted rather than forcing the legacy path. + */ + fun approveLegacyPrivateMedia(requestId: String) { + scope.launch { + approveLegacyPrivateMediaAsync(requestId) + } + } + + private suspend fun approveLegacyPrivateMediaAsync(requestId: String) { + val pending = consumePendingConsent(requestId) ?: return + val automatic = PendingAutomaticPrivateMedia( + requestId = UUID.randomUUID().toString(), + peerID = pending.peerID, + filePacket = pending.filePacket, + filePath = pending.filePath, + messageType = pending.messageType, + transferId = pending.transferId, + allowLegacyFallback = true + ) + if (!reserveAutomaticPending(automatic)) { + addPrivateMediaSystemMessage( + pending.peerID, + "Private media was not sent because another secure media send is still pending." + ) + return + } + evaluateAutomaticPending(automatic) + } + + fun cancelLegacyPrivateMedia(requestId: String) { + consumePendingConsent(requestId) + } + + fun clearPendingPrivateMediaConsent() { + synchronized(pendingConsentLock) { + pendingPrivateMedia = null + pendingAutomaticPrivateMedia = null + evaluatingAutomaticRequestId = null + automaticRetryRequestedFor = null + pendingAutomaticTimeoutRequestId = null + _legacyPrivateMediaConsent.value = null + } + } + + /** Retry the exact first-send intent after peer-state proof or watchdog resolution. */ + fun retryPendingPrivateMedia(peerID: String) { + scope.launch { retryPendingPrivateMediaOnScope(peerID) } + } + + private suspend fun retryPendingPrivateMediaOnScope(peerID: String) { + val pending = synchronized(pendingConsentLock) { + pendingAutomaticPrivateMedia + ?.takeIf { it.peerID == peerID } + } ?: return + evaluateAutomaticPending(pending) + } + + private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) { + val acquired = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) { + return@synchronized false + } + if (evaluatingAutomaticRequestId == pending.requestId) { + automaticRetryRequestedFor = pending.requestId + return@synchronized false + } + evaluatingAutomaticRequestId = pending.requestId + true + } + if (!acquired) return + + while (true) { + val preparation = try { + withContext(mediaWorkDispatcher) { + meshService.prepareFilePrivate( + recipientPeerID = pending.peerID, + file = pending.filePacket, + transferId = pending.transferId, + allowLegacyFallback = pending.allowLegacyFallback + ) + } + } catch (error: Exception) { + PrivateMediaPreparation.Rejected( + error.message ?: "Secure private-media preparation failed" + ) + } + val stillCurrent = synchronized(pendingConsentLock) { + pendingAutomaticPrivateMedia?.requestId == pending.requestId + } + if (stillCurrent) handlePrivatePreparation(preparation, pending) + + val rerun = synchronized(pendingConsentLock) { + if (evaluatingAutomaticRequestId == pending.requestId) { + evaluatingAutomaticRequestId = null + } + val requested = automaticRetryRequestedFor == pending.requestId && + pendingAutomaticPrivateMedia?.requestId == pending.requestId + if (automaticRetryRequestedFor == pending.requestId) { + automaticRetryRequestedFor = null + } + if (requested) evaluatingAutomaticRequestId = pending.requestId + requested + } + if (!rerun) return + } + } + + private fun handlePrivatePreparation( + preparation: PrivateMediaPreparation, + pending: PendingAutomaticPrivateMedia + ) { + when (preparation) { + is PrivateMediaPreparation.Ready -> { + clearAutomaticPending(pending.requestId) + commitPreparedPrivateFile( + preparation, + pending.peerID, + pending.filePath, + pending.messageType, + pending.transferId + ) + } + + is PrivateMediaPreparation.RequiresLegacyConsent -> { + clearAutomaticPending(pending.requestId) + if (pending.allowLegacyFallback) { + Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted") + addPrivateMediaSystemMessage( + pending.peerID, + "Private media was not sent because its security policy changed." + ) + return + } + val nickname = try { + meshService.getPeerNicknames()[pending.peerID] + } catch (_: Exception) { + null + } ?: pending.peerID.take(8) + val request = LegacyPrivateMediaConsentRequest( + requestId = UUID.randomUUID().toString(), + recipientNickname = nickname, + fileName = pending.filePacket.fileName, + warning = preparation.warning + ) + synchronized(pendingConsentLock) { + if (pendingPrivateMedia != null) { + Log.w(TAG, "A legacy private-media consent prompt is already pending") + return + } + pendingPrivateMedia = PendingPrivateMedia( + request, + pending.peerID, + pending.filePacket, + pending.filePath, + pending.messageType, + pending.transferId + ) + _legacyPrivateMediaConsent.value = request + } + } + + PrivateMediaPreparation.NeedsHandshake -> { + ensureAutomaticPendingTimeout(pending) + Log.i(TAG, "Private media needs a Noise handshake; retaining first-send intent") + try { + meshService.initiateNoiseHandshake(pending.peerID) + } catch (e: Exception) { + Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}") + } + } + + PrivateMediaPreparation.AwaitingPeerState -> { + ensureAutomaticPendingTimeout(pending) + Log.i(TAG, "Private media is waiting for authenticated peer state; first-send intent retained") + } + + is PrivateMediaPreparation.Rejected -> { + clearAutomaticPending(pending.requestId) + Log.w(TAG, "Private media not sent: ${preparation.reason}") + addPrivateMediaSystemMessage( + pending.peerID, + "Private media was not sent: ${preparation.reason}" + ) + } + } + } + + private fun reserveAutomaticPending(pending: PendingAutomaticPrivateMedia): Boolean = + synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia != null) return@synchronized false + pendingAutomaticPrivateMedia = pending + true + } + + private fun ensureAutomaticPendingTimeout(pending: PendingAutomaticPrivateMedia) { + val shouldStart = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId || + pendingAutomaticTimeoutRequestId == pending.requestId + ) return@synchronized false + pendingAutomaticTimeoutRequestId = pending.requestId + true + } + if (!shouldStart) return + scope.launch { + delay(PENDING_PRIVATE_MEDIA_TIMEOUT_MS) + val expired = synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) false + else { + pendingAutomaticPrivateMedia = null + if (automaticRetryRequestedFor == pending.requestId) { + automaticRetryRequestedFor = null + } + if (pendingAutomaticTimeoutRequestId == pending.requestId) { + pendingAutomaticTimeoutRequestId = null + } + true + } + } + if (expired) { + addPrivateMediaSystemMessage( + pending.peerID, + "Private media was not sent because secure session setup timed out." + ) + } + } + } + + private fun clearAutomaticPending(requestId: String) { + synchronized(pendingConsentLock) { + if (pendingAutomaticPrivateMedia?.requestId == requestId) { + pendingAutomaticPrivateMedia = null + } + if (automaticRetryRequestedFor == requestId) automaticRetryRequestedFor = null + if (pendingAutomaticTimeoutRequestId == requestId) { + pendingAutomaticTimeoutRequestId = null + } + } + } + + private fun addPrivateMediaSystemMessage(peerID: String, text: String) { + messageManager.addPrivateMessageNoUnread( + peerID, + BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + ) + } + + private fun consumePendingConsent(requestId: String): PendingPrivateMedia? { + return synchronized(pendingConsentLock) { + val pending = pendingPrivateMedia + if (pending?.request?.requestId != requestId) return@synchronized null + pendingPrivateMedia = null + _legacyPrivateMediaConsent.value = null + pending + } + } + + private fun commitPreparedPrivateFile( + preparation: PrivateMediaPreparation.Ready, + toPeerID: String, + filePath: String, + messageType: BitchatMessageType, + transferId: String + ) { + if (preparation.transfer.transferId != transferId) { + Log.e(TAG, "Prepared private-media transfer ID changed; send aborted") + return + } + val msg = BitchatMessage( - id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message + id = UUID.randomUUID().toString().uppercase(), sender = state.getNicknameValue() ?: "me", content = filePath, type = messageType, @@ -197,43 +588,54 @@ class MediaSendingManager( recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null }, senderPeerID = meshService.myPeerID ) - + + // Preparation already built and admitted the exact final packet. Map + // progress before commit so the first asynchronous event cannot race us. messageManager.addPrivateMessage(toPeerID, msg) - synchronized(transferMessageMap) { transferMessageMap[transferId] = msg.id messageTransferMap[msg.id] = transferId } - - // Seed progress so delivery icons render for media messageManager.updateMessageDeliveryStatus( msg.id, com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) ) - - Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") - meshService.sendFilePrivate(toPeerID, filePacket) - Log.d(TAG, "✅ File send completed successfully") + + if (!preparation.transfer.commit()) { + messageManager.removeMessageById(msg.id) + synchronized(transferMessageMap) { + transferMessageMap.remove(transferId) + messageTransferMap.remove(msg.id) + } + Log.w(TAG, "Prepared private-media commit failed; local echo rolled back") + addPrivateMediaSystemMessage( + toPeerID, + "Private media was not sent because the prepared transfer could not be committed." + ) + return + } + Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}") } /** * Send a file publicly (broadcast or channel) */ - private fun sendPublicFile( + private suspend fun sendPublicFile( channelOrNull: String?, filePacket: BitchatFilePacket, filePath: String, messageType: BitchatMessageType ) { - val payload = filePacket.encode() - if (payload == null) { - Log.e(TAG, "❌ Failed to encode file packet for broadcast send") - return - } + val payload = withContext(mediaWorkDispatcher) { filePacket.encode() } + ?: run { + Log.e(TAG, "❌ Failed to encode file packet for broadcast send") + return + } Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes") - - val transferId = sha256Hex(payload) - val contentHash = sha256Hex(filePacket.content) + + val (transferId, contentHash) = withContext(mediaWorkDispatcher) { + sha256Hex(payload) to sha256Hex(filePacket.content) + } Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…") @@ -266,7 +668,9 @@ class MediaSendingManager( ) Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") - meshService.sendFileBroadcast(filePacket) + withContext(mediaWorkDispatcher) { + meshService.sendFileBroadcast(filePacket) + } Log.d(TAG, "✅ File broadcast completed successfully") } 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 new file mode 100644 index 00000000..d522c812 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt @@ -0,0 +1,39 @@ +package com.bitchat.android.wifiaware + +/** + * Resolves an authenticated callback to the exact still-active ingress link that completed Noise. + * 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?, + links: Map>, + currentTransportForRelay: (String) -> T? + ): Link? { + val linkID = authenticatedLinkID ?: return null + val relayAddress = authenticatedRelayAddress ?: return null + val link = links[linkID] ?: return null + if (link.relayAddress != relayAddress) return null + return link.takeIf { currentTransportForRelay(relayAddress) === it.transport } + } +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt index 115dbb05..9376b078 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt @@ -23,6 +23,7 @@ class WifiAwareConnectionTracker( // Active resources per peer val peerSockets = ConcurrentHashMap() private val socketAliases = ConcurrentHashMap() + private val socketBindingLock = Any() val serverSockets = ConcurrentHashMap() val networkCallbacks = ConcurrentHashMap() @@ -32,24 +33,26 @@ class WifiAwareConnectionTracker( } override fun disconnect(id: String) { - Log.d(TAG, "Disconnecting peer $id") - val canonicalId = resolveCanonicalPeerId(id) - - // 1. Close client socket - peerSockets.remove(canonicalId)?.let { - try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } - } - socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } + synchronized(socketBindingLock) { + Log.d(TAG, "Disconnecting peer $id") + val canonicalId = resolveCanonicalPeerId(id) - // 2. Close server socket - serverSockets.remove(canonicalId)?.let { - try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } - } + // 1. Close client socket + peerSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") } + } + socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId } - // Ensure any pending/active network request is explicitly released - releaseNetworkRequest(canonicalId) - removePendingConnection(id) - removePendingConnection(canonicalId) + // 2. Close server socket + serverSockets.remove(canonicalId)?.let { + try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") } + } + + // Ensure any pending/active network request is explicitly released + releaseNetworkRequest(canonicalId) + removePendingConnection(id) + removePendingConnection(canonicalId) + } } fun releaseNetworkRequest(id: String) { @@ -71,12 +74,16 @@ class WifiAwareConnectionTracker( * Successfully established a client connection */ fun onClientConnected(peerId: String, socket: SyncedSocket) { - // Close previous socket if one exists to prevent zombie readers - peerSockets[peerId]?.let { - try { it.close() } catch (_: Exception) {} + synchronized(socketBindingLock) { + val canonicalPeerId = resolveCanonicalPeerId(peerId) + // Close previous socket if one exists to prevent zombie readers + peerSockets[canonicalPeerId]?.let { + try { it.close() } catch (_: Exception) {} + } + peerSockets[canonicalPeerId] = socket + removePendingConnection(peerId) // Clear retry state on success + if (canonicalPeerId != peerId) removePendingConnection(canonicalPeerId) } - peerSockets[peerId] = socket - removePendingConnection(peerId) // Clear retry state on success } fun getSocketForPeer(peerId: String): SyncedSocket? { @@ -87,6 +94,37 @@ class WifiAwareConnectionTracker( fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId) fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String { + return synchronized(socketBindingLock) { + rebindPeerIdLocked(previousPeerId, resolvedPeerId, socket) + } + } + + /** + * Atomically require that [expectedSocket] is still the active provisional transport and, only + * then, promote it. This closes the gap where a replacement socket could land after validation + * but before mutation and the stale authenticated socket would become canonical. + */ + fun rebindPeerIdIfCurrent( + previousPeerId: String, + resolvedPeerId: String, + expectedSocket: SyncedSocket + ): Boolean = synchronized(socketBindingLock) { + val previousCanonical = resolveCanonicalPeerId(previousPeerId) + if (peerSockets[previousCanonical] !== expectedSocket) return@synchronized false + val resolvedCanonical = resolveCanonicalPeerId(resolvedPeerId) + val existingResolvedSocket = peerSockets[resolvedCanonical] + if (existingResolvedSocket != null && existingResolvedSocket !== expectedSocket) { + return@synchronized false + } + rebindPeerIdLocked(previousPeerId, resolvedPeerId, expectedSocket) + true + } + + private fun rebindPeerIdLocked( + previousPeerId: String, + resolvedPeerId: String, + socket: SyncedSocket + ): String { if (previousPeerId == resolvedPeerId) { peerSockets[resolvedPeerId] = socket return resolvedPeerId 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 36eaea1c..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 @@ -42,6 +42,7 @@ import java.net.ServerSocket import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -74,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 @@ -121,6 +123,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Transport state private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm) + private val ingressLinks = ConcurrentHashMap< + 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 @@ -167,8 +177,40 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor onAnnounceProcessed = { routed, _ -> routed.peerID?.let { pid -> try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + + // 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. A fresh challenge is sent through the exact transport + // generation, and only its same-link completion may promote that alias. + val relay = routed.relayAddress + 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)}" + ) + } + } + } } }, + onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ -> + promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID) + }, announcementNicknameProvider = { try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null } }, @@ -562,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() @@ -606,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 @@ -1241,6 +1289,85 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor else -> myPeerID < peerId } + /** + * Promote a provisional discovery alias only when the exact, still-active socket delivered the + * Noise frame that completed authentication for the canonical peer ID. + */ + private fun promoteAuthenticatedIngressLink( + canonicalPeerId: String, + 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, + links = ingressLinks, + currentTransportForRelay = connectionTracker::getSocketForPeer + ) ?: run { + Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched") + return + } + + 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 + } + if (existingCanonical != provisionalPeerId) { + Log.w( + TAG, + "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on an existing alias" + ) + return + } + + if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) { + Log.w( + TAG, + "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed before rebind" + ) + return + } + authenticatedWifiLinks[canonicalPeerId] = + AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID) + handleToPeerId.forEach { (handle, peerId) -> + if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId + } + subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it } + publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it } + val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis() + discoveredTimestamps[canonicalPeerId] = discoveredAt + + try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { } + try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { } + try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } + try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } + try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + + Log.i( + TAG, + "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)} on exact ingress link" + ) + } + /** * Listens for incoming packets from a connected peer and dispatches them through * the packet processor. @@ -1249,7 +1376,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor * @param initialLogicalPeerId Temporary identifier before peer ID resolution */ private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) { - var logicalPeerId = initialLogicalPeerId + val logicalPeerId = initialLogicalPeerId + val ingressLinkID = UUID.randomUUID().toString() + val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket) + ingressLinks[ingressLinkID] = ingressLink while (isActive) { val raw = socket.read() ?: break @@ -1263,29 +1393,24 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) { - val previousPeerId = logicalPeerId - logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket) - handleToPeerId.forEach { (handle, peerId) -> - if (peerId == previousPeerId) { - handleToPeerId[handle] = senderPeerHex - } - } - subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it } - discoveredTimestamps.remove(previousPeerId) - discoveredTimestamps[senderPeerHex] = System.currentTimeMillis() - try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { } - try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { } - try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { } - publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it } - Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}") + // The socket's discovery identity remains provisional until Noise proves possession + // of the claimed static key on this link. A canonical self-signed announcement is + // only TOFU and cannot safely rebind/remove transport state on its own. + Log.w( + TAG, + "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof" + ) } // Route the packet: // - peerID = Originator (who signed it) // - relayAddress = Neighbor (who sent it to us over this socket) Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})") - meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId) + meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID) } + + 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.") @@ -1293,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") @@ -1399,6 +1546,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendFilePrivate(recipientPeerID, file) } + override fun prepareFilePrivate( + recipientPeerID: String, + file: BitchatFilePacket, + transferId: String, + allowLegacyFallback: Boolean + ): com.bitchat.android.mesh.PrivateMediaPreparation = meshCore.prepareFilePrivate( + recipientPeerID, + file, + transferId, + allowLegacyFallback + ) + /** * Attempts to cancel an in-flight file transfer identified by its transferId. * @@ -1570,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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27a5c5e5..1ece95a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + bitchat Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. @@ -88,6 +88,11 @@ End-to-end encrypted Handshake failed + + Send without end-to-end encryption? + %1$s cannot be sent encrypted to %2$s because their client is older. %3$s + Send this file once + 📎 File Received 📄 %1$s diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt index 4dce6d7f..bf3e3d01 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -5,6 +5,8 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.FragmentPayload import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -180,6 +182,68 @@ class FragmentManagerTest { assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload)) } + @Test + fun `inbound fragment set above 256 is rejected`() { + val payload = FragmentPayload( + fragmentID = ByteArray(8) { 1 }, + index = 0, + total = 257, + originalType = MessageType.NOISE_ENCRYPTED.value, + data = byteArrayOf(1) + ).encode() + val packet = BitchatPacket( + version = 1u, + type = MessageType.FRAGMENT.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = 1u, + payload = payload, + ttl = 7u + ) + + assertNull(fragmentManager.handleFragment(packet)) + } + + @Test + fun `fragment payload refuses UInt16 truncation`() { + assertThrows(IllegalArgumentException::class.java) { + FragmentPayload( + fragmentID = ByteArray(8) { 2 }, + index = 0, + total = 65_536, + originalType = MessageType.NOISE_ENCRYPTED.value, + data = byteArrayOf(1) + ).encode() + } + } + + @Test + fun `generic public packet retains a 257 fragment outbound plan`() { + val randomPayload = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) } + + fun plan(contentSize: Int): List = fragmentManager.createFragments( + BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(senderID), + recipientID = com.bitchat.android.protocol.SpecialRecipients.BROADCAST, + timestamp = 1u, + payload = randomPayload.copyOf(contentSize), + signature = ByteArray(64) { 7 }, + ttl = 7u + ) + ) + + var low = 1 + var high = randomPayload.size + while (low < high) { + val mid = low + (high - low) / 2 + if (plan(mid).size >= 257) high = mid else low = mid + 1 + } + + assertEquals(257, plan(low).size) + } + private fun hexStringToByteArray(hexString: String): ByteArray { val result = ByteArray(8) for (i in 0 until 8) { diff --git a/app/src/test/kotlin/android/util/Log.kt b/app/src/test/kotlin/android/util/Log.kt index 703cbcb8..1d0bd3f1 100644 --- a/app/src/test/kotlin/android/util/Log.kt +++ b/app/src/test/kotlin/android/util/Log.kt @@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int { return 0; } +fun e(tag: String, msg: String, throwable: Throwable): Int { + println("ERROR: $tag: $msg (${throwable.message})") + return 0; +} + fun w(tag: String, msg: String): Int { println("WARN: $tag: $msg") return 0; @@ -25,4 +30,4 @@ fun v(tag: String, msg: String): Int { fun i(tag: String, msg: String): Int { println("INFO: $tag: $msg") return 0; -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt index d9c3897e..90021c52 100644 --- a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt @@ -1,6 +1,7 @@ package com.bitchat import com.bitchat.android.mesh.PeerManager +import com.bitchat.android.model.PeerCapabilities import junit.framework.TestCase.assertEquals import org.junit.Test @@ -31,6 +32,71 @@ class PeerManagerTest { val emptyDeviceAddresses = emptyMap() + @Test + fun peer_capabilities_are_retained_with_verified_identity() { + val capabilities = PeerCapabilities( + PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15) + ) + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID = "peer-capabilities", + nickname = "alice", + noisePublicKey = ByteArray(32) { 1 }, + signingPublicKey = ByteArray(32) { 2 }, + isVerified = true, + capabilities = capabilities + ) + + assertEquals(capabilities, peerManager.getPeerInfo("peer-capabilities")?.capabilities) + } + + @Test + fun normal_peer_updates_preserve_signed_absent_and_empty_capabilities() { + val peerID = "peer-capability-state" + val noiseKey = ByteArray(32) { 3 } + val signingKey = ByteArray(32) { 4 } + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "alice", + noiseKey, + signingKey, + true, + null + ) + var info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(null, info.capabilities) + assertEquals(true, info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey)) + + peerManager.updatePeerInfo(peerID, "alice2", noiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(null, info.capabilities) + + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "alice2", + noiseKey, + signingKey, + true, + PeerCapabilities.NONE + ) + peerManager.updatePeerInfo(peerID, "alice3", noiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(true, info.hasVerifiedAnnouncement) + assertEquals(PeerCapabilities.NONE, info.capabilities) + + val changedNoiseKey = ByteArray(32) { 7 } + peerManager.updatePeerInfo(peerID, "alice4", changedNoiseKey, signingKey, true) + info = peerManager.getPeerInfo(peerID)!! + assertEquals(PeerCapabilities.NONE, info.capabilities) + assertEquals( + true, + info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey) + ) + } + val testRSSI = mapOf( "peer1" to 0, "peer2" to 10, @@ -257,4 +323,4 @@ class PeerManagerTest { assertEquals(expectedLine2, actualLine2) } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt new file mode 100644 index 00000000..6d865582 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.identity + +import android.content.Context +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class PrivateMediaCapabilityPinPersistenceTest { + private val fingerprint = "ab".repeat(32) + private lateinit var manager: SecureIdentityStateManager + private lateinit var prefs: android.content.SharedPreferences + + @Before + fun setup() { + prefs = RuntimeEnvironment.getApplication().getSharedPreferences( + "private-media-pin-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + manager = SecureIdentityStateManager(prefs, testOnly = true) + manager.clearIdentityData() + } + + @After + fun tearDown() { + manager.clearIdentityData() + } + + @Test + fun `authenticated Ed key and capabilities persist rotate and clear atomically`() { + val firstKey = ByteArray(32) { 0x11 } + val rotatedKey = ByteArray(32) { 0x22 } + assertTrue(manager.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey) + )) + + val reloaded = SecureIdentityStateManager(prefs, testOnly = true) + assertArrayEquals(firstKey, reloaded.getAuthenticatedSigningKey(fingerprint)) + assertEquals( + PeerCapabilities.PRIVATE_MEDIA, + reloaded.getAuthenticatedPeerState(fingerprint)?.capabilities + ) + assertTrue(reloaded.isPrivateMediaCapable(fingerprint)) + + assertTrue(reloaded.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.NONE, rotatedKey) + )) + assertArrayEquals(rotatedKey, reloaded.getAuthenticatedSigningKey(fingerprint)) + // HSTS-style private-media history is not erased by a no-bit proof. + assertTrue(reloaded.isPrivateMediaCapable(fingerprint)) + + reloaded.clearIdentityData() + assertFalse(manager.storeAuthenticatedPeerState( + fingerprint, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey) + )) + val afterPanic = SecureIdentityStateManager(prefs, testOnly = true) + assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint)) + assertFalse(afterPanic.isPrivateMediaCapable(fingerprint)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt new file mode 100644 index 00000000..81ba5f7a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthenticatedBleLinkPolicyTest { + private val claim = AuthenticatedBleLinkPolicy.Claim( + deviceAddress = "AA:BB:CC:DD:EE:FF", + linkID = "connection-a" + ) + + @Test + fun `accepts completion from exact claimed connection`() { + assertTrue( + AuthenticatedBleLinkPolicy.matches( + claim, + authenticatedAddress = claim.deviceAddress, + authenticatedLinkID = claim.linkID + ) + ) + } + + @Test + fun `rejects replacement connection reusing device address`() { + assertFalse( + AuthenticatedBleLinkPolicy.matches( + claim, + authenticatedAddress = claim.deviceAddress, + authenticatedLinkID = "connection-b" + ) + ) + } + + @Test + fun `rejects completion on another address or without a claim`() { + assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID)) + assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt new file mode 100644 index 00000000..7598a96b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt @@ -0,0 +1,299 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoisePeerIdentity +import java.security.MessageDigest +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthenticatedPeerStateCoordinatorTest { + private class MemoryStore : AuthenticatedPeerStateStore { + val states = mutableMapOf() + val pins = mutableSetOf() + + override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint] + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean { + states[fingerprint] = state + if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) pins += fingerprint + onCommitted() + return true + } + override fun isPrivateMediaPinned(fingerprint: String): Boolean = fingerprint in pins + } + + private val remoteStatic = ByteArray(32) { 0x33 } + private val peerID = NoisePeerIdentity.derivePeerID(remoteStatic)!! + private val firstSession = AuthenticatedNoiseSession( + remoteStatic, + ByteArray(32) { 0x71 } + ) + private val secondSession = AuthenticatedNoiseSession( + remoteStatic, + ByteArray(32) { 0x72 } + ) + private val localState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x44 }) + private val remoteState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x55 }) + + @Test + fun `every generation emits and first valid proof echoes exactly once`() { + val store = MemoryStore() + val sent = CopyOnWriteArrayList() + val applied = CopyOnWriteArrayList() + var activeSession = firstSession + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, key, state -> + assertArrayEquals(remoteStatic, key) + applied += state + }, + sendState = { _, state, _ -> sent.add(state) }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + assertEquals(1, sent.size) + assertTrue(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(2, sent.size) + assertTrue(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals("Repeated proof must not echo again", 2, sent.size) + assertEquals(1, applied.size) + + val different = AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 0x66 }) + assertFalse( + "A generation cannot replace its first proof", + coordinator.receive(peerID, different, firstSession) + ) + + activeSession = secondSession + // Policy can observe the crypto swap before its completion callback. It must adopt the + // new token as Awaiting instead of reusing gen-N Proven state. + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertEquals(3, sent.size) + coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken) + assertEquals(3, sent.size) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertTrue(coordinator.receive(peerID, different, secondSession)) + assertEquals(4, sent.size) + assertEquals(2, applied.size) + } + + @Test + fun `live generation is adopted once and watchdog is never reset by policy reads`() = runBlocking { + val sent = CopyOnWriteArrayList() + val resolutions = CopyOnWriteArrayList() + val coordinator = AuthenticatedPeerStateCoordinator( + scope = this, + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, state, _ -> sent += state; true }, + onResolution = resolutions::add, + proofTimeoutMs = 20 + ) + + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + delay(10) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + delay(25) + + assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession)) + assertEquals(1, sent.size) + assertEquals(listOf(peerID), resolutions) + } + + @Test + fun `delayed old completion cannot replace a newer tracked generation`() { + val sentTokens = CopyOnWriteArrayList() + var activeSession = secondSession + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, _, session -> sentTokens += session.sessionToken; true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession)) + assertEquals(1, sentTokens.size) + assertArrayEquals(secondSession.sessionToken, sentTokens.single()) + } + + @Test + fun `watchdog resolves no-proof generation without trusting persisted prior state`() = runBlocking { + val store = MemoryStore() + store.states[fingerprint(remoteStatic)] = remoteState + val resolutions = CopyOnWriteArrayList() + val coordinator = AuthenticatedPeerStateCoordinator( + scope = this, + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, _, _ -> true }, + onResolution = resolutions::add, + proofTimeoutMs = 20 + ) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + delay(50) + + assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession)) + assertEquals(listOf(peerID), resolutions) + } + + @Test + fun `copied-static preannounce Ed key is replaced by authenticated proof`() { + val peerManager = PeerManager() + val attackerEd = ByteArray(32) { 0x11 } + val victimEd = ByteArray(32) { 0x22 } + peerManager.updatePeerInfoFromVerifiedAnnouncement( + peerID, + "attacker-name", + remoteStatic, + attackerEd, + true, + PeerCapabilities.PRIVATE_MEDIA + ) + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = MemoryStore(), + localStateProvider = { localState }, + applyAuthenticatedState = peerManager::applyAuthenticatedPeerState, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertTrue( + coordinator.receive( + peerID, + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, victimEd), + firstSession + ) + ) + val peer = peerManager.getPeerInfo(peerID)!! + assertArrayEquals(victimEd, peer.signingPublicKey) + assertEquals(peerID, peer.nickname) + assertFalse("Copied self-signed nickname must lose verified status", peer.isVerifiedNickname) + assertFalse(peer.hasVerifiedAnnouncement) + } + + @Test + fun `failed durable persistence cannot publish peer state in memory`() { + val applied = CopyOnWriteArrayList() + val store = object : AuthenticatedPeerStateStore { + override fun load(fingerprint: String): AuthenticatedPeerState? = null + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean = false + override fun isPrivateMediaPinned(fingerprint: String): Boolean = false + } + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == firstSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, state -> applied += state }, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession)) + assertTrue(applied.isEmpty()) + } + + @Test + fun `stale decryption lease cannot persist or apply after generation replacement`() { + var activeSession = firstSession + var persistCalls = 0 + var applyCalls = 0 + val store = object : AuthenticatedPeerStateStore { + override fun load(fingerprint: String): AuthenticatedPeerState? = null + override fun persist( + fingerprint: String, + state: AuthenticatedPeerState, + onCommitted: () -> Unit + ): Boolean { + persistCalls += 1 + onCommitted() + return true + } + override fun isPrivateMediaPinned(fingerprint: String): Boolean = false + } + val coordinator = AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { activeSession }, + withAuthenticatedSession = { _, expected, action -> + if (expected == activeSession) action() else false + }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> applyCalls += 1 }, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + activeSession = secondSession + + assertFalse(coordinator.receive(peerID, remoteState, firstSession)) + assertEquals(0, persistCalls) + assertEquals(0, applyCalls) + } + + private fun fingerprint(key: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt new file mode 100644 index 00000000..3c6d5d13 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionManagerTest.kt @@ -0,0 +1,45 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class BluetoothConnectionManagerTest { + private lateinit var manager: BluetoothConnectionManager + + @Before + fun setUp() { + manager = BluetoothConnectionManager( + RuntimeEnvironment.getApplication(), + "0011223344556677" + ) + } + + @After + fun tearDown() { + manager.stopServices() + } + + @Test + fun `inactive manager rejects a broadcast instead of reporting it queued`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7), + recipientID = null, + timestamp = 1uL, + payload = byteArrayOf(1), + ttl = 7u + ) + + assertFalse(manager.broadcastPacket(RoutedPacket(packet))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt new file mode 100644 index 00000000..16d2d286 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BluetoothConnectionTrackerLinkIdentityTest { + private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + private val tracker = BluetoothConnectionTracker(scope, mock()) + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `stale connection callbacks cannot mutate or remove replacement link`() { + val address = "AA:BB:CC:DD:EE:FF" + val device = mock() + whenever(device.address).thenReturn(address) + + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a") + ) + tracker.addDeviceConnection( + address, + BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b") + ) + + assertFalse( + tracker.updateDeviceConnectionIfCurrent(address, "link-a") { + it.copy(rssi = -10) + } + ) + assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a")) + assertEquals("link-b", tracker.getCurrentLinkID(address)) + + assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677")) + assertEquals("0011223344556677", tracker.addressPeerMap[address]) + assertSame(device, tracker.getDeviceConnection(address)?.device) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 2c093353..945155fd 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -2,7 +2,15 @@ package com.bitchat.android.mesh import android.os.Build import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.noise.AuthenticatedNoiseSession +import com.bitchat.android.noise.NoiseDecryptionResult import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients @@ -16,8 +24,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq -import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -33,9 +41,13 @@ class MessageHandlerTest { private lateinit var delegate: MessageHandlerDelegate private val myPeerID = "1111222233334444" - private val peerID = "aaaabbbbccccdddd" - private val nickname = "peer" private val noiseKey = ByteArray(32) { 0x0B } + private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!! + private val authenticatedSession = AuthenticatedNoiseSession( + noiseKey, + ByteArray(32) { 0x5D } + ) + private val nickname = "peer" private val signingKey = ByteArray(32) { 0x0A } private val signature = ByteArray(64) { 1 } private val announceClockSkewToleranceMs = 10 * 60 * 1000L @@ -49,7 +61,11 @@ class MessageHandlerTest { whenever(delegate.getPeerInfo(peerID)).thenReturn(null) whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true) - whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true) + whenever( + delegate.updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + ).thenReturn(true) } @After @@ -58,46 +74,317 @@ class MessageHandlerTest { } @Test - fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking { - val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) + fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() { + runBlocking { + val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000) - val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) - assertTrue("Announce within clock skew tolerance should still store peer identity", result) - verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) - verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull()) + assertTrue("Announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() + ) + } } @Test - fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking { - val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + fun `handleAnnounce accepts future announce within clock skew tolerance`() { + runBlocking { + val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue("Future announce within clock skew tolerance should still store peer identity", result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull() + ) + } + } + + @Test + fun `handleAnnounce stores advertised capabilities including unknown bits`() { + runBlocking { + val capabilities = PeerCapabilities( + PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15) + ) + val packet = announcePacket(ageMs = 0, capabilities = capabilities) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertTrue(result) + verify(delegate).updatePeerInfoFromVerifiedAnnouncement( + eq(peerID), + eq(nickname), + any(), + any(), + eq(true), + eq(capabilities) + ) + } + } + + @Test + fun `handleAnnounce rejects announce older than clock skew tolerance`() { + runBlocking { + val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + + assertFalse("Announce older than clock skew tolerance should not store peer identity", result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + } + } + + @Test + fun `handleAnnounce never promotes capability after invalid signature`() { + runBlocking { + whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(false) + val packet = announcePacket(ageMs = 0, capabilities = PeerCapabilities.PRIVATE_MEDIA) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + } + } + + @Test + fun `directed raw private media requires a valid signature`() { + runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(false) + val file = BitchatFilePacket( + fileName = "legacy.jpg", + fileSize = 3, + mimeType = "image/jpeg", + content = byteArrayOf(1, 2, 3) + ) + val unsigned = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = file.encode()!!, + signature = null, + ttl = 7u + ) + + handler.handleMessage(RoutedPacket(unsigned, peerID, "direct-link")) + handler.handleMessage( + RoutedPacket(unsigned.copy(signature = ByteArray(64) { 1 }), peerID, "direct-link") + ) + + verify(delegate, never()).onMessageReceived(any()) + } + } + + @Test + fun `valid signed directed raw private media remains interoperable`() { + runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(true) + val file = BitchatFilePacket( + fileName = "legacy-valid.jpg", + fileSize = 3, + mimeType = "image/jpeg", + content = byteArrayOf(1, 2, 3) + ) + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = file.encode()!!, + signature = signature, + ttl = 7u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + verify(delegate).verifySignature(packet, peerID) + verify(delegate).onMessageReceived(any()) + } + } + + @Test + fun `encrypted prerelease iOS Noise 0x09 private media is delivered`() { + runBlocking { + val file = BitchatFilePacket( + fileName = "prerelease-ios.pdf", + fileSize = 4, + mimeType = "application/pdf", + content = byteArrayOf(1, 2, 3, 4) + ) + val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!! + val ciphertext = byteArrayOf(0x41, 0x42, 0x43) + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(prereleasePlaintext, authenticatedSession) + ) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.getMyNickname()).thenReturn("me") + whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55)) + + val outerPacket = BitchatPacket( + version = 2u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + signature = signature, + ttl = 7u + ) + + handler.handleNoiseEncrypted(RoutedPacket(outerPacket, peerID, "direct-link")) + + verify(delegate).decryptFromPeer(ciphertext, peerID) + verify(delegate).onMessageReceived(any()) + } + } + + @Test + fun `valid encrypted peer state is delivered and malformed state is ignored`() = runBlocking { + val state = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey) + val validCiphertext = byteArrayOf(0x31) + val malformedCiphertext = byteArrayOf(0x32) + whenever(delegate.decryptFromPeer(validCiphertext, peerID)).thenReturn( + NoiseDecryptionResult( + NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode(), + authenticatedSession + ) + ) + whenever(delegate.decryptFromPeer(malformedCiphertext, peerID)).thenReturn( + NoiseDecryptionResult( + NoisePayload(NoisePayloadType.PEER_STATE, byteArrayOf(0x01, 0x01)).encode(), + authenticatedSession + ) + ) + val base = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = validCiphertext, + ttl = 7u + ) + + handler.handleNoiseEncrypted(RoutedPacket(base, peerID, "direct-link")) + handler.handleNoiseEncrypted( + RoutedPacket(base.copy(payload = malformedCiphertext), peerID, "direct-link") + ) + + verify(delegate).onAuthenticatedPeerStateReceived(peerID, state, authenticatedSession) + } + + @Test + fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val packet = announcePacket(ageMs = 0, noisePublicKey = attackerNoiseKey) val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) - assertTrue("Future announce within clock skew tolerance should still store peer identity", result) - verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true)) + assertFalse("A valid self-signature cannot bind an attacker key to a victim ID", result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) Unit } @Test - fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking { - val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000) + fun `announce requires a 32-byte Noise static key before peer update`() = runBlocking { + val malformedNoiseKey = ByteArray(31) { 0x0B } + val packet = announcePacket(ageMs = 0, noisePublicKey = malformedNoiseKey) - val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link")) + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) - assertFalse("Announce older than clock skew tolerance should not store peer identity", result) - verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any()) - verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any()) + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `announce packet sender cannot be processed under a different routed peer ID`() = runBlocking { + val otherPeerID = NoisePeerIdentity.derivePeerID(ByteArray(32) { 0x21 })!! + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, otherPeerID, "relay-link")) + + assertFalse(result) + verify(delegate, never()).verifyEd25519Signature(any(), any(), any()) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `known peer signing key cannot be replaced without bound Noise session`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `ambient bound Noise session does not authorize signing key replacement`() = runBlocking { + whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 })) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit + } + + @Test + fun `persisted authenticated Ed key rejects copied-static preannounce after restart`() = runBlocking { + whenever(delegate.getAuthenticatedSigningKey(any())).thenReturn(ByteArray(32) { 0x44 }) + val packet = announcePacket(ageMs = 0) + + val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link")) + + assertFalse(result) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement( + any(), any(), any(), any(), any(), anyOrNull() + ) + Unit } private fun announcePacket( ageMs: Long, - ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte() + ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), + capabilities: PeerCapabilities? = null, + noisePublicKey: ByteArray = noiseKey ): BitchatPacket { val announcement = IdentityAnnouncement( nickname = nickname, - noisePublicKey = noiseKey, - signingPublicKey = signingKey + noisePublicKey = noisePublicKey, + signingPublicKey = signingKey, + capabilities = capabilities ) return BitchatPacket( version = 1u, @@ -114,4 +401,15 @@ class MessageHandlerTest { private fun String.hexToBytes(): ByteArray { return chunked(2).map { it.toInt(16).toByte() }.toByteArray() } + + private fun peerInfo(signingPublicKey: ByteArray) = PeerInfo( + id = peerID, + nickname = nickname, + isConnected = true, + isDirectConnection = true, + noisePublicKey = noiseKey, + signingPublicKey = signingPublicKey, + isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt new file mode 100644 index 00000000..df0344cf --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -0,0 +1,143 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PacketProcessorAnnounceSideEffectTest { + private val processors = mutableListOf() + + @After + fun tearDown() { + processors.forEach(PacketProcessor::shutdown) + } + + @Test + fun `rejected announce does not update last seen or relay`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = false) + val processor = processor(delegate) + + processor.processPacket(announce()) + withTimeout(1_000) { delegate.handled.await() } + + assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() }) + assertEquals(0, delegate.relayCount) + } + + @Test + fun `accepted announce unlocks post-handler side effects`() = runBlocking { + val delegate = RecordingDelegate(acceptAnnounce = true) + val processor = processor(delegate) + + processor.processPacket(announce()) + + 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 + processors += it + } + + private fun announce(): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = PEER_ID.hexToBytes(), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x01), + ttl = 7u + ) + 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 acceptHandshake: Boolean = false + ) : PacketProcessorDelegate { + val handled = CompletableDeferred() + val handshakeHandled = CompletableDeferred() + val lastSeen = CompletableDeferred() + @Volatile var relayCount = 0 + + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = true + override fun updatePeerLastSeen(peerID: String) { + lastSeen.complete(peerID) + } + override fun getPeerNickname(peerID: String): String? = null + override fun getNetworkSize() = 1 + override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST + 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) + return acceptAnnounce + } + override fun handleMessage(routed: RoutedPacket) = Unit + override fun handleLeave(routed: RoutedPacket) = Unit + override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null + override fun handleRequestSync(routed: RoutedPacket) = Unit + override fun sendAnnouncementToPeer(peerID: String) = Unit + override fun sendCachedMessages(peerID: String) = Unit + override fun relayPacket(routed: RoutedPacket) { + relayCount += 1 + } + override fun sendToPeer(peerID: String, routed: RoutedPacket) = false + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val MY_PEER_ID = "1111222233334444" + const val PEER_ID = "aaaabbbbccccdddd" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt new file mode 100644 index 00000000..64deea13 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaSecurityTest.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.noise.AuthenticatedNoiseSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrivateMediaSecurityTest { + private val peerID = "0011223344556677" + private var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession( + ByteArray(32) { (it + 1).toByte() }, + ByteArray(32) { 0x51 } + ) + private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting + private var pinned = false + private val controller = PrivateMediaSecurityController( + authenticatedSessionProvider = { authenticatedSession }, + peerStateStatusProvider = { _, _ -> status }, + isPrivateMediaPinned = { pinned } + ) + + @Test + fun `live session waits for fresh generation proof`() { + assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID)) + } + + @Test + fun `valid private-media proof enables encrypted wire`() { + status = AuthenticatedPeerStateStatus.Proven( + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 }) + ) + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted) + } + + @Test + fun `no-bit proof permits consent only when capability was never pinned`() { + status = AuthenticatedPeerStateStatus.Proven( + AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 1 }) + ) + assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID)) + + pinned = true + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked) + } + + @Test + fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() { + status = AuthenticatedPeerStateStatus.TimedOut + assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID)) + + pinned = true + assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked) + } + + @Test + fun `live session missing coordinator generation waits and never unlocks legacy`() { + status = AuthenticatedPeerStateStatus.Missing + assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID)) + } + + @Test + fun `pin never bypasses requirement for live authenticated session`() { + pinned = true + authenticatedSession = null + assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt new file mode 100644 index 00000000..b84d641b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt @@ -0,0 +1,349 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.noise.AuthenticatedNoiseSession +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +class PrivateMediaTransferPreparerTest { + private val senderID = hex("0011223344556677") + private val recipientID = hex("8877665544332211") + private val authenticatedSession = AuthenticatedNoiseSession( + ByteArray(32) { 0x21 }, + ByteArray(32) { 0x22 } + ) + private val fragmentManagers = mutableListOf() + + @After + fun tearDown() { + fragmentManagers.forEach(FragmentManager::shutdown) + } + + @Test + fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() { + var encryptedPlaintext: ByteArray? = null + val preparer = preparer( + policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession), + encrypt = { bytes, _, _ -> + encryptedPlaintext = bytes + PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + val ready = outcome as PrivateMediaBuildOutcome.Ready + assertEquals(MessageType.NOISE_ENCRYPTED.value, ready.built.packet.type) + assertNotNull(ready.built.packet.signature) + assertEquals(PrivateMediaWireMode.ENCRYPTED_NOISE_0X20, ready.built.wireMode) + val decoded = NoisePayload.decode(encryptedPlaintext!!) + assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type) + assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte()) + } + + @Test + fun `prerelease iOS Noise 0x09 decodes as file transfer but re-encodes canonical 0x20`() { + val filePayload = file(3).encode()!! + val prereleasePayload = byteArrayOf(0x09) + filePayload + + val decoded = NoisePayload.decode(prereleasePayload) + + assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type) + assertTrue(filePayload.contentEquals(decoded?.data)) + assertEquals(0x20u.toUByte(), decoded!!.encode()[0].toUByte()) + } + + @Test + fun `legacy mode requires consent and signing failure aborts`() { + val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent) + .prepare("peer", recipientID, file(64), false) + assertTrue(noConsent is PrivateMediaBuildOutcome.RequiresLegacyConsent) + + val signingFailure = preparer( + policy = PrivateMediaPolicyDecision.RequiresLegacyConsent, + finalizer = { null } + ).prepare("peer", recipientID, file(64), true) + assertTrue(signingFailure is PrivateMediaBuildOutcome.Rejected) + assertTrue((signingFailure as PrivateMediaBuildOutcome.Rejected).reason.contains("nothing was sent")) + + val malformedSignature = preparer( + policy = PrivateMediaPolicyDecision.RequiresLegacyConsent, + finalizer = { packet -> packet.copy(signature = ByteArray(0)) } + ).prepare("peer", recipientID, file(64), true) + assertTrue(malformedSignature is PrivateMediaBuildOutcome.Rejected) + } + + @Test + fun `consented legacy mode is signed directed raw 0x22`() { + val outcome = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent) + .prepare("peer", recipientID, file(64), true) + + val ready = outcome as PrivateMediaBuildOutcome.Ready + assertEquals(MessageType.FILE_TRANSFER.value, ready.built.packet.type) + assertTrue(ready.built.packet.recipientID!!.contentEquals(recipientID)) + assertNotNull(ready.built.packet.signature) + assertEquals(PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22, ready.built.wireMode) + } + + @Test + fun `handshake requirement returns before encoding signing encryption or fragmentation`() { + var encrypted = false + var finalized = false + var fragmented = false + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { packet, maxFragments -> + fragmented = true + fragmentManager.createFragments(packet, maxFragments) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertEquals(PrivateMediaBuildOutcome.NeedsHandshake, outcome) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `peer-state wait returns before encoding signing encryption or fragmentation`() { + var encrypted = false + var finalized = false + var fragmented = false + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.AwaitingPeerState }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { packet, maxFragments -> + fragmented = true + fragmentManager.createFragments(packet, maxFragments) + } + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertEquals(PrivateMediaBuildOutcome.AwaitingPeerState, outcome) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `impossible content size rejects before policy encryption signing or fragmentation`() { + var policyChecked = false + var encrypted = false + var finalized = false + var fragmented = false + val absolutePayloadUpperBound = + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID * + com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { + policyChecked = true + PrivateMediaPolicyDecision.Encrypted(authenticatedSession) + }, + encrypt = { _, _, _ -> + encrypted = true + PrivateMediaEncryptionResult.Success(byteArrayOf(1)) + }, + finalizeRoutedAndSigned = { + finalized = true + it + }, + fragment = { _, _ -> + fragmented = true + emptyList() + } + ) + + val outcome = preparer.prepare( + "peer", + recipientID, + file(absolutePayloadUpperBound + 1), + false + ) + + assertTrue(outcome is PrivateMediaBuildOutcome.Rejected) + assertTrue((outcome as PrivateMediaBuildOutcome.Rejected).reason.contains("256")) + assertTrue(!policyChecked) + assertTrue(!encrypted) + assertTrue(!finalized) + assertTrue(!fragmented) + } + + @Test + fun `no route accepts 256 final fragments and rejects 257`() { + assertExactBoundary(route = null) + } + + @Test + fun `source route accepts 256 final fragments and rejects 257`() { + assertExactBoundary( + route = listOf( + hex("1021324354657687"), + hex("2031425364758697"), + hex("30415263748596a7") + ) + ) + } + + @Test + fun `generation churn re-runs policy once instead of losing the send intent`() { + val replacementSession = AuthenticatedNoiseSession( + authenticatedSession.remoteStaticKey, + ByteArray(32) { 0x23 } + ) + var policyCalls = 0 + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { + policyCalls += 1 + PrivateMediaPolicyDecision.Encrypted( + if (policyCalls == 1) authenticatedSession else replacementSession + ) + }, + encrypt = { bytes, _, session -> + if (session == authenticatedSession) { + PrivateMediaEncryptionResult.GenerationChanged + } else { + PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes) + } + }, + finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) }, + fragment = fragmentManager::createFragments + ) + + val outcome = preparer.prepare("peer", recipientID, file(64), false) + + assertTrue(outcome is PrivateMediaBuildOutcome.Ready) + assertEquals(2, policyCalls) + } + + @Test + fun `repeated generation churn remains retryable`() { + val fragmentManager = FragmentManager().also { fragmentManagers += it } + val preparer = PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { PrivateMediaPolicyDecision.Encrypted(authenticatedSession) }, + encrypt = { _, _, _ -> PrivateMediaEncryptionResult.GenerationChanged }, + finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) }, + fragment = fragmentManager::createFragments + ) + + assertEquals( + PrivateMediaBuildOutcome.AwaitingPeerState, + preparer.prepare("peer", recipientID, file(64), false) + ) + } + + private fun assertExactBoundary(route: List?) { + val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) } + val preparer = preparer( + policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession), + finalizer = { packet -> + packet.copy( + version = if (route == null) packet.version else 2u, + route = route, + signature = ByteArray(64) { 0x5A } + ) + } + ) + + fun outcome(contentSize: Int): PrivateMediaBuildOutcome = preparer.prepare( + "peer", + recipientID, + BitchatFilePacket( + fileName = "boundary.bin", + fileSize = contentSize.toLong(), + mimeType = "application/octet-stream", + content = randomContent.copyOf(contentSize) + ), + false + ) + + var low = 1 + var high = randomContent.size + while (low < high) { + val mid = low + (high - low) / 2 + if (outcome(mid) is PrivateMediaBuildOutcome.Rejected) high = mid else low = mid + 1 + } + + val accepted = outcome(low - 1) as PrivateMediaBuildOutcome.Ready + val rejected = outcome(low) + assertEquals(256, accepted.built.fragments.size) + assertTrue(rejected is PrivateMediaBuildOutcome.Rejected) + assertTrue((rejected as PrivateMediaBuildOutcome.Rejected).reason.contains("256")) + } + + private fun preparer( + policy: PrivateMediaPolicyDecision, + encrypt: ( + ByteArray, + String, + AuthenticatedNoiseSession + ) -> PrivateMediaEncryptionResult = { bytes, _, _ -> + PrivateMediaEncryptionResult.Success(byteArrayOf(0x01) + bytes) + }, + finalizer: (BitchatPacket) -> BitchatPacket? = { packet -> + packet.copy(signature = ByteArray(64) { 0x33 }) + } + ): PrivateMediaTransferPreparer { + val fragmentManager = FragmentManager().also { fragmentManagers += it } + return PrivateMediaTransferPreparer( + senderID = senderID, + ttl = 7u, + policyProvider = { policy }, + encrypt = encrypt, + finalizeRoutedAndSigned = finalizer, + fragment = fragmentManager::createFragments, + now = { 1_700_000_000_000uL } + ) + } + + private fun file(size: Int) = BitchatFilePacket( + fileName = "test.bin", + fileSize = size.toLong(), + mimeType = "application/octet-stream", + content = ByteArray(size) { it.toByte() } + ) + + private fun hex(value: String): ByteArray = + value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} 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 aa629ca7..17156e00 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -3,8 +3,13 @@ package com.bitchat.android.mesh import android.os.Build import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.noise.NoiseHandshakeProcessingResult +import com.bitchat.android.noise.NoisePeerIdentity +import com.bitchat.android.noise.NoiseSessionError import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -26,21 +31,26 @@ class SecurityManagerTest { private val myPeerID = "1111222233334444" private val otherPeerID = "aaaabbbbccccdddd" - private val unknownPeerID = "9999888877776666" + // Key pairs (using dummy bytes for mock verification) + private val otherSigningKey = ByteArray(32) { 0xA } + private val otherNoiseKey = ByteArray(32) { 0xB } + private val sessionToken = ByteArray(32) { 0x5C } + private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!! private val dummyPayload = "Hello World".toByteArray() private val validSignature = ByteArray(64) { 1 } private val invalidSignature = ByteArray(64) { 0 } - - // Key pairs (using dummy bytes for mock verification) - private val otherSigningKey = ByteArray(32) { 0xA } - private val otherNoiseKey = ByteArray(32) { 0xB } // Fake implementation to bypass initialization issues in tests open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { var shouldVerify: Boolean = true var lastVerifySignature: ByteArray? = null + var lastVerifyData: ByteArray? = null var lastVerifyKey: ByteArray? = null + var handshakeResult = NoiseHandshakeProcessingResult(null, false) + var handshakeError: Exception? = null + var handshakeCalls = 0 + var removePeerCalls = 0 override fun initialize() { // Do nothing to avoid KeyStore access in tests @@ -48,6 +58,7 @@ class SecurityManagerTest { override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { lastVerifySignature = signature + lastVerifyData = data lastVerifyKey = publicKeyBytes // Simple logic: if configured to verify, check if signature matches validSignature @@ -57,6 +68,19 @@ class SecurityManagerTest { } return false } + + override fun processHandshakeMessageWithResult( + data: ByteArray, + peerID: String + ): NoiseHandshakeProcessingResult { + handshakeCalls += 1 + handshakeError?.let { throw it } + return handshakeResult + } + + override fun removePeer(peerID: String) { + removePeerCalls += 1 + } } @Before @@ -90,6 +114,44 @@ class SecurityManagerTest { assertFalse("Packet without signature should be rejected", result) } + @Test + fun `verifySignature - verifies canonical packet with announced signing key`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = dummyPayload, + signature = validSignature, + ttl = 10u + ) + + assertTrue(securityManager.verifySignature(packet, otherPeerID)) + assertTrue(fakeEncryptionService.lastVerifySignature.contentEquals(validSignature)) + assertTrue(fakeEncryptionService.lastVerifyData.contentEquals(packet.toBinaryDataForSigning())) + assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `verifySignature - rejects missing signature and unknown signing key`() { + val unsigned = BitchatPacket( + version = 1u, + type = MessageType.FILE_TRANSFER.value, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = dummyPayload, + ttl = 10u + ) + assertFalse(securityManager.verifySignature(unsigned, otherPeerID)) + + unsigned.signature = validSignature + whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(null) + assertFalse(securityManager.verifySignature(unsigned, otherPeerID)) + } + @Test fun `validatePacket - rejects packet with invalid signature`() { setupKnownPeer(otherPeerID, otherSigningKey) @@ -107,6 +169,22 @@ class SecurityManagerTest { assertFalse("Packet with invalid signature should be rejected", result) } + @Test + fun `invalid packet does not poison duplicate detection for later valid packet`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + assertFalse(securityManager.validatePacket(packet, otherPeerID)) + + packet.signature = validSignature + assertTrue(securityManager.validatePacket(packet, otherPeerID)) + } + @Test fun `validatePacket - rejects packet from unknown peer (no key)`() { whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) @@ -141,6 +219,63 @@ class SecurityManagerTest { assertTrue("Valid signed packet from known peer should be accepted", result) } + @Test + fun `validatePacket rejects unsigned and invalidly signed LEAVE packets`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val unsigned = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ) + assertFalse("Unsigned LEAVE must not evict or relay the claimed peer", securityManager.validatePacket(unsigned, otherPeerID)) + + val invalid = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = "forged".toByteArray() + ).also { it.signature = invalidSignature } + assertFalse("Bad LEAVE signature must be rejected", securityManager.validatePacket(invalid, otherPeerID)) + } + + @Test + fun `validatePacket accepts signed LEAVE from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = 7u, + senderID = otherPeerID, + payload = byteArrayOf() + ).also { it.signature = validSignature } + + assertTrue("A valid signed LEAVE remains wire-compatible", securityManager.validatePacket(packet, otherPeerID)) + 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( @@ -205,6 +340,48 @@ class SecurityManagerTest { assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) } + @Test + fun `validatePacket rejects self-signed announce whose Noise key derives another sender ID`() { + val attackerNoiseKey = ByteArray(32) { 0x6B } + val announcement = IdentityAnnouncement("Attacker", attackerNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, unknownPeerID)) + } + + @Test + fun `validatePacket rejects announce packet under a different routed sender`() { + val announcement = IdentityAnnouncement("Peer", otherNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, otherPeerID)) + } + + @Test + fun `validatePacket rejects announce conflicting with persisted authenticated Ed key`() { + whenever(mockDelegate.getAuthenticatedSigningKey(otherNoiseKey)) + .thenReturn(ByteArray(32) { 0x44 }) + val announcement = IdentityAnnouncement("Copied", otherNoiseKey, otherSigningKey) + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 7u, + senderID = unknownPeerID, + payload = announcement.encode()!! + ).also { it.signature = validSignature } + + assertFalse(securityManager.validatePacket(packet, unknownPeerID)) + } + @Test fun `validatePacket - ignores own packets`() { val packet = BitchatPacket( @@ -270,6 +447,101 @@ class SecurityManagerTest { assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) } + @Test + fun `replacement message one sends response without evicting or falsely completing`() = runBlocking { + val response = byteArrayOf(0x31, 0x32) + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(response, false) + val routed = handshakePacket(byteArrayOf(0x01, 0x02, 0x03)) + + val accepted = securityManager.handleNoiseHandshake(routed) + + assertTrue(accepted) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate).sendHandshakeResponse(otherPeerID, response) + verify(mockDelegate, never()).onKeyExchangeCompleted( + any(), any(), any(), anyOrNull(), anyOrNull() + ) + } + + @Test + fun `identity mismatch preserves peer and does not poison retry or completion`() = runBlocking { + val routed = handshakePacket(byteArrayOf(0x41, 0x42, 0x43)) + fakeEncryptionService.handshakeError = NoiseSessionError.PeerIdentityMismatch( + otherPeerID, + "0000000000000000" + ) + + assertFalse(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + verify(mockDelegate, never()).onKeyExchangeCompleted( + any(), any(), any(), anyOrNull(), anyOrNull() + ) + + fakeEncryptionService.handshakeError = null + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + verify(mockDelegate).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + "direct-link", + "direct-link-token" + ) + } + + @Test + fun `completion callback fires only for the frame that establishes a bound session`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53)) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + + verify(mockDelegate, times(1)).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + "direct-link", + "direct-link-token" + ) + verify(mockDelegate, never()).sendHandshakeResponse(any(), any()) + assertTrue(fakeEncryptionService.removePeerCalls == 0) + } + + @Test + fun `relayed completion does not authenticate the relay as the peer link`() = runBlocking { + fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult( + response = null, + establishedNow = true, + authenticatedRemoteStaticKey = otherNoiseKey, + authenticatedSessionToken = sessionToken + ) + val routed = handshakePacket( + payload = byteArrayOf(0x61, 0x62, 0x63), + ttl = 6u + ) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + verify(mockDelegate).onKeyExchangeCompleted( + otherPeerID, + otherNoiseKey, + sessionToken, + null, + null + ) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, @@ -283,4 +555,25 @@ class SecurityManagerTest { ) whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) } + + private fun handshakePacket(payload: ByteArray, ttl: UByte = 7u): RoutedPacket { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = otherPeerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + ttl = ttl + ) + return RoutedPacket( + packet = packet, + peerID = otherPeerID, + relayAddress = "direct-link", + ingressLinkID = "direct-link-token" + ) + } + + private fun String.hexToBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() } diff --git a/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt new file mode 100644 index 00000000..056a5584 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AuthenticatedPeerStateTest { + private val signingKey = ByteArray(32) { it.toByte() } + + @Test + fun `encoder matches canonical iOS bytes`() { + val encoded = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode() + + assertArrayEquals( + byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey, + encoded + ) + assertEquals( + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey), + AuthenticatedPeerState.decode(encoded) + ) + } + + @Test + fun `decoder skips unknown TLVs and accepts either known-field order`() { + val payload = byteArrayOf( + 0x01, + 0x7F, 0x02, 0x55, 0x66, + 0x02, 0x20 + ) + signingKey + byteArrayOf(0x01, 0x01, 0x00) + + assertEquals( + AuthenticatedPeerState(PeerCapabilities.NONE, signingKey), + AuthenticatedPeerState.decode(payload) + ) + } + + @Test + fun `decoder rejects malformed duplicate missing and noncanonical fields`() { + val validCapabilities = byteArrayOf(0x01, 0x01, 0x00) + val validSigning = byteArrayOf(0x02, 0x20) + signingKey + val invalid = listOf( + byteArrayOf(), + byteArrayOf(0x02) + validCapabilities + validSigning, + byteArrayOf(0x01) + validCapabilities, + byteArrayOf(0x01) + validSigning, + byteArrayOf(0x01) + validCapabilities + validCapabilities + validSigning, + byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x00) + validSigning, + byteArrayOf(0x01, 0x01, 0x00) + validSigning, + byteArrayOf(0x01, 0x01, 0x09) + ByteArray(9) + validSigning, + byteArrayOf(0x01, 0x02, 0x1F) + ByteArray(31) + validCapabilities, + byteArrayOf(0x01, 0x7F), + byteArrayOf(0x01, 0x7F, 0x02, 0x01) + ) + + invalid.forEach { assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) } + } + + @Test + fun `Noise wrapper emits and decodes canonical 0x21`() { + val state = AuthenticatedPeerState(PeerCapabilities.NONE, signingKey) + val encoded = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode() + + assertEquals(0x21, encoded[0].toInt() and 0xFF) + assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt new file mode 100644 index 00000000..78d72bee --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdentityAnnouncementTest { + private val nickname = "peer" + private val noiseKey = ByteArray(32) { 0x11 } + private val signingKey = ByteArray(32) { 0x22 } + + @Test + fun `private media capability uses iOS little-endian bytes`() { + assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded()) + assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA)) + } + + @Test + fun `legacy announcement without capability TLV still decodes`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + + val decoded = IdentityAnnouncement.decode(legacy)!! + + assertEquals(nickname, decoded.nickname) + assertArrayEquals(noiseKey, decoded.noisePublicKey) + assertArrayEquals(signingKey, decoded.signingPublicKey) + assertNull(decoded.capabilities) + } + + @Test + fun `explicit empty capability TLV decodes as present but empty`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + + val decoded = IdentityAnnouncement.decode(legacy + byteArrayOf(0x05, 0x00))!! + + assertEquals(PeerCapabilities.NONE, decoded.capabilities) + } + + @Test + fun `unknown capability bits and TLVs survive decode and re-encode`() { + val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! + val wire = legacy + byteArrayOf( + 0x05, 0x02, 0x00, 0x81.toByte(), // privateMedia plus unknown bit 15 + 0x7F, 0x03, 0x01, 0x02, 0x03 + ) + + val decoded = IdentityAnnouncement.decode(wire)!! + + assertEquals(0x8100L, decoded.capabilities?.rawValue) + assertEquals(1, decoded.unknownTLVs.size) + assertEquals(0x7F, decoded.unknownTLVs.single().type) + assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), decoded.unknownTLVs.single().value) + + val roundTripped = IdentityAnnouncement.decode(decoded.encode()!!)!! + assertEquals(decoded.capabilities, roundTripped.capabilities) + assertEquals(decoded.unknownTLVs, roundTripped.unknownTLVs) + } + + @Test + fun `local announcement send advertises private media`() { + val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! + + assertArrayEquals( + byteArrayOf(0x05, 0x02, 0x00, 0x01), + encoded.takeLast(4).toByteArray() + ) + assertTrue( + IdentityAnnouncement.decode(encoded)!! + .capabilities!! + .contains(PeerCapabilities.PRIVATE_MEDIA) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt new file mode 100644 index 00000000..63f5e3c1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt @@ -0,0 +1,384 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerIdentityBindingTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `valid derived identities establish in initiator and responder roles`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID)) + + val plaintext = "bound transport".toByteArray() + val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!! + val bobSession = bobManager.getAuthenticatedSession(alice.peerID)!! + val ciphertext = aliceManager.encryptForSession(plaintext, bob.peerID, aliceSession) + val decrypted = bobManager.decryptWithSession(ciphertext, alice.peerID) + assertArrayEquals(plaintext, decrypted.plaintext) + assertArrayEquals(bobSession.sessionToken, decrypted.authenticatedSession.sessionToken) + } + + @Test + fun `initiator rejects remote static key before returning message three`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(victim.peerID)!! + val message2 = bobManager.processHandshakeMessage(alice.peerID, message1)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(victim.peerID, message2) + } + + assertFalse(aliceManager.hasEstablishedSession(victim.peerID)) + assertNull(aliceManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched initiator", authenticatedCallbacks == 0) + } + + @Test + fun `responder rejects authenticated initiator key under a different claimed ID`() { + val alice = identity() + val bob = identity() + val victim = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + var authenticatedCallbacks = 0 + bobManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 } + + val message1 = aliceManager.initiateHandshake(bob.peerID)!! + val message2 = bobManager.processHandshakeMessage(victim.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + + expectIdentityMismatch { + bobManager.processHandshakeMessage(victim.peerID, message3) + } + + assertFalse(bobManager.hasEstablishedSession(victim.peerID)) + assertNull(bobManager.getSession(victim.peerID)) + assertTrue("No authenticated callback may escape a mismatched responder", authenticatedCallbacks == 0) + } + + @Test + fun `mismatched responder replacement preserves established session and transport keys`() { + val alice = identity() + val bob = identity() + val attacker = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + val attackerManager = manager(attacker) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + assertTrue(aliceAuthenticatedCallbacks == 1) + + val attackerMessage1 = attackerManager.initiateHandshake(alice.peerID)!! + val aliceMessage2 = aliceManager.processHandshakeMessage(bob.peerID, attackerMessage1)!! + val attackerMessage3 = attackerManager.processHandshakeMessage(alice.peerID, aliceMessage2)!! + + expectIdentityMismatch { + aliceManager.processHandshakeMessage(bob.peerID, attackerMessage3) + } + + assertSame("Rejected candidate must not replace the working session", originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue("Rejected replacement must not fire authentication callback", aliceAuthenticatedCallbacks == 1) + + val plaintext = "original session survives".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + + // The failed candidate must be fully removed so a later valid restart can replace cleanly. + val restartedBobManager = manager(bob) + val validMessage1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val validMessage2 = aliceManager.processHandshakeMessage(bob.peerID, validMessage1)!! + val validMessage3 = restartedBobManager.processHandshakeMessage(alice.peerID, validMessage2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, validMessage3)) + assertTrue(aliceAuthenticatedCallbacks == 2) + + val retriedPlaintext = "valid retry promoted".toByteArray() + val retriedCiphertext = restartedBobManager.encrypt(retriedPlaintext, alice.peerID) + assertArrayEquals(retriedPlaintext, aliceManager.decrypt(retriedCiphertext, bob.peerID)) + } + + @Test + fun `valid responder replacement promotes only after bound handshake completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + var aliceAuthenticatedCallbacks = 0 + aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 } + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + + // Simulate Bob restarting with the same persistent static identity and no session state. + val restartedBobManager = manager(bob) + val message1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!! + val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID)) + assertTrue(aliceAuthenticatedCallbacks == 2) + val replacementBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + assertFalse(originalBinding.sessionToken.contentEquals(replacementBinding.sessionToken)) + try { + aliceManager.encryptForSession( + "stale generation".toByteArray(), + bob.peerID, + originalBinding + ) + fail("Expected stale generation-bound encryption to be rejected") + } catch (_: NoiseSessionError.SessionGenerationChanged) { + // Expected. + } + + val plaintext = "replacement transport".toByteArray() + val ciphertext = restartedBobManager.encryptForSession( + plaintext, + alice.peerID, + restartedBobManager.getAuthenticatedSession(alice.peerID)!! + ) + assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID)) + } + + @Test + fun `generation lease blocks replacement and rejects stale token afterward`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!! + + val restartedBobManager = manager(bob) + val message1 = restartedBobManager.initiateHandshake(alice.peerID)!! + val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!! + val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!! + + val leaseEntered = CountDownLatch(1) + val releaseLease = CountDownLatch(1) + val replacementStarted = CountDownLatch(1) + val replacementCompleted = CountDownLatch(1) + val replacementFinished = AtomicBoolean(false) + val threadFailure = AtomicReference(null) + val leaseThread = Thread { + try { + assertTrue( + aliceManager.withAuthenticatedSession(bob.peerID, originalBinding) { + leaseEntered.countDown() + releaseLease.await(2, TimeUnit.SECONDS) + } + ) + } catch (error: Throwable) { + threadFailure.set(error) + } + } + val replacementThread = Thread { + try { + replacementStarted.countDown() + aliceManager.processHandshakeMessage(bob.peerID, message3) + replacementFinished.set(true) + } catch (error: Throwable) { + threadFailure.set(error) + } finally { + replacementCompleted.countDown() + } + } + + leaseThread.start() + assertTrue(leaseEntered.await(1, TimeUnit.SECONDS)) + replacementThread.start() + assertTrue(replacementStarted.await(1, TimeUnit.SECONDS)) + try { + assertFalse( + "Replacement must wait while the old generation lease is active", + replacementCompleted.await(100, TimeUnit.MILLISECONDS) + ) + } finally { + releaseLease.countDown() + } + leaseThread.join(2_000) + replacementThread.join(2_000) + assertTrue(replacementCompleted.await(1, TimeUnit.SECONDS)) + threadFailure.get()?.let { throw it } + assertTrue(replacementFinished.get()) + + try { + aliceManager.encryptForSession(byteArrayOf(1), bob.peerID, originalBinding) + fail("Expected old token to be rejected after replacement") + } catch (_: NoiseSessionError.SessionGenerationChanged) { + // Expected. + } + } + + @Test + fun `fresh initiator replacement preserves active session until authentication completes`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val originalBobManager = manager(bob) + + completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID) + val originalSession = aliceManager.getSession(bob.peerID) + + val restartedBobManager = manager(bob) + val message1 = aliceManager.initiateHandshake(bob.peerID, replaceEstablished = true)!! + assertSame(originalSession, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + + val message2 = restartedBobManager.processHandshakeMessage(alice.peerID, message1)!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(restartedBobManager.processHandshakeMessage(alice.peerID, message3)) + + assertNotSame(originalSession, aliceManager.getSession(bob.peerID)) + val plaintext = "fresh link authenticated".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, restartedBobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `simultaneous initiator replacements use peer ID tie break and complete`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + + val originalAliceSession = aliceManager.getSession(bob.peerID) + val originalBobSession = bobManager.getSession(alice.peerID) + val aliceMessage1 = aliceManager.initiateHandshake( + bob.peerID, + replaceEstablished = true + )!! + val bobMessage1 = bobManager.initiateHandshake( + alice.peerID, + replaceEstablished = true + )!! + + val aliceCollisionResponse = aliceManager.processHandshakeMessage( + bob.peerID, + bobMessage1 + ) + val bobCollisionResponse = bobManager.processHandshakeMessage( + alice.peerID, + aliceMessage1 + ) + + if (alice.peerID < bob.peerID) { + assertNull(aliceCollisionResponse) + val message2 = bobCollisionResponse!! + val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!! + assertNull(bobManager.processHandshakeMessage(alice.peerID, message3)) + } else { + assertNull(bobCollisionResponse) + val message2 = aliceCollisionResponse!! + val message3 = bobManager.processHandshakeMessage(alice.peerID, message2)!! + assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3)) + } + + assertNotSame(originalAliceSession, aliceManager.getSession(bob.peerID)) + assertNotSame(originalBobSession, bobManager.getSession(alice.peerID)) + val plaintext = "collision replacement transport".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `peer ID derivation rejects malformed keys and non-wire claims`() { + val peer = identity() + + assertTrue(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID.uppercase(), peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID("not-a-wire-id", peer.publicKey)) + assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, ByteArray(31))) + assertNull(NoisePeerIdentity.derivePeerID(ByteArray(31))) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun expectIdentityMismatch(block: () -> Unit) { + try { + block() + fail("Expected authenticated Noise key to be rejected for the claimed peer ID") + } catch (_: NoiseSessionError.PeerIdentityMismatch) { + // Expected. + } + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt new file mode 100644 index 00000000..7e774dc3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.service + +import android.os.Build +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class TransportBridgeServiceTest { + private val targetId = "test-${UUID.randomUUID()}" + + @After + fun tearDown() { + TransportBridgeService.unregister(targetId) + } + + @Test + fun `bridged prepared plan retains exact payloads with decremented TTL`() { + var captured: RoutedPacket? = null + TransportBridgeService.register( + targetId, + object : TransportBridgeService.TransportLayer { + override fun send(packet: RoutedPacket) { + captured = packet + } + } + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = ByteArray(8) { 1 }, + recipientID = ByteArray(8) { 2 }, + timestamp = System.nanoTime().toULong(), + payload = byteArrayOf(3, 4, 5), + signature = ByteArray(64) { 6 }, + ttl = 7u + ) + val prepared = listOf( + packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(10)), + packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(11)) + ) + + TransportBridgeService.broadcast( + sourceId = "source-${UUID.randomUUID()}", + packet = RoutedPacket(packet, preparedPackets = prepared) + ) + + val forwarded = captured + assertNotNull(forwarded) + assertEquals(6u.toUByte(), forwarded!!.packet.ttl) + assertEquals(2, forwarded.preparedPackets?.size) + forwarded.preparedPackets!!.zip(prepared).forEach { (actual, original) -> + assertEquals(6u.toUByte(), actual.ttl) + assertTrue(actual.payload.contentEquals(original.payload)) + assertEquals(original.type, actual.type) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt new file mode 100644 index 00000000..ebe5f744 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -0,0 +1,303 @@ +package com.bitchat.android.ui + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.PreparedPrivateMediaTransfer +import com.bitchat.android.mesh.PrivateMediaPreparation +import com.bitchat.android.mesh.PrivateMediaWireMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +class MediaSendingManagerMigrationTest { + private val peerID = "8877665544332211" + private lateinit var state: ChatState + private lateinit var mesh: MeshService + private lateinit var manager: MediaSendingManager + private lateinit var file: File + + @Before + fun setup() { + state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + state.setNickname("me") + mesh = mock() + whenever(mesh.myPeerID).thenReturn("0011223344556677") + whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer")) + manager = MediaSendingManager( + state, + MessageManager(state), + mock(), + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mediaWorkDispatcher = Dispatchers.Unconfined, + getMeshService = { mesh } + ) + file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply { + writeBytes(ByteArray(128) { it.toByte() }) + } + } + + @After + fun tearDown() { + file.delete() + } + + @Test + fun `preflight rejection creates only a visible failure and no file echo or send`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.Rejected("too many fragments")) + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("too many fragments")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `private preparation runs on the configured media worker`() { + val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "private-media-test-worker") + } + val dispatcher = executor.asCoroutineDispatcher() + try { + val preparationThread = AtomicReference() + val prepared = CountDownLatch(1) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { + preparationThread.set(Thread.currentThread().name) + prepared.countDown() + PrivateMediaPreparation.Rejected("test complete") + } + val asynchronousManager = MediaSendingManager( + state, + MessageManager(state), + mock(), + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mediaWorkDispatcher = dispatcher, + getMeshService = { mesh } + ) + + asynchronousManager.sendImageNote(peerID, null, file.absolutePath) + + assertTrue(prepared.await(5, TimeUnit.SECONDS)) + assertTrue(preparationThread.get().contains("private-media-test-worker")) + } finally { + dispatcher.close() + executor.shutdownNow() + } + } + + @Test + fun `pinned downgrade rejection is visible without a file echo or send`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn( + PrivateMediaPreparation.Rejected( + "Encrypted private media was previously pinned, but this session proved no support; send blocked" + ) + ) + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("previously pinned")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `missing Noise session retains first send and commits after proof resolution`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.NeedsHandshake) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + verify(mesh, times(1)).initiateNoiseHandshake(peerID) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + + manager.retryPendingPrivateMedia(peerID) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, never()).sendFilePrivate(any(), any()) + } + + @Test + fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.AwaitingPeerState) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + + manager.sendImageNote(peerID, null, file.absolutePath) + + verify(mesh, never()).initiateNoiseHandshake(peerID) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + + manager.retryPendingPrivateMedia(peerID) + + assertNotNull(manager.legacyPrivateMediaConsent.value) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + } + + @Test + fun `reentrant proof resolution during preparation cannot lose first send intent`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { + manager.retryPendingPrivateMedia(peerID) + PrivateMediaPreparation.AwaitingPeerState + } + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + } + + @Test + fun `legacy consent is one shot rechecks policy and echoes only after approval`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(true))) + .thenAnswer { invocation -> + val transferId = invocation.getArgument(2) + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = transferId, + // Simulate the capability becoming authenticated while + // the consent dialog was open: recheck must upgrade. + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + val request = manager.legacyPrivateMediaConsent.value + assertNotNull(request) + + manager.approveLegacyPrivateMedia(request!!.requestId) + manager.approveLegacyPrivateMedia(request.requestId) + + assertEquals(1, commits.get()) + assertEquals(1, state.privateChats.value[peerID]?.size) + assertEquals(null, manager.legacyPrivateMediaConsent.value) + verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(false)) + verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(true)) + } + + @Test + fun `prepared transfer ID mismatch aborts before local echo or commit`() { + val commits = AtomicInteger(0) + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn( + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = "wrong-transfer-id", + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + commits.incrementAndGet() + true + } + ) + ) + + manager.sendImageNote(peerID, null, file.absolutePath) + + assertEquals(0, commits.get()) + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + } + + @Test + fun `failed prepared commit rolls back the local file echo`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenAnswer { invocation -> + PrivateMediaPreparation.Ready( + PreparedPrivateMediaTransfer( + transferId = invocation.getArgument(2), + wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 + ) { + false + } + ) + } + + manager.sendImageNote(peerID, null, file.absolutePath) + + val messages = state.privateChats.value[peerID].orEmpty() + assertEquals(1, messages.size) + assertTrue(messages.single().content.contains("could not be committed")) + assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image }) + } + + @Test + fun `cancelled consent cannot later send or echo`() { + whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) + .thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning")) + + manager.sendImageNote(peerID, null, file.absolutePath) + val request = manager.legacyPrivateMediaConsent.value!! + manager.cancelLegacyPrivateMedia(request.requestId) + manager.approveLegacyPrivateMedia(request.requestId) + + assertTrue(state.privateChats.value[peerID].isNullOrEmpty()) + verify(mesh, never()).prepareFilePrivate(eq(peerID), any(), any(), eq(true)) + } +} 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 new file mode 100644 index 00000000..1f483543 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt @@ -0,0 +1,93 @@ +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() + val victimSocket = Any() + val links = mapOf( + "attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket), + "victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket) + ) + val current = mapOf( + "provisional-attacker" to attackerSocket, + "provisional-victim" to victimSocket + ) + + val resolved = AuthenticatedIngressLinkPolicy.resolve( + authenticatedLinkID = "victim-link", + authenticatedRelayAddress = "provisional-victim", + links = links, + currentTransportForRelay = current::get + ) + + assertSame(victimSocket, resolved?.transport) + } + + @Test + fun `stale replaced or mismatched ingress links cannot be promoted`() { + val completedSocket = Any() + val replacementSocket = Any() + val links = mapOf( + "completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket) + ) + + assertNull( + AuthenticatedIngressLinkPolicy.resolve( + authenticatedLinkID = "missing-link", + authenticatedRelayAddress = "provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + AuthenticatedIngressLinkPolicy.resolve( + authenticatedLinkID = "completed-link", + authenticatedRelayAddress = "different-provisional", + links = links, + currentTransportForRelay = { completedSocket } + ) + ) + assertNull( + AuthenticatedIngressLinkPolicy.resolve( + authenticatedLinkID = "completed-link", + authenticatedRelayAddress = "provisional", + links = links, + currentTransportForRelay = { replacementSocket } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt new file mode 100644 index 00000000..b1b12d0e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.wifiaware + +import android.net.ConnectivityManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.net.Socket + +class WifiAwareConnectionTrackerTest { + @Test + fun `compare and rebind rejects stale authenticated socket after replacement`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val authenticatedSocket = syncedSocket() + val replacementSocket = syncedSocket() + tracker.onClientConnected("provisional", authenticatedSocket) + tracker.onClientConnected("provisional", replacementSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = authenticatedSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + assertNull(tracker.getSocketForPeer("canonical")) + + assertTrue( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = replacementSocket + ) + ) + assertSame(replacementSocket, tracker.getSocketForPeer("canonical")) + assertSame(replacementSocket, tracker.getSocketForPeer("provisional")) + } + + @Test + fun `authenticated provisional socket cannot displace existing canonical socket`() { + val tracker = WifiAwareConnectionTracker( + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), + mock() + ) + val provisionalSocket = syncedSocket() + val canonicalSocket = syncedSocket() + tracker.onClientConnected("provisional", provisionalSocket) + tracker.onClientConnected("canonical", canonicalSocket) + + assertFalse( + tracker.rebindPeerIdIfCurrent( + previousPeerId = "provisional", + resolvedPeerId = "canonical", + expectedSocket = provisionalSocket + ) + ) + assertSame(provisionalSocket, tracker.getSocketForPeer("provisional")) + assertSame(canonicalSocket, tracker.getSocketForPeer("canonical")) + assertTrue("Rejected promotion must not alias the provisional ID", tracker.canonicalPeerId("provisional") == "provisional") + } + + private fun syncedSocket(): SyncedSocket { + val raw = mock { + on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf()) + on { getOutputStream() } doReturn ByteArrayOutputStream() + } + return SyncedSocket(raw) + } +} diff --git a/docs/ANNOUNCEMENT_GOSSIP.md b/docs/ANNOUNCEMENT_GOSSIP.md index 61d8f398..25593c6f 100644 --- a/docs/ANNOUNCEMENT_GOSSIP.md +++ b/docs/ANNOUNCEMENT_GOSSIP.md @@ -8,7 +8,10 @@ Status: optional and backward-compatible. - Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged. - Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility. -- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature. +- Signature: The packet is signed using the Ed25519 public key carried in TLV + `0x03`. The gossip and capability TLVs are part of the payload and therefore + covered by the signature. Current clients require a valid signature before + applying identity or capability state. ## TLV Format @@ -24,6 +27,12 @@ Existing TLVs (unchanged): - `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519) - `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes) +Other optional extension: + +- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises + authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is + `00 01`. An empty value is valid and means no advertised capabilities. + New TLV (optional): - `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored. @@ -44,7 +53,9 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th - Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs. - Remove duplicates before encoding. - Order is arbitrary and not semantically significant. -- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended): +- Current capable senders also include TLV `0x05` with private-media bit 8 set + in every broadcast and peer-directed announcement. +- Sign the ANNOUNCE packet so all extension TLVs are covered: - Signature algorithm: Ed25519 using the key in TLV `0x03`. - Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature. - The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression. @@ -53,6 +64,11 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th - Decompress payload if the packet’s compression flag is set, then parse TLVs in order. - Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs. +- Parse TLV `0x05`, when present, as a little-endian bitfield. Absence and an + explicitly empty value remain valid legacy capability states. A + security-sensitive bit is trusted only after the signed announcement key is + bound to the matching remote static key from a live Noise handshake; see + `PRIVATE_MEDIA_V1.md`. - If a `0x04` TLV is present: - Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes). - Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase). @@ -76,8 +92,8 @@ ANNOUNCE payload TLVs (concatenated): - `02 [len=32] [32 bytes X25519 pubkey]` - `03 [len=32] [32 bytes Ed25519 pubkey]` - `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional) +- `05 02 00 01` (optional private-media-v1 capability) Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above. That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged. - diff --git a/docs/NOISE_PEER_ID_BINDING.md b/docs/NOISE_PEER_ID_BINDING.md new file mode 100644 index 00000000..a6c23a0c --- /dev/null +++ b/docs/NOISE_PEER_ID_BINDING.md @@ -0,0 +1,62 @@ +# Noise peer-ID binding + +Mesh wire peer IDs are exactly 16 lowercase hexadecimal characters derived as +`hex(SHA-256(noiseStaticPublicKey)[0..<8])`. Android enforces that binding at +both identity entry points: + +- A verified announcement must carry a 32-byte Noise static key whose derived + ID matches both the packet sender and routed sender. +- A Noise XX initiator or responder must authenticate a remote static key whose + derived ID matches the claimed session key before transport ciphers are + exposed or an authentication callback runs. + +Inbound rehandshakes use a separate responder candidate. An established +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 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 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 +also cannot change based on an announcement or merely because some session for +that peer ID is active. Rotation requires an authenticated peer-state proof +tied to the exact Noise channel; ambient session presence is not enough. +The same authenticated callback restores any existing Noise-key-to-Nostr +relationship under the canonical 16-hex mesh ID; unproven announcements never +write that routing index. + +Generation-sensitive consumers use the 32-byte Noise handshake hash as a local +session token. The hash is cloned before handshake-state zeroization, and +session lookup, decrypt, expected-token encrypt, and leased identity mutation +share the Noise manager lock. A same-static replacement therefore cannot reuse +the previous generation's proof or destroy a session while a bound mutation is +in progress. + +## Remaining TOFU boundary + +The first public announcement is still self-signed trust-on-first-use. An +attacker can copy a public Noise key and self-sign an announcement, but cannot +complete the bound Noise handshake for that ID. Public-mesh identity admission +is intentionally not gated behind an automatic handshake in this change; doing +so is a separate availability/protocol decision. + +Consequently, discovery metadata or capability bits in an announcement are +hints, not proof of Noise-key possession. Security-sensitive capabilities must +be confirmed inside the authenticated Noise channel before they are pinned or +used to authorize a downgrade-sensitive behavior. diff --git a/docs/PRIVATE_MEDIA_V1.md b/docs/PRIVATE_MEDIA_V1.md new file mode 100644 index 00000000..5fbe0b95 --- /dev/null +++ b/docs/PRIVATE_MEDIA_V1.md @@ -0,0 +1,168 @@ +# Private media v1 interoperability and migration + +Private media reuses the canonical `BitchatFilePacket` TLV. A capable sender +wraps the complete encoded file TLV as Noise payload type `0x20`, encrypts it +for the recipient, and only then fragments the final outer packet. + +## Encrypted wire contract + +- Outer packet type: `MessageType.NOISE_ENCRYPTED` (`0x11`). +- Decrypted Noise payload type: `NoisePayloadType.FILE_TRANSFER` (`0x20`). +- Noise payload data: one complete encoded `BitchatFilePacket`. +- Outer recipient: the target peer ID; never broadcast for private media. + +Prerelease iOS builds of #1434 briefly emitted the inner file type as `0x09`. +Android accepts that value on decode and immediately canonicalizes it to +`NoisePayloadType.FILE_TRANSFER`; every Android encode remains `0x20`. Do not +allocate or emit a second Noise payload type for this format. + +The decode-only `0x09` alias may be removed only after every TestFlight/internal +build that emitted it has expired and the project's minimum-supported-client +policy excludes those builds. Track that release criterion explicitly; do not +remove the alias on an arbitrary calendar date. + +## Discovery hint + +Identity announcement TLV `0x05` is a minimal little-endian bitfield. Bit 8 +means the peer implements private-media v1, so its exact encoding is: + +```text +05 02 00 01 +| | |----- capability bytes: 0x0100 little-endian +| |-------- value length +|----------- capabilities TLV +``` + +Current Android builds include this TLV in broadcast and peer-directed +announcements over both BLE and Wi-Fi Aware. Older clients safely skip the +unknown TLV. Its absence, or a present TLV with bit 8 clear, does not invalidate +the announcement. + +Decoders retain unknown low-64-bit capability bits and unknown announcement +TLVs so a decode/re-encode cycle does not erase newer extensions. + +The announcement bit is discovery metadata only. A self-signed announcement +does not prove possession of its public Noise key, so it never authorizes +encrypted media, creates a capability pin, or satisfies a pending send. + +## Authenticated peer state (`0x21`) + +Every newly authenticated Noise generation, including a rekey with the same +static key, exchanges a fresh peer-state proof. Its decrypted Noise payload +type is `0x21`, followed by this canonical byte sequence: + +```text +01 version +01 capabilities, minimal little-endian +02 20 <32 bytes> Ed25519 signing public key +``` + +Both known TLVs are required exactly once. Decoders reject an unknown version, +truncated TLV, duplicate known TLV, a capability length outside `1...8`, a +non-minimal capability value, or an Ed25519 key whose length is not 32. Unknown +TLVs are skipped for forward compatibility, and the two known TLVs may arrive +in either order. + +Each endpoint sends `0x21` when the generation authenticates and echoes its +local state at most once after accepting the peer's first valid proof for that +generation. Repeated identical proofs are idempotent; a different second proof +cannot replace the first within that generation. A five-second generation +watchdog distinguishes an older client that ignores `0x21` from a new client +that supplied a proof. Persisted state from a previous connection never +satisfies the fresh-generation watchdog. + +Locally, the Noise handshake hash is the generation token. Decryption returns +that token with the plaintext, coordinator mutations hold a lease on that exact +session, and private-media encryption accepts the policy decision only while +the same token remains active. A same-static rekey therefore cannot reuse an +older proof or race policy into encrypting on an unproved generation. + +The proof is bound by the Noise channel to the exact authenticated 32-byte +remote static key and canonical peer ID. Store its capabilities and 32-byte +Ed25519 key under the SHA-256 fingerprint of that static key in encrypted +identity state. A proof with bit 8 creates an HSTS-style private-media pin; a +later no-bit proof does not erase that history. Panic wipe clears both records +and prevents an in-flight pre-wipe controller from restoring them. + +The persisted Ed25519 key is consulted before accepting later announcements. +This lets a valid proof recover from a copied-static, wrong-Ed preannouncement, +while preventing that preannouncement from winning again after restart. A +fresh proof in a later Noise generation may intentionally rotate the Ed25519 +key; an announcement by itself cannot. + +## Mixed-client send policy + +- No live authenticated Noise remote-static key: retain the first send intent, + emit no media or local echo, and initiate one Noise handshake. +- Live generation waiting for `0x21`: retain that same intent while the + five-second peer-state watchdog runs. +- Fresh proof with bit 8: automatically retry the retained intent and send + encrypted `0x11` / `0x20` after final-packet admission. +- Fresh proof without bit 8, or a five-second no-proof timeout for an unpinned + old client: retry the retained intent by showing the existing explicit + one-shot legacy consent prompt. +- A previously pinned identity that proves no bit, or fails to prove state in + the current generation, is visibly blocked. It cannot silently fall back. + +The exact automatic intent is reserved before its first policy evaluation, so a +proof or timeout callback racing that evaluation cannot be lost. It is bounded +and singular, and expires after 15 seconds with a visible system message. +Retries are serialized and always re-run current policy and exact packet +admission. No waiting, rejected, expired, or cancelled attempt creates a local +file echo or transmits raw media; terminal rejection is shown as a system +message rather than failing silently. + +The legacy consent path sends a recipient-directed raw +`MessageType.FILE_TRANSFER` (`0x22`) packet. Its contents are visible to relays, +so the UI must say that it is not end-to-end encrypted. The final routed packet +must carry a valid Ed25519 signature over the canonical packet bytes; signing +failure aborts the send. Receivers reject unsigned or invalid signed directed +raw files. + +Consent is consumed at most once. On approval the sender re-runs the policy and +packet admission checks. If a bit-8 proof arrived while the dialog was open, +the send upgrades to encrypted mode. Cancellation, duplicate approval, panic +wipe, and changed security state cannot cause a later send. + +## Final-packet admission + +Before creating a local echo or progress mapping, the sender builds the exact +encrypted-or-legacy packet, attaches its final source route, signs it, and +creates the exact transport fragment plan. Commit sends that prepared plan +without rebuilding it. + +One packet may use at most 256 fragments. This limit is checked after route, +signature, encryption, and envelope overhead are known; therefore there is no +single safe file-byte estimate for every route. Fragment totals and indices +must also fit their unsigned 16-bit wire fields without truncation. A rejected +plan creates no local echo and sends no fragments. + +The 256-fragment limit is a transport/reassembly safety bound, not a capability +negotiated through bit 8. A future larger transfer protocol needs a separate +capability and bounded streaming design. + +## Compatibility summary + +- New Android to new iOS/Android: generation-scoped authenticated `0x21` + exchange, then encrypted Noise `0x20`. +- Prerelease iOS to new Android: encrypted Noise `0x09` is decoded, + canonicalized to `0x20`, and delivered during the migration window. +- New Android to an older client: the unknown `0x21` is ignored; after the + five-second watchdog, an unpinned identity may use one explicitly consented, + relay-visible signed raw `0x22` transfer. +- Old clients receiving a new announcement: ignore TLV `0x05` and continue + operating normally. Old clients receiving `0x21` drop the unknown inner type + without affecting their existing Noise session or private messages. +- A previously capable authenticated identity cannot force a silent downgrade + by omitting `0x21`, clearing the bit, or changing only its announcement. + +Do not remove the `0x21` watchdog/legacy-consent migration path until the +minimum-supported Android and iOS versions both emit an authenticated bit-8 +`0x21` proof on every Noise generation, and the released legacy population has +aged out under the project's explicit support policy. Merely observing an +announcement bit or waiting for an arbitrary date is not sufficient. The HSTS +pin remains necessary after that point; removing old-client consent must not +re-enable a raw automatic fallback. + +Public media remains signed broadcast `MessageType.FILE_TRANSFER` (`0x22`) and +is outside this private-media capability. diff --git a/docs/file_transfer.md b/docs/file_transfer.md index 01b3e6f7..333c4d5a 100644 --- a/docs/file_transfer.md +++ b/docs/file_transfer.md @@ -3,13 +3,20 @@ This document is the exhaustive implementation guide for Bitchat’s Bluetooth file transfer protocol for voice notes (audio) and images, including interactive features like waveform seeking. It describes the on‑wire packet format (both v1 and v2), fragmentation/progress/cancellation, sender/receiver behaviors, and the complete UX we implemented in the Android client so that other implementers can interoperate and match the user experience precisely. **Protocol Versions:** -- **v1**: Original protocol with 2‑byte payload length (≤ 64 KiB files) -- **v2**: Extended protocol with 4-byte payload length (≤ 4 GiB files) - use for all file transfers -- File transfer packets use v2 format by default for optimal compatibility +- **v1**: Original envelope with a 2-byte payload length. +- **v2**: Extended envelope with a 4-byte payload length. +- Public and legacy raw file-transfer envelopes use v2. A Noise-encrypted + private envelope may use v1 when its ciphertext fits, and source routing + upgrades the final envelope to v2. +- The payload-length field is not the practical transfer limit. Private-media + admission is limited to 256 final fragments after route, signature, + encryption, and envelope overhead. Generic/public outbound fragmentation + retains the UInt16 wire range; receivers may impose a lower safety bound. **Interactive Features:** - **Waveform Seeking**: Tap anywhere on audio waveforms to jump to that playback position -- **Large File Support**: v2 protocol enables multi-GiB file transfers through fragmentation +- **Bounded Transfer Support**: v2 removes the 16-bit envelope-length limit, + while bounded fragmentation protects receivers from unbounded reassembly. - **Unified Experience**: Identical UX between platforms with enhanced user control The guide is organized into: @@ -34,12 +41,15 @@ Bitchat BLE transport carries application messages inside the common `BitchatPac Fields (subset relevant to file transfer): - `version: UByte` — protocol version (`1` for v1, `2` for v2 with extended payload length). -- `type: UByte` — message type. File transfer uses `MessageType.FILE_TRANSFER (0x22)`. +- `type: UByte` — public file transfer uses `MessageType.FILE_TRANSFER (0x22)`; + private file transfer uses outer `MessageType.NOISE_ENCRYPTED (0x11)`. - `senderID: ByteArray (8)` — 8‑byte binary peer ID. - `recipientID: ByteArray (8)` — 8‑byte recipient. For public: `SpecialRecipients.BROADCAST (0xFF…FF)`; for private: the target peer’s 8‑byte ID. - `timestamp: ULong` — milliseconds since epoch. -- `payload: ByteArray` — TLV file payload (see below). -- `signature: ByteArray?` — optional signature (present for private sends in our implementation, to match iOS integrity path). +- `payload: ByteArray` — the public form contains the file TLV directly. The + private form contains Noise ciphertext which authenticates payload type + `FILE_TRANSFER (0x20)` followed by the same file TLV. +- `signature: ByteArray?` — packet signature added by the mesh send path. - `ttl: UByte` — hop TTL (we use `MAX_TTL` for broadcast, `7` for private). Envelope creation and broadcast paths are implemented in: @@ -48,7 +58,9 @@ Envelope creation and broadcast paths are implemented in: - `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt) - `app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt) -Private sends are additionally encrypted at the higher layer (Noise) for text messages, but file transfers use the `FILE_TRANSFER` message type in the clear at the envelope level with content carried inside a TLV. See code for any deployment‑specific enforcement. +Private sends encrypt the complete file TLV as Noise payload `0x20` before +outer fragmentation. See `PRIVATE_MEDIA_V1.md` for the capability and mixed- +client interoperability contract. ### 1.2 Binary Protocol Extensions (v2) @@ -77,19 +89,23 @@ PayloadLength: 4 bytes (big-endian, max ~4 GiB) ``` - **Header Size**: Increased from 13 to 15 bytes. -- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4 bytes), allowing file transfers up to ~4 GiB. -- **Backward Compatibility**: Clients must support both v1 and v2 decoding. File transfer packets always use v2. +- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4 + bytes). That is the field's theoretical range, not the permitted mesh + reassembly size. +- **Backward Compatibility**: Clients must support both v1 and v2 decoding. - **Implementation**: See `BinaryProtocol.kt` with `getHeaderSize(version)` logic. -#### Use Cases for v2 -- **Large Audio Files**: Professional recordings, podcasts, or music samples. -- **High-Resolution Images**: Full-resolution photos from modern smartphones. -- **Future File Types**: PDFs, documents, archives, or other large media. +#### Use cases for v2 + +v2 carries file payloads that exceed the v1 envelope-length field and carries +source-route metadata. It does not imply multi-gigabyte mesh transfer support. #### Interoperability Requirements - Clients receiving v2 packets must decode 4-byte `PayloadLength` fields. - Clients sending file transfers should preferentially use v2 format. -- Fragmentation still applies: large files are split into fragments that fit within BLE MTU constraints (~128 KiB per fragment). +- Fragmentation still applies. Each serialized mesh fragment fits the 512-byte + transport threshold; the data portion is at most 469 bytes and becomes + smaller when recipient or source-route overhead is present. ### 1.3 File Transfer TLV payload (BitchatFilePacket) @@ -114,7 +130,8 @@ Encoding rules: - Standard TLVs use `1 byte type + 2 bytes big‑endian length + value`. - CONTENT uses a 4‑byte big‑endian length to allow payloads well beyond 64 KiB. -- With the v2 envelope (4‑byte payload length), CONTENT can be large; transport still fragments oversize packets to fit BLE MTU. +- With the v2 envelope (4-byte payload length), CONTENT can exceed 64 KiB, but + sender and receiver fragmentation policies still bound practical transfers. - Implementations should validate TLV boundaries; decoding should fail fast on malformed structures. Decoding rules (v2): @@ -140,8 +157,13 @@ Legacy Compatibility (optional, for mixed‑version meshes): File transfers reuse the mesh broadcaster’s fragmentation logic: - `BluetoothPacketBroadcaster` checks if the serialized envelope exceeds the configured MTU and splits it into fragments via `FragmentManager`. -- Fragments are sent with a short inter‑fragment delay (currently ~200 ms; matches iOS/Rust behavior notes in code). +- Fragments are sent with a short inter-fragment delay (currently 20 ms). - When only one fragment is needed, send as a single packet. +- Android receivers reject fragment sets declaring more than 256 fragments. + Private senders use that same 256-fragment limit and calculate it from the + exact final routed, signed, and encrypted packet before creating a local + echo. Generic/public outbound planning retains its prior UInt16 count range, + so this private-media migration does not silently change public sending. ### 2.2 Transfer ID and progress events @@ -216,24 +238,38 @@ Files: - Files saved under `files/voicenotes/outgoing/voice_YYYYMMDD_HHMMSS.m4a`. 2) Local echo - - We create a `BitchatMessage` with content `"[voice] "` and add to the appropriate timeline (public/channel/private). - - For private: `messageManager.addPrivateMessage(peerID, message)`. For public/channel: `messageManager.addMessage(message)` or add to channel. + - Public/channel sends create their timeline entry before dispatch. + - Private sends first finish capability policy and exact final-fragment + admission. They create the local echo and progress mapping only for an + admitted plan, then atomically commit that prepared plan. 3) Packet creation - Build a `BitchatFilePacket`: - `fileName`: basename (e.g., `voice_… .m4a`) - `fileSize`: file length - `mimeType`: `audio/mp4` - - `content`: full bytes (ensure content ≤ 64 KiB; with chosen codec params typical short notes fit fragmentation constraints) + - `content`: full bytes; final-packet admission determines whether it fits + the 256-fragment limit. - Encode TLV; compute `transferId = sha256Hex(payload)`. - Map `transferId → messageId` for UI progress. 4) Send - Public: `BluetoothMeshService.sendFileBroadcast(filePacket)`. - - Private: `BluetoothMeshService.sendFilePrivate(peerID, filePacket)`. + - Private UI: `prepareFilePrivate(...)`, followed by one-shot commit of a + ready plan. The non-interactive `sendFilePrivate(...)` entry point commits + only encrypted-ready plans and never silently chooses a legacy downgrade. - Broadcaster handles fragmentation and progress emission. -5) Waveform +5) Mixed-client private migration + - A verified legacy recipient with no private-media capability requires a + user warning and one-shot consent. + - Approval rechecks policy. The fallback is recipient-directed raw `0x22`, + visible to relays, and must have a valid Ed25519 packet signature. + - No authenticated Noise identity starts a handshake without a local echo + or media send; the user retries after it completes. Signing failure or a + private plan above 256 final fragments aborts without an echo or send. + +6) Waveform - We extract a 120‑bin waveform from the recorded file (the same extractor used for the receiver) and cache by file path, so sender and receiver waveforms are identical. Core files: @@ -373,8 +409,10 @@ Files: - Path markers in messages - We use simple content markers: `"[voice] ", "[image] ", "[file] "` for local rendering. These are not sent on the wire; the actual file bytes are inside the TLV payload. - Progress math for images relies on `(sent / total)` from `TransferProgressManager` (fragment‑level granularity). The block grid density can be tuned; currently 24×16. -- Private vs public: both use the same file TLV; only the envelope `recipientID` differs. Private may have signatures; code shows a signing step consistent with iOS behavior prior to broadcast to ensure integrity. -- BLE timing: there is a 200 ms inter‑fragment delay for stability. Adjust as needed for your radio stack while maintaining compatibility. +- Private vs public: both use the same file TLV. Private media wraps it in a + Noise payload of type `0x20`; public media carries it directly in a `0x22` + packet. +- BLE timing: there is a 20 ms inter-fragment delay. Adjust as needed for your radio stack while maintaining compatibility. --- @@ -426,7 +464,9 @@ Fullscreen image: - FILE_NAME and MIME_TYPE: `type(1) + len(2) + value` - FILE_SIZE: `type(1) + len(2=4) + value(4, UInt32 BE)` - CONTENT: `type(1) + len(4) + value` -3. Embed the TLV into a `BitchatPacket` envelope with `type = FILE_TRANSFER (0x22)` and the correct `recipientID` (broadcast vs private). +3. For public media, embed the TLV in `FILE_TRANSFER (0x22)`. For private media, + prefix the TLV with Noise payload type `0x20`, encrypt it for the recipient, + and put the ciphertext in `NOISE_ENCRYPTED (0x11)`. 4. Fragment, send, and report progress using a transfer ID derived from `sha256(payload)` so the UI can map progress to a message. 5. Support cancellation at the fragment sender: stop sending remaining fragments and propagate a cancel to the UI (we remove the message). 6. On receive, decode TLV, persist to an app directory (separate audio/images/other), and create a chat message with content marker `"[voice] path"`, `"[image] path"`, or `"[file] path"` for local rendering. From 61588db4741fec4d1947f17dda2de23d96a2383b Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:34:08 +0300 Subject: [PATCH 29/32] chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37 (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37 Bring the toolchain and every dependency to latest stable. No app source changes were required. Toolchain: - AGP 8.10.1 -> 9.3.1, Gradle 8.13 -> 9.6.1, Kotlin 2.2.0 -> 2.4.10 - compileSdk 35 -> 37, targetSdk 35 -> 37 (Android 17, stable) - Java 8 -> 11 AGP 9 migration (built-in Kotlin): - Drop org.jetbrains.kotlin.android; AGP 9 provides Kotlin natively and the plugin is incompatible with the new DSL - Migrate kotlinOptions.jvmTarget to kotlin.compilerOptions (the String setter is a hard error in Kotlin 2.4) - Drop android.enableJetifier (deprecated, removed in AGP 10, no support library deps remain) Libraries: - Compose BOM 2025.06.01 -> 2026.06.01, activity-compose 1.10.1 -> 1.13.0 - core-ktx 1.16.0 -> 1.19.0, lifecycle 2.9.1 -> 2.11.0 (unified with lifecycle-process, which had drifted to 2.8.7) - okhttp 4.12.0 -> 5.4.0, coroutines 1.10.2 -> 1.11.0, gson 2.13.1 -> 2.14.0 - BouncyCastle 1.70 -> 1.85, switching bcprov-jdk15on -> bcprov-jdk18on (jdk15on is abandoned; same org.bouncycastle packages) - Tink 1.10.0 -> 1.23.0, CameraX 1.5.2 -> 1.6.1, gms-location 21.3.0 -> 21.4.0 - security-crypto 1.1.0-beta01 -> 1.1.0, navigation-compose 2.9.1 -> 2.9.8 - exifinterface 1.3.7 -> 1.4.2, moved from a hardcoded coordinate into the version catalog - Tests: espresso 3.6.1 -> 3.7.0, test-ext 1.2.1 -> 1.3.0, mockito-kotlin 4.1.0 -> 6.3.0; mockito-inline (deprecated) -> mockito-core 5.23.0; coroutines-test 1.6 -> 1.11.0, now sharing the coroutines version ref instead of drifting Robolectric stays pinned at 4.15: 4.16+ breaks EncryptionServiceTest with "AndroidKeyStore not found". Bisected away from security-crypto and shown not to be SDK-level related. Unpinning needs an EncryptionService refactor, which is deliberately left to a follow-up PR. targetSdk behaviour changes for API 36 and 37 were audited against the source: edge-to-edge and predictive back are already handled, ACCESS_LOCAL_NETWORK is not needed (loopback only, for Arti's SOCKS proxy), the reflection in ChatViewModel touches instance rather than static final fields, and there is no RFCOMM or scheduleAtFixedRate usage. Verified: compileDebugKotlin, testDebugUnitTest (96 tests, 0 failures), bundleRelease with R8, gradlew help, and build --dry-run. The 6 R8 "cannot parse kotlin metadata" warnings present under AGP 8.13.2 are gone under 9.3.1. Not verified on hardware. BLE mesh, foreground services, Nostr relay websockets, Tor, and the Noise handshake still need a device smoke test. Co-Authored-By: Claude Opus 5 (1M context) * fix: declare ACCESS_LOCAL_NETWORK for Wi-Fi Aware on Android 17 Android 17 (API 37) makes local network protection mandatory for apps targeting it. WifiAwareMeshService reaches peers over link-local IPv6 TCP sockets (connectAwareClientSocket), which may be gated by the new ACCESS_LOCAL_NETWORK runtime permission once targetSdk is raised to 37. The official local network permission documentation frames the feature as LAN access and does not explicitly state whether Wi-Fi Aware peer-to-peer networks are in scope, so this is defensive rather than confirmed-necessary. The sockets are bound to a dedicated Aware Network obtained via requestNetwork, not the user's subnet. Declaring it costs nothing: ACCESS_LOCAL_NETWORK shares the NEARBY_DEVICES group with NEARBY_WIFI_DEVICES, so users who have already granted the latter are not prompted again. The runtime request is gated on SDK_INT >= 37 so older devices are unaffected. Raised by automated review on #750. Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures), bundleRelease, and ACCESS_LOCAL_NETWORK present in the merged manifest. Not verified on an Android 17 device. Co-Authored-By: Claude Opus 5 (1M context) * docs: correct ACCESS_LOCAL_NETWORK permission-group claim Device testing on Android 17 (API 37) disproved the earlier claim that ACCESS_LOCAL_NETWORK is effectively free because it shares the NEARBY_DEVICES group with NEARBY_WIFI_DEVICES. Granting NEARBY_WIFI_DEVICES alone leaves ACCESS_LOCAL_NETWORK denied: pm grant ... NEARBY_WIFI_DEVICES -> NEARBY_WIFI_DEVICES: granted=true -> ACCESS_LOCAL_NETWORK: granted=false The two are tracked and granted independently, so ACCESS_LOCAL_NETWORK has to be requested explicitly. That is exactly what the wifiAwarePermissions() list already does, so no behavioural change is needed — only the comments were wrong. Whether the runtime dialog bundles the two into a single prompt remains unverified, since enabling Wi-Fi Aware from Debug Settings after onboarding never triggers a permission request at all (pre-existing, unrelated to this branch). Comment-only change. Co-Authored-By: Claude Opus 5 (1M context) * fix: request Wi-Fi Aware permissions when enabling it from Debug Settings Enabling Wi-Fi Aware from the Debug Settings sheet never requested the permissions it needs. The permission flow is reachable only through PermissionManager.getRequiredPermissions(), which gates the Wi-Fi Aware entries behind shouldRequireWifiAwarePermission() — and that returns false unless the debug toggle is already on. Since the toggle defaults to off, onboarding never asks, and flipping it later starts WifiAwareController directly, which only checks the permission and bails. The result was a silent dead end: Wi-Fi Aware could never start, and the controller logged "Missing NEARBY_WIFI_DEVICES permission" on a 5s retry loop indefinitely. Reproduced on a Pixel 9a (Android 17) and a Samsung SM-A366E (Android 16); both needed adb grants to get the transport running at all. The toggle and the Start chip now request the permissions first and only enable the transport once NEARBY_WIFI_DEVICES is granted. ACCESS_LOCAL_NETWORK is treated as best-effort since it does not exist below API 37 — confirmed by `pm grant` rejecting it as an unknown permission on the Android 16 device. The list comes from PermissionManager.wifiAwarePermissions() so the API 37 gate has a single definition. Also corrects the permission-group comments now that both levels are verified on Android 17: grants are tracked independently (granting NEARBY_WIFI_DEVICES alone leaves ACCESS_LOCAL_NETWORK denied), but the two share the NEARBY_DEVICES group so requesting them together produces a single "Nearby devices" prompt. Verified on device: after a clean uninstall/reinstall, toggling Wi-Fi Aware produced one prompt and left both permissions granted with the USER_SET flag. Addresses the second automated review finding on #750. Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) * fix: version-gate Wi-Fi Aware permissions, check live grant state Two issues from automated review of ee1ee3ad. wifiAwarePermissions() returned NEARBY_WIFI_DEVICES unconditionally, but that permission only exists from API 33 while minSdk is 26 and Wi-Fi Aware is available from API 26. On an API 26-32 device the new enable path would request an unknown permission, receive a denial, and never enable a transport that needs no runtime permission there at all — a regression introduced by the previous commit. Both entries are now version-gated. The result callback also inferred the Nearby grant from the result map, which omits permissions that were already held and so filtered out before launching. It now reads the live permission state instead. A denied ACCESS_LOCAL_NETWORK still does not block enabling: the controller starts fine without it (verified on Android 17), and its necessity for link-local sockets remains unproven, so a denial should not disable a transport that otherwise works. Also trims the comments added in the last two commits down to the density of the surrounding code. Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- app/build.gradle.kts | 18 +++-- app/src/main/AndroidManifest.xml | 2 + .../android/onboarding/PermissionManager.kt | 24 ++++++- .../android/ui/debug/DebugSettingsSheet.kt | 42 +++++++++++- build.gradle.kts | 1 - gradle.properties | 3 - gradle/libs.versions.toml | 66 ++++++++++--------- gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 110 insertions(+), 48 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 697b91a5..7ee54989 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.compose) } @@ -65,11 +66,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } buildFeatures { compose = true @@ -86,6 +84,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + dependencies { // Core Android dependencies implementation(libs.androidx.core.ktx) @@ -143,7 +147,7 @@ dependencies { implementation(libs.androidx.security.crypto) // EXIF orientation handling for images - implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation(libs.androidx.exifinterface) // Testing testImplementation(libs.bundles.testing) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f83ecefb..8c732bbc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,8 @@ + + diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index e7e02203..23235068 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -39,6 +39,26 @@ class PermissionManager(private val context: Context) { } } + /** + * Runtime permissions for the Wi‑Fi Aware transport, version-gated because neither exists + * at minSdk 26 — requesting an unknown permission comes back permanently denied. + * + * ACCESS_LOCAL_NETWORK is defensive: Android 17 gates local network access, and the + * transport reaches peers over link-local IPv6 sockets. It is granted separately from + * NEARBY_WIFI_DEVICES but shares its permission group, so the two prompt only once. + */ + fun wifiAwarePermissions(): List { + val permissions = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // API 37 == Android 17; no named VERSION_CODES constant is available yet. + if (Build.VERSION.SDK_INT >= 37) { + permissions.add(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + return permissions + } + /** * Check if this is the first time the user is launching the app */ @@ -87,7 +107,7 @@ class PermissionManager(private val context: Context) { // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission if (shouldRequireWifiAwarePermission()) { - permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + permissions.addAll(wifiAwarePermissions()) } // Notification permission intentionally excluded to keep it optional @@ -232,7 +252,7 @@ class PermissionManager(private val context: Context) { // Wi‑Fi Aware category (Android 13+) if (shouldRequireWifiAwarePermission()) { - val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) + val wifiAwarePermissions = wifiAwarePermissions() categories.add( PermissionCategory( type = PermissionType.WIFI_AWARE, diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 65b19182..be950316 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -37,6 +37,11 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalContext +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle @@ -128,6 +133,37 @@ fun DebugSettingsSheet( val bleEnabled by manager.bleEnabled.collectAsState() val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() + + // Onboarding only asks for these when the toggle is already on, and it defaults to off, + // so enabling from here has to request them or the controller never starts. + val wifiAwarePermissions = remember { PermissionManager(context).wifiAwarePermissions() } + val wifiAwarePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { _ -> + // Check live state, not the result map — already-held permissions are filtered out + // before launching. Only NEARBY_WIFI_DEVICES blocks startup; the other is defensive. + val nearbyPermission = android.Manifest.permission.NEARBY_WIFI_DEVICES + val nearbyGranted = nearbyPermission !in wifiAwarePermissions || + ContextCompat.checkSelfPermission(context, nearbyPermission) == + PackageManager.PERMISSION_GRANTED + if (nearbyGranted) { + manager.setWifiAwareEnabled(true) + } else { + manager.addDebugMessage( + DebugMessage.SystemMessage("Wi‑Fi Aware needs the Nearby devices permission") + ) + } + } + val enableWifiAware: () -> Unit = { + val missing = wifiAwarePermissions.filter { + ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + manager.setWifiAwareEnabled(true) + } else { + wifiAwarePermissionLauncher.launch(missing.toTypedArray()) + } + } val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() val wifiAwareConnected by manager.wifiAwareConnected.collectAsState() val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState() @@ -345,7 +381,9 @@ fun DebugSettingsSheet( Switch( checked = wifiAwareEnabled && wifiAwareSupported, enabled = wifiSwitchEnabled, - onCheckedChange = { manager.setWifiAwareEnabled(it) } + onCheckedChange = { on -> + if (on) enableWifiAware() else manager.setWifiAwareEnabled(false) + } ) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -643,7 +681,7 @@ fun DebugSettingsSheet( } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { AssistChip( - onClick = { manager.setWifiAwareEnabled(true) }, + onClick = enableWifiAware, enabled = wifiAwareSupported, label = { Text("Start") } ) diff --git a/build.gradle.kts b/build.gradle.kts index 44082684..44c5199e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false } diff --git a/gradle.properties b/gradle.properties index 4c4ae04f..c5ca74c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,9 +9,6 @@ # Specifies the JVM arguments used for the daemon process. android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true - # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6a5411a..4a8ad015 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,72 +1,72 @@ [versions] # Android and Kotlin -agp = "8.10.1" -kotlin = "2.2.0" -compileSdk = "35" +agp = "9.3.1" +kotlin = "2.4.10" +compileSdk = "37" minSdk = "26" # API 26 for proper BLE support -targetSdk = "35" +targetSdk = "37" # AndroidX Core -core-ktx = "1.16.0" -lifecycle-runtime = "2.9.1" -activity-compose = "1.10.1" +core-ktx = "1.19.0" +lifecycle-runtime = "2.11.0" +activity-compose = "1.13.0" appcompat = "1.7.1" # Compose -compose-bom = "2025.06.01" +compose-bom = "2026.06.01" # Navigation -navigation-compose = "2.9.1" +navigation-compose = "2.9.8" # Accompanist accompanist-permissions = "0.37.3" # Cryptography -bouncycastle = "1.70" -tink-android = "1.10.0" +bouncycastle = "1.85" +tink-android = "1.23.0" # JSON -gson = "2.13.1" +gson = "2.14.0" # Coroutines -kotlinx-coroutines = "1.10.2" +kotlinx-coroutines = "1.11.0" # Bluetooth -nordic-ble = "2.6.1" +nordic-ble = "2.11.0" # WebSocket -okhttp = "4.12.0" +okhttp = "5.4.0" tor-android-binary = "0.4.4.6" # Google Play Services -gms-location = "21.3.0" +gms-location = "21.4.0" # Security -security-crypto = "1.1.0-beta01" +security-crypto = "1.1.0" # QR zxing-core = "3.5.4" +# EXIF +exifinterface = "1.4.2" + # CameraX / ML Kit -camerax = "1.5.2" +camerax = "1.6.1" mlkit-barcode = "17.3.0" # Testing junit = "4.13.2" -androidx-test-ext = "1.2.1" -espresso = "3.6.1" -mockito-kotlin = "4.1.0" -mockito-inline = "4.1.0" -roboelectric = "4.15" -kotlinx-coroutines-test = "1.6" - -lifecycle-process = "2.8.7" +androidx-test-ext = "1.3.0" +espresso = "3.7.0" +mockito-kotlin = "6.3.0" +mockito-core = "5.23.0" +roboelectric = "4.15" # 4.16+ drops the AndroidKeyStore shim; breaks EncryptionServiceTest [libraries] # AndroidX Core androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } -androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-process" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -90,7 +90,7 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist-permissions" } # Cryptography -bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } # JSON @@ -117,6 +117,9 @@ androidx-security-crypto = { module = "androidx.security:security-crypto", versi # QR zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } +# EXIF +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } + # CameraX / ML Kit androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } @@ -130,14 +133,13 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } -mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"} -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "kotlin-parcelize" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } @@ -165,7 +167,7 @@ testing = [ "androidx-test-ext-junit", "androidx-test-espresso-core", "mockito-kotlin", - "mockito-inline", + "mockito-core", "roboelectric", "kotlinx-coroutines-test" ] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..a351597e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 92d07b22fab04b85f8bc8e23f5fe4feb418dd43e Mon Sep 17 00:00:00 2001 From: Ruslan <96379204+yet300@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:54:51 +0400 Subject: [PATCH 30/32] ui: refresh chat branding and text message layout (#767) * Add BitChatBrandButton and BitChatIcon Add a dedicated brand button component and custom vector icon to the UI library. Refactor the chat header to use this component, replacing the previous modifier-based multi-click implementation. * **BitChatBrandButton**: A new Composable that encapsulates single and triple click detection logic using coroutine delays and tap counting. * **BitChatIcon**: A custom pixel-style `ImageVector` representing the brand. * **ChatHeader**: Updated to use the new brand button and included a visual separator (`/`) in the layout. * **ModifierExt.kt**: Removed the `singleOrTripleClickable` extension as its functionality is now handled internally by the brand button component. * ui: refactor text messages to two-row layout Refactor the message list items to use a two-row layout for standard text messages, while preserving the legacy compact format for system messages. * **UI Formatting**: Added `formatTextMessageSender`, `formatTextMessageMetadata`, and `formatTextMessageBody` in `ChatUIUtils.kt` to separate the rendering of sender info, timestamps/PoW, and message content. * **Layout Change**: Replaced the single-block `Text` component with a `Column` containing a `Row` (Sender + Metadata) and a message body `Text` block for standard messages. * **Interaction**: Updated `pointerInput` and `detectTapGestures` to handle nickname clicks in the header row and geohash/URL clicks within the message body row. * **Styling**: Refined `appendIOSFormattedContent` to support a `contentColor` parameter and applied specific font weights and colors (e.g., orange for self-messages) to match the platform's design language. * **System Messages**: Explicitly kept messages where `sender == "system"` on the original compact layout. * refactor(ui): extract annotated text pointer handling * fix(ui): address chat redesign review feedback * fix(ui): align PoW animation with text layout * refactor(ui): extract message interaction helpers --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- .../ui/component/button/BitChatBrandButton.kt | 69 ++++ .../component/text/AnnotatedClickableText.kt | 96 ++++++ .../android/core/ui/icon/BitChatIcon.kt | 48 +++ .../android/core/ui/utils/ModifierExt.kt | 57 ---- .../java/com/bitchat/android/ui/ChatHeader.kt | 22 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 133 +++++++- .../android/ui/MatrixEncryptionAnimation.kt | 248 +++++---------- .../bitchat/android/ui/MessageComponents.kt | 294 +++++++++++------- .../android/ui/MessageInteractionUtils.kt | 53 ++++ .../android/ui/media/AudioMessageItem.kt | 33 +- .../android/ui/media/ImageMessageItem.kt | 33 +- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-bn/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-fil/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-he/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ka/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-mg/strings.xml | 2 +- app/src/main/res/values-ms/strings.xml | 2 +- app/src/main/res/values-ne/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pa-rPK/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 2 +- app/src/main/res/values-ta/strings.xml | 2 +- app/src/main/res/values-th/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-ur/strings.xml | 1 + app/src/main/res/values-vi/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 44 +++ .../android/ui/MessageInteractionUtilsTest.kt | 63 ++++ 44 files changed, 812 insertions(+), 428 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt create mode 100644 app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt create mode 100644 app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt delete mode 100644 app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt new file mode 100644 index 00000000..94529d77 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.core.ui.component.button + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.bitchat.android.core.ui.icon.BitChatIcon +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +private val MultiClickThreshold = 300.milliseconds + +@Composable +fun BitChatBrandButton( + onClick: () -> Unit, + onTripleClick: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.primary, +) { + var tapCount by remember { mutableIntStateOf(0) } + var resetJob by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + val currentOnClick by rememberUpdatedState(onClick) + val currentOnTripleClick by rememberUpdatedState(onTripleClick) + + IconButton( + onClick = { + tapCount += 1 + resetJob?.cancel() + + if (tapCount == 3) { + tapCount = 0 + resetJob = null + currentOnTripleClick() + } else { + resetJob = coroutineScope.launch { + delay(MultiClickThreshold) + if (tapCount == 1) { + currentOnClick() + } + tapCount = 0 + resetJob = null + } + } + }, + modifier = modifier, + ) { + Icon( + imageVector = BitChatIcon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(16.dp), + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt new file mode 100644 index 00000000..3e1d7fe1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.core.ui.component.text + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow + +internal data class ClickedAnnotation( + val tag: String, + val item: String, +) + +internal fun findAnnotationAt( + text: AnnotatedString, + offset: Int, + annotationTags: List, +): ClickedAnnotation? { + for (tag in annotationTags) { + text.getStringAnnotations(tag = tag, start = offset, end = offset) + .firstOrNull() + ?.let { annotation -> + return ClickedAnnotation(tag = tag, item = annotation.item) + } + } + return null +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AnnotatedClickableText( + text: AnnotatedString, + annotationTags: List, + onAnnotationClick: (tag: String, item: String) -> Boolean, + modifier: Modifier = Modifier, + onLongPress: (() -> Unit)? = null, + color: Color = Color.Unspecified, + fontFamily: FontFamily? = null, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + style: TextStyle = LocalTextStyle.current, +) { + var layoutResult by remember { mutableStateOf(null) } + val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick) + val currentOnLongPress by rememberUpdatedState(onLongPress) + + Text( + text = text, + modifier = modifier.pointerInput(text, annotationTags, onLongPress != null) { + detectTapGestures( + onTap = { position -> + val offset = layoutResult + ?.getOffsetForPosition(position) + ?: return@detectTapGestures + + var remainingTags = annotationTags + while (remainingTags.isNotEmpty()) { + val annotation = findAnnotationAt( + text = text, + offset = offset, + annotationTags = remainingTags, + ) ?: break + if (currentOnAnnotationClick(annotation.tag, annotation.item)) { + return@detectTapGestures + } + remainingTags = remainingTags.drop( + remainingTags.indexOf(annotation.tag) + 1 + ) + } + }, + onLongPress = currentOnLongPress?.let { callback -> + { callback() } + }, + ) + }, + color = color, + fontFamily = fontFamily, + softWrap = softWrap, + overflow = overflow, + style = style, + onTextLayout = { layoutResult = it }, + ) +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt new file mode 100644 index 00000000..14247f2d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/icon/BitChatIcon.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.core.ui.icon + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val BitChatIcon: ImageVector + get() { + _BitChatIcon?.let { return it } + + return ImageVector.Builder( + name = "BitChatIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 8f, + viewportHeight = 8f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(2f, 0f) + lineTo(6f, 0f) + lineTo(6f, 1f) + lineTo(7f, 1f) + lineTo(7f, 2f) + lineTo(8f, 2f) + lineTo(8f, 5f) + lineTo(7f, 5f) + lineTo(7f, 6f) + lineTo(6f, 6f) + lineTo(6f, 8f) + lineTo(5f, 8f) + lineTo(5f, 7f) + lineTo(3f, 7f) + lineTo(3f, 6f) + lineTo(1f, 6f) + lineTo(1f, 5f) + lineTo(0f, 5f) + lineTo(0f, 2f) + lineTo(1f, 2f) + lineTo(1f, 1f) + lineTo(2f, 1f) + close() + } + }.build().also { _BitChatIcon = it } + } + +private var _BitChatIcon: ImageVector? = null diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt deleted file mode 100644 index 19dabb57..00000000 --- a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.bitchat.android.core.ui.utils - -import androidx.compose.foundation.clickable -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -fun Modifier.singleOrTripleClickable( - onSingleClick: () -> Unit, - onTripleClick: () -> Unit, - clickTimeThreshold: Long = 300L -): Modifier = composed { - var tapCount by remember { mutableIntStateOf(0) } - var lastTapTime by remember { mutableLongStateOf(0L) } - var singleClickJob by remember { mutableStateOf(null) } - val coroutineScope = rememberCoroutineScope() - - this.clickable { - val currentTime = System.currentTimeMillis() - - if (currentTime - lastTapTime < clickTimeThreshold) { - tapCount++ - } else { - tapCount = 1 - } - - lastTapTime = currentTime - - // Cancel any pending single click action - singleClickJob?.cancel() - singleClickJob = null - - when (tapCount) { - 1 -> { - // Wait to see if more taps come - singleClickJob = coroutineScope.launch { - delay(clickTimeThreshold) - if (tapCount == 1) { - onSingleClick() - } - } - } - 3 -> { - // Triple click detected - execute immediately - onTripleClick() - tapCount = 0 - } - } - - // Reset after threshold if no triple click - if (tapCount > 3) { - tapCount = 0 - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 7273813b..e91e30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.core.ui.utils.singleOrTripleClickable import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.core.ui.component.button.BitChatBrandButton /** * Header components for ChatScreen @@ -356,16 +356,18 @@ private fun MainHeader( modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(R.string.app_brand), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.primary, - modifier = Modifier.singleOrTripleClickable( - onSingleClick = onTitleClick, - onTripleClick = onTripleTitleClick - ) + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), ) - + + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.primary, + ) + Spacer(modifier = Modifier.width(2.dp)) NicknameEditor( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 6dd02584..d227eb41 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -3,13 +3,9 @@ package com.bitchat.android.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Shield -import androidx.compose.ui.graphics.vector.ImageVector import com.bitchat.android.model.BitchatMessage import com.bitchat.android.mesh.MeshService import androidx.compose.material3.ColorScheme @@ -50,9 +46,7 @@ fun formatMessageAsAnnotatedString( val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f // Determine if this message was sent by self - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) if (message.sender != "system") { // Get base color for this peer (iOS-style color assignment) @@ -117,7 +111,14 @@ fun formatMessageAsAnnotatedString( builder.pop() // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark) + appendIOSFormattedContent( + builder, + message.content, + message.mentions, + currentUserNickname, + baseColor, + isSelf, + ) // iOS-style timestamp at the END (smaller, grey) // Timestamp (and optional PoW badge) @@ -156,6 +157,108 @@ fun formatMessageAsAnnotatedString( return builder.toAnnotatedString() } +/** + * Build the sender label used by the two-row text-message layout. + */ +fun formatTextMessageSender( + message: BitchatMessage, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme +): AnnotatedString { + val builder = AnnotatedString.Builder() + val isDark = + colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium + val (baseName, suffix) = splitSuffix(message.sender) + + builder.pushStyle( + SpanStyle( + color = senderColor, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = senderWeight + ) + ) + builder.append("@") + val nicknameStart = builder.length + builder.append(truncateNickname(baseName)) + val nicknameEnd = builder.length + if (!isSelf) { + builder.addStringAnnotation( + tag = "nickname_click", + annotation = message.originalSender ?: message.sender, + start = nicknameStart, + end = nicknameEnd + ) + } + builder.pop() + + if (suffix.isNotEmpty()) { + builder.pushStyle( + SpanStyle( + color = senderColor.copy(alpha = 0.6f), + fontSize = BASE_FONT_SIZE.sp, + fontWeight = senderWeight + ) + ) + builder.append(suffix) + builder.pop() + } + + return builder.toAnnotatedString() +} + +/** + * Build the compact timestamp and optional proof-of-work label. + */ +fun formatTextMessageMetadata( + message: BitchatMessage, + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = Color.Gray.copy(alpha = 0.7f), + fontSize = (BASE_FONT_SIZE - 4).sp + ) + ) + builder.append(timeFormatter.format(message.timestamp)) + message.powDifficulty?.takeIf { it > 0 }?.let { bits -> + builder.append(" ⛨${bits}b") + } + builder.pop() + return builder.toAnnotatedString() +} + +/** + * Build only the message body while retaining mention, URL and geohash styling. + */ +fun formatTextMessageBody( + message: BitchatMessage, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme +): AnnotatedString { + val builder = AnnotatedString.Builder() + val isDark = + colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + + appendIOSFormattedContent( + builder = builder, + content = message.content, + mentions = message.mentions, + currentUserNickname = currentUserNickname, + baseColor = accentColor, + isSelf = isSelf, + contentColor = colorScheme.onSurface + ) + return builder.toAnnotatedString() +} + /** * Build only the nickname + timestamp header line for a message, matching styles of normal messages. */ @@ -169,9 +272,7 @@ fun formatMessageHeaderAnnotatedString( val builder = AnnotatedString.Builder() val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) if (message.sender != "system") { val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) @@ -338,7 +439,7 @@ private fun appendIOSFormattedContent( currentUserNickname: String, baseColor: Color, isSelf: Boolean, - isDark: Boolean + contentColor: Color = baseColor, ) { // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() @@ -416,7 +517,7 @@ private fun appendIOSFormattedContent( val beforeText = content.substring(lastEnd, range.first) if (beforeText.isNotEmpty()) { builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -476,7 +577,7 @@ private fun appendIOSFormattedContent( "hashtag" -> { // Render general hashtags like normal content builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -530,7 +631,7 @@ private fun appendIOSFormattedContent( } else { // Fallback: treat as normal text builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) @@ -547,7 +648,7 @@ private fun appendIOSFormattedContent( if (lastEnd < content.length) { val remainingText = content.substring(lastEnd) builder.pushStyle(SpanStyle( - color = baseColor, + color = contentColor, fontSize = BASE_FONT_SIZE.sp, fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 045ea127..4ca8674f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -1,16 +1,17 @@ package com.bitchat.android.ui -import androidx.compose.material3.* +import androidx.compose.material3.ColorScheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.text.SimpleDateFormat import kotlin.random.Random /** @@ -18,7 +19,6 @@ import kotlin.random.Random */ private enum class CharacterAnimationState { ENCRYPTED, // Showing random encrypted characters - DECRYPTING, // Transitioning to final character FINAL // Showing final decrypted character } @@ -69,205 +69,111 @@ object PoWMiningTracker { } /** - * Enhanced message display that shows matrix animation during PoW mining - * Formats message like a normal message but animates only the content portion + * Shows the active PoW animation inside the same two-row layout used by static text messages. */ @Composable fun MessageWithMatrixAnimation( - message: com.bitchat.android.model.BitchatMessage, - messages: List = emptyList(), + message: BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.MeshService, - colorScheme: androidx.compose.material3.ColorScheme, - timeFormatter: java.text.SimpleDateFormat, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?, - onImageClick: ((String, List, Int) -> Unit)?, - modifier: Modifier = Modifier + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, ) { - val isAnimating = shouldAnimateMessage(message.id) - - if (isAnimating) { - // During animation: Show formatted message with animated content - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - modifier = modifier - ) - } else { - // After animation: Show complete normal message using existing formatter - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) - - Text( - text = annotatedText, - modifier = modifier, - fontFamily = FontFamily.Monospace, - softWrap = true - ) - } + AnimatedMessageDisplay( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier, + ) } /** - * Display message with proper formatting but animated content - * Uses IDENTICAL layout structure as normal message for pixel-perfect alignment + * Animates only the body content; sender, metadata, gestures, and spacing remain stable. */ @Composable private fun AnimatedMessageDisplay( - message: com.bitchat.android.model.BitchatMessage, + message: BitchatMessage, currentUserNickname: String, - meshService: com.bitchat.android.mesh.MeshService, - colorScheme: androidx.compose.material3.ColorScheme, - timeFormatter: java.text.SimpleDateFormat, - modifier: Modifier = Modifier + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, ) { - // Get the animated content text - var animatedContent by remember(message.content) { mutableStateOf(message.content) } - val isAnimating = shouldAnimateMessage(message.id) + var animatedContent by remember(message.id, message.content) { + mutableStateOf(message.content) + } // Character-by-character animation state like the JavaScript version - var characterStates by remember(message.content) { + var characterStates by remember(message.id, message.content) { mutableStateOf(message.content.map { char -> if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED }) } - // Update animated content when animation state changes - LaunchedEffect(isAnimating, message.content) { - if (isAnimating && message.content.isNotEmpty()) { - val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() - - // Start character animations with staggered delays (like JS version) - message.content.forEachIndexed { index, targetChar -> - if (targetChar != ' ') { // Skip spaces - launch { - delay(index * 50L) // Stagger start like JS version - - // Animate this character indefinitely in a loop - while (true) { - // Animate with random characters - while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { - // Generate random encrypted character for this position - val newContent = animatedContent.toCharArray() - if (index < newContent.size) { - newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] - animatedContent = String(newContent) - } - - delay(100L) // Change character every 100ms like JS - - // Random chance to reveal (10% like JS version) - if (Random.nextFloat() < 0.1f) { - // Reveal the final character - val finalContent = animatedContent.toCharArray() - if (index < finalContent.size) { - finalContent[index] = targetChar - animatedContent = String(finalContent) - } - - // Mark as revealed - val finalStates = characterStates.toMutableList() - finalStates[index] = CharacterAnimationState.FINAL - characterStates = finalStates - break - } + LaunchedEffect(message.id, message.content) { + if (message.content.isEmpty()) return@LaunchedEffect + + val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() + + // Start character animations with staggered delays (like JS version). + message.content.forEachIndexed { index, targetChar -> + if (targetChar != ' ') { + launch { + delay(index * 50L) + + while (true) { + while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { + val newContent = animatedContent.toCharArray() + if (index < newContent.size) { + newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] + animatedContent = String(newContent) + } + + delay(100L) + + if (Random.nextFloat() < 0.1f) { + val finalContent = animatedContent.toCharArray() + if (index < finalContent.size) { + finalContent[index] = targetChar + animatedContent = String(finalContent) + } + + val finalStates = characterStates.toMutableList() + finalStates[index] = CharacterAnimationState.FINAL + characterStates = finalStates + break } - - // Keep revealed for 2 seconds, then fade back to encrypted (like JS) - delay(2000L) - - // Reset back to encrypted for next cycle - val resetStates = characterStates.toMutableList() - resetStates[index] = CharacterAnimationState.ENCRYPTED - characterStates = resetStates } + + delay(2000L) + + val resetStates = characterStates.toMutableList() + resetStates[index] = CharacterAnimationState.ENCRYPTED + characterStates = resetStates } } } - } else { - // Not animating, show final content - animatedContent = message.content - characterStates = message.content.map { CharacterAnimationState.FINAL } } } - - // Create a temporary message with animated content for formatting - val animatedMessage = message.copy(content = animatedContent) - - // Use formatting function without timestamp during animation - val annotatedText = if (isAnimating) { - formatMessageAsAnnotatedStringWithoutTimestamp( - message = animatedMessage, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme - ) - } else { - formatMessageAsAnnotatedString( - message = animatedMessage, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) - } - - // Use IDENTICAL Text composable structure as normal message - Text( - text = annotatedText, - modifier = modifier, - fontFamily = FontFamily.Monospace, - softWrap = true, - overflow = androidx.compose.ui.text.style.TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface - ) - ) -} - -/** - * Format message without timestamp and PoW badge for animation phase - * Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge - */ -private fun formatMessageAsAnnotatedStringWithoutTimestamp( - message: com.bitchat.android.model.BitchatMessage, - currentUserNickname: String, - meshService: com.bitchat.android.mesh.MeshService, - colorScheme: androidx.compose.material3.ColorScheme -): AnnotatedString { - // Get the full formatted text first - val timeFormatter = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) - val fullText = formatMessageAsAnnotatedString( + TextMessageLayout( message = message, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier, + bodyContent = animatedContent, ) - - // Find and remove the timestamp and PoW badge at the end - val text = fullText.text - val timestampPattern = """ \[\d{2}:\d{2}:\d{2}].*$""".toRegex() // Matches " [HH:mm:ss] 12b" or just " [HH:mm:ss]" - val match = timestampPattern.find(text) - - return if (match != null) { - // Remove timestamp and PoW portion - val endIndex = match.range.first - AnnotatedString( - text = text.substring(0, endIndex), - spanStyles = fullText.spanStyles.filter { it.end <= endIndex }, - paragraphStyles = fullText.paragraphStyles.filter { it.end <= endIndex } - ) - } else { - fullText - } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 88b24015..73fac04d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,50 +1,57 @@ package com.bitchat.android.ui + import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.ui.draw.clip +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState - - -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import android.content.Intent -import android.net.Uri -import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.DeliveryStatus -import com.bitchat.android.mesh.MeshService -import java.text.SimpleDateFormat -import java.util.* -import com.bitchat.android.ui.media.VoiceNotePlayer -import androidx.compose.material3.Icon -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.shape.CircleShape -import com.bitchat.android.ui.media.FileMessageItem -import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.R -import androidx.compose.ui.res.stringResource +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.ui.media.FileMessageItem +import java.text.SimpleDateFormat +import java.util.Locale // VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer @@ -266,23 +273,21 @@ fun MessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, color = colorScheme.onSurface, - modifier = Modifier.pointerInput(message.id) { - detectTapGestures(onTap = { pos -> - val layout = headerLayout ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(pos) - val ann = headerText.getStringAnnotations("nickname_click", offset, offset) - if (ann.isNotEmpty() && onNicknameClick != null) { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick.invoke(ann.first().item) - } - }, onLongPress = { onMessageLongPress?.invoke(message) }) - }, - onTextLayout = { headerLayout = it } ) // Try to load the file packet from the path @@ -354,18 +359,16 @@ fun MessageItem( // Display message with matrix animation for content MessageWithMatrixAnimation( message = message, - messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, - onImageClick = onImageClick, modifier = modifier ) - } else { - // Normal message display + } else if (message.sender == "system") { + // Keep system messages on the compact legacy line. val annotatedText = formatMessageAsAnnotatedString( message = message, currentUserNickname = currentUserNickname, @@ -373,80 +376,12 @@ fun MessageItem( colorScheme = colorScheme, timeFormatter = timeFormatter ) - - // Check if this message was sent by self to avoid click interactions on own nickname - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") - + val haptic = LocalHapticFeedback.current - val context = LocalContext.current - var textLayoutResult by remember { mutableStateOf(null) } Text( text = annotatedText, modifier = modifier.pointerInput(message) { detectTapGestures( - onTap = { position -> - val layout = textLayoutResult ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(position) - // Nickname click only when not self - if (!isSelf && onNicknameClick != null) { - val nicknameAnnotations = annotatedText.getStringAnnotations( - tag = "nickname_click", - start = offset, - end = offset - ) - if (nicknameAnnotations.isNotEmpty()) { - val nickname = nicknameAnnotations.first().item - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick.invoke(nickname) - return@detectTapGestures - } - } - // Geohash teleport (all messages) - val geohashAnnotations = annotatedText.getStringAnnotations( - tag = "geohash_click", - start = offset, - end = offset - ) - if (geohashAnnotations.isNotEmpty()) { - val geohash = geohashAnnotations.first().item - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( - context - ) - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) - locationManager.setTeleported(true) - locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) - } catch (_: Exception) { } - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - return@detectTapGestures - } - // URL open (all messages) - val urlAnnotations = annotatedText.getStringAnnotations( - tag = "url_click", - start = offset, - end = offset - ) - if (urlAnnotations.isNotEmpty()) { - val raw = urlAnnotations.first().item - val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw" - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved)) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } catch (_: Exception) { } - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - return@detectTapGestures - } - }, onLongPress = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onMessageLongPress?.invoke(message) @@ -458,8 +393,129 @@ fun MessageItem( overflow = TextOverflow.Visible, style = androidx.compose.ui.text.TextStyle( color = colorScheme.onSurface - ), - onTextLayout = { result -> textLayoutResult = result } + ) + ) + } else { + TextMessageLayout( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier, + ) + } +} + +@Composable +internal fun TextMessageLayout( + message: BitchatMessage, + currentUserNickname: String, + meshService: MeshService, + colorScheme: ColorScheme, + timeFormatter: SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier, + bodyContent: String = message.content, +) { + val myPeerId = meshService.myPeerID + val displayMessage = remember(message, bodyContent) { + if (bodyContent == message.content) message else message.copy(content = bodyContent) + } + val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) { + formatTextMessageSender( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + ) + } + val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) { + formatTextMessageMetadata( + message = message, + timeFormatter = timeFormatter, + ) + } + val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) { + formatTextMessageBody( + message = displayMessage, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + ) + } + val isSelf = message.isFromSelf(currentUserNickname, myPeerId) + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val handleLongPress: () -> Unit = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + AnnotatedClickableText( + text = senderText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && !isSelf && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = handleLongPress, + modifier = Modifier.weight(1f), + fontFamily = FontFamily.Monospace, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + AnnotatedClickableText( + text = metadataText, + annotationTags = emptyList(), + onAnnotationClick = { _, _ -> false }, + onLongPress = handleLongPress, + fontFamily = FontFamily.Monospace, + softWrap = false, + ) + } + + AnnotatedClickableText( + text = bodyText, + annotationTags = listOf("geohash_click", "url_click"), + onAnnotationClick = { tag, item -> + when (tag) { + "geohash_click" -> { + navigateToGeohash(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + "url_click" -> { + openMessageUrl(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } + + else -> false + } + }, + onLongPress = handleLongPress, + fontFamily = FontFamily.Monospace, + softWrap = true, + overflow = TextOverflow.Visible, + style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt new file mode 100644 index 00000000..275dbaf8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.ui + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.model.BitchatMessage + +internal fun BitchatMessage.isFromSelf( + currentUserNickname: String, + myPeerId: String, +): Boolean = + senderPeerID == myPeerId || + sender == currentUserNickname || + sender.startsWith("$currentUserNickname#") + +internal fun normalizeMessageUrl(rawUrl: String): String = + if ( + rawUrl.startsWith("http://", ignoreCase = true) || + rawUrl.startsWith("https://", ignoreCase = true) + ) { + rawUrl + } else { + "https://$rawUrl" + } + +internal fun openMessageUrl(context: Context, rawUrl: String): Boolean = + runCatching { + val intent = Intent(Intent.ACTION_VIEW, normalizeMessageUrl(rawUrl).toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.isSuccess + +internal fun channelForGeohash(geohash: String): GeohashChannel { + val level = when (geohash.length) { + in 0..2 -> GeohashChannelLevel.REGION + in 3..4 -> GeohashChannelLevel.PROVINCE + 5 -> GeohashChannelLevel.CITY + 6 -> GeohashChannelLevel.NEIGHBORHOOD + else -> GeohashChannelLevel.BLOCK + } + return GeohashChannel(level, geohash.lowercase()) +} + +internal fun navigateToGeohash(context: Context, geohash: String): Boolean = + runCatching { + val locationManager = LocationChannelManager.getInstance(context) + locationManager.setTeleported(true) + locationManager.select(ChannelID.Location(channelForGeohash(geohash))) + }.isSuccess diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 6af2cc51..7c0a1ac6 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -2,25 +2,22 @@ package com.bitchat.android.ui.media import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import androidx.compose.material3.ColorScheme @@ -58,23 +55,21 @@ fun AudioMessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, color = colorScheme.onSurface, - modifier = Modifier.pointerInput(message.id) { - detectTapGestures(onTap = { pos -> - val layout = headerLayout ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(pos) - val ann = headerText.getStringAnnotations("nickname_click", offset, offset) - if (ann.isNotEmpty() && onNicknameClick != null) { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick.invoke(ann.first().item) - } - }, onLongPress = { onMessageLongPress?.invoke(message) }) - }, - onTextLayout = { headerLayout = it } ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 43ae84f9..35b0bbea 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -3,13 +3,11 @@ package com.bitchat.android.ui.media import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -18,21 +16,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.text.font.FontFamily import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import androidx.compose.material3.ColorScheme +import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import java.text.SimpleDateFormat -import java.util.* @Composable fun ImageMessageItem( @@ -58,23 +53,21 @@ fun ImageMessageItem( timeFormatter = timeFormatter ) val haptic = LocalHapticFeedback.current - var headerLayout by remember { mutableStateOf(null) } - Text( + AnnotatedClickableText( text = headerText, + annotationTags = listOf("nickname_click"), + onAnnotationClick = { tag, item -> + if (tag == "nickname_click" && onNicknameClick != null) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onNicknameClick.invoke(item) + true + } else { + false + } + }, + onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = FontFamily.Monospace, color = colorScheme.onSurface, - modifier = Modifier.pointerInput(message.id) { - detectTapGestures(onTap = { pos -> - val layout = headerLayout ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(pos) - val ann = headerText.getStringAnnotations("nickname_click", offset, offset) - if (ann.isNotEmpty() && onNicknameClick != null) { - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick.invoke(ann.first().item) - } - }, onLongPress = { onMessageLongPress?.invoke(message) }) - }, - onTextLayout = { headerLayout = it } ) val context = LocalContext.current diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3d72337a..4d2ffec0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + فتح قسم حول diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 3cda37a6..c5902ee2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + পরিচিতি খুলুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c6cb4aac..9002f9bd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -401,4 +401,5 @@ Verifiziert Du hast %1$s verifiziert verifiziert %1$s + Info öffnen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b8fed3aa..cf6b336e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -400,4 +400,5 @@ Verificado Verificaste a %1$s verificado %1$s + Abrir Acerca de diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8ad97fe9..84273529 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + باز کردن درباره diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 34e1d5f5..65e4cbd2 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -280,7 +280,6 @@ Payagan HINDI sinusubaybayan ng bitchat ang lokasyon mo @ - bitchat/ · ⧉ TAO walang tao sa paligid… @@ -400,4 +399,5 @@ Verified You verified %1$s verified %1$s + Buksan ang Tungkol diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9828a76b..aee12bf1 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -281,7 +281,6 @@ Accorder les autorisations bitchat ne suit PAS ta position @ - bitchat/ · ⧉ PERSONNES personne aux alentours… @@ -414,4 +413,5 @@ Vérifié Vous avez vérifié %1$s vérifié %1$s + Ouvrir À propos diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index e18b57b8..a78d83b6 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -53,5 +53,5 @@ Verified You verified %1$s verified %1$s + פתיחת אודות - diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 02d64711..926cc3a4 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + परिचय खोलें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index fb9830be..bb15e3bb 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + Buka Tentang diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3db4e85e..c10a8575 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -340,7 +340,6 @@ @ - bitchat/ · ⧉ @%1$s %1$d / %2$d @@ -434,4 +433,5 @@ Verificato Hai verificato %1$s verificato %1$s + Apri Informazioni diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index df489157..eb9d6ba1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -400,4 +400,5 @@ 検証済み %1$s を検証しました %1$s を検証しました + このアプリについてを開く diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 2d1c6693..1e3187d0 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + აპის შესახებ გახსნა diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 68d46136..829cd1f5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + 정보 열기 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 57676dec..a7edd747 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -289,7 +289,6 @@ Omeo Alalana Ny bitchat dia TSY manaraka ny toerananao @ - bitchat/ · ⧉ OLONA tsy misy olona manodidina... @@ -414,4 +413,5 @@ Verified You verified %1$s verified %1$s + Sokafy ny momba diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index b4b23655..9b6f2bb8 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + Buka Perihal - diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 252f81c4..f1dbb3c6 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -280,7 +280,6 @@ अनुमति दिनुहोस् bitchat ले तपाईंको स्थान पछ्याउँदैन @ - bitchat/ · ⧉ मानिसहरू वरिपरि कोही छैन… @@ -400,4 +399,5 @@ Verified You verified %1$s verified %1$s + परिचय खोल्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 59705572..6a014f87 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -340,7 +340,6 @@ @ - bitchat/ · ⧉ @%1$s %1$d / %2$d @@ -432,4 +431,5 @@ Verified You verified %1$s verified %1$s + Info openen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 58135f7c..3b94bd8c 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + ایپ بارے کھولو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cdbffdd0..1579f9b3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -53,5 +53,5 @@ Verified You verified %1$s verified %1$s + Otwórz informacje - diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 147b5f37..274bbaae 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -400,4 +400,5 @@ Verificado Você verificou %1$s verificou %1$s + Abrir Sobre diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dade7d1d..d1d27acd 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -264,7 +264,6 @@ Выдать разрешения bitchat НЕ отслеживает вашу геопозицию @ - bitchat/ · ⧉ ЛЮДИ никого рядом… @@ -390,4 +389,5 @@ Проверено Вы проверили %1$s проверен %1$s + Открыть раздел «О приложении» diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 9ac7df67..aa3194ed 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -264,7 +264,6 @@ Ge behörigheter bitchat spårar INTE din plats @ - bitchat/ · ⧉ PERSONER ingen i närheten… @@ -388,4 +387,5 @@ Verified You verified %1$s verified %1$s + Öppna Om diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 1e487548..a47d4dbd 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + அறிமுகத்தைத் திற - diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 4a4c5021..5b2ba927 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + เปิดเกี่ยวกับ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a2df9dfe..d27d1b59 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -264,7 +264,6 @@ İzin ver bitchat konumunu takip etmez @ - bitchat/ · ⧉ KİŞİLER yakında kimse yok… @@ -388,4 +387,5 @@ Verified You verified %1$s verified %1$s + Hakkında’yı aç diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2c631d04..9ce840ac 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -40,5 +40,5 @@ Verified You verified %1$s verified %1$s + Відкрити розділ «Про застосунок» - diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 291c3e9a..1d8399de 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -400,4 +400,5 @@ Verified You verified %1$s verified %1$s + تعارف کھولیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a1315617..6ab72813 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -387,4 +387,5 @@ Verified You verified %1$s verified %1$s + Mở phần Giới thiệu diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 46caf1af..8a28e5f7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -280,7 +280,6 @@ 授予权限 bitchat 不会跟踪你的位置 @ - bitchat/ · ⧉ 成员 附近无人… @@ -413,4 +412,5 @@ 已验证 你已验证 %1$s 已验证 %1$s + 打开“关于” diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ece95a7..e674112c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,7 +371,7 @@ Grant Permissions bitchat does NOT track your location @ - bitchat/ + Open About · ⧉ PEOPLE nobody around... diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt new file mode 100644 index 00000000..304166c0 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -0,0 +1,44 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatUIUtilsTest { + private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + + @Test + fun `text message metadata separates PoW badge with one space`() { + val message = BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + powDifficulty = 12, + ) + + assertEquals( + "00:00:00 ⛨12b", + formatTextMessageMetadata(message, timeFormatter).text, + ) + } + + @Test + fun `text message metadata omits non-positive PoW difficulty`() { + val message = BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + powDifficulty = 0, + ) + + assertEquals( + "00:00:00", + formatTextMessageMetadata(message, timeFormatter).text, + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt new file mode 100644 index 00000000..90e279e1 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageInteractionUtilsTest.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageInteractionUtilsTest { + @Test + fun `self detection accepts peer id nickname and nickname suffix`() { + assertTrue(message(sender = "alice", senderPeerId = "peer-a").isFromSelf("me", "peer-a")) + assertTrue(message(sender = "me").isFromSelf("me", "peer-a")) + assertTrue(message(sender = "me#1a2b").isFromSelf("me", "peer-a")) + } + + @Test + fun `self detection rejects unrelated sender`() { + assertFalse(message(sender = "alice", senderPeerId = "peer-b").isFromSelf("me", "peer-a")) + } + + @Test + fun `URL normalization preserves explicit HTTP schemes`() { + assertEquals("http://example.com", normalizeMessageUrl("http://example.com")) + assertEquals("HTTPS://example.com", normalizeMessageUrl("HTTPS://example.com")) + } + + @Test + fun `URL normalization defaults bare URLs to HTTPS`() { + assertEquals("https://example.com", normalizeMessageUrl("example.com")) + } + + @Test + fun `geohash channel precision matches navigation levels`() { + val expectedLevels = mapOf( + "9q" to GeohashChannelLevel.REGION, + "9q8" to GeohashChannelLevel.PROVINCE, + "9q8y" to GeohashChannelLevel.PROVINCE, + "9q8yy" to GeohashChannelLevel.CITY, + "9q8yyk" to GeohashChannelLevel.NEIGHBORHOOD, + "9q8yyk8" to GeohashChannelLevel.BLOCK, + ) + + expectedLevels.forEach { (geohash, level) -> + assertEquals(level, channelForGeohash(geohash).level) + } + } + + @Test + fun `geohash channel normalizes casing`() { + assertEquals("9q8yy", channelForGeohash("9Q8YY").geohash) + } + + private fun message(sender: String, senderPeerId: String? = null): BitchatMessage = + BitchatMessage( + sender = sender, + content = "hello", + timestamp = Date(0), + senderPeerID = senderPeerId, + ) +} From d615fc9cc07faa8ba243c25bb769fb617ecc38ea Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:17:44 +0200 Subject: [PATCH 31/32] Gate nearby notes behind tap-to-reveal consent (#771) * Add nearby notes tap-to-reveal consent * Stop nearby notes while app is backgrounded --- .../android/nostr/LocationNotesManager.kt | 39 ++- .../android/nostr/NearbyNotesController.kt | 130 ++++++++ .../java/com/bitchat/android/ui/ChatScreen.kt | 277 ++++++++++++++---- .../android/ui/LocationChannelsSheet.kt | 11 +- .../bitchat/android/ui/LocationNotesSheet.kt | 29 +- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-bn/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fa/strings.xml | 3 + app/src/main/res/values-fil/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-he/strings.xml | 3 + app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-id/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ka/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-mg/strings.xml | 3 + app/src/main/res/values-ms/strings.xml | 3 + app/src/main/res/values-ne/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-pa-rPK/strings.xml | 3 + app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-sv/strings.xml | 3 + app/src/main/res/values-ta/strings.xml | 3 + app/src/main/res/values-th/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-ur/strings.xml | 3 + app/src/main/res/values-vi/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 4 + .../nostr/NearbyNotesControllerTest.kt | 159 ++++++++++ 40 files changed, 678 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index dc1a8e85..8873c271 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow */ @MainThread class LocationNotesManager private constructor() { - + companion object { private const val TAG = "LocationNotesManager" private const val MAX_NOTES_IN_MEMORY = 500 @@ -27,7 +27,7 @@ class LocationNotesManager private constructor() { } } } - + /** * Note data class matching iOS implementation */ @@ -94,6 +94,8 @@ class LocationNotesManager private constructor() { // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var subscribeRetryJob: Job? = null + private var initialLoadJob: Job? = null /** * Initialize dependencies @@ -289,6 +291,11 @@ class LocationNotesManager private constructor() { * Subscribe to location notes for current geohash */ private fun subscribeAll() { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + val currentGeohash = _geohash.value if (currentGeohash == null) { Log.w(TAG, "Cannot subscribe - no geohash set") @@ -301,7 +308,7 @@ class LocationNotesManager private constructor() { Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly") _state.value = State.LOADING // Retry a few times in case initialization is racing the sheet open - scope.launch { + subscribeRetryJob = scope.launch { var attempts = 0 while (attempts < 10 && subscribeFunc == null) { delay(300) @@ -342,9 +349,9 @@ class LocationNotesManager private constructor() { } // Mark initial load complete after brief delay to allow relay responses - scope.launch { + initialLoadJob = scope.launch { delay(2000) // Wait 2 seconds for initial batch - if (!_initialLoadComplete.value!!) { + if (_geohash.value == currentGeohash && !_initialLoadComplete.value) { _initialLoadComplete.value = true _state.value = State.READY Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)") @@ -441,6 +448,11 @@ class LocationNotesManager private constructor() { * Cancel subscription and clear state */ fun cancel() { + subscribeRetryJob?.cancel() + subscribeRetryJob = null + initialLoadJob?.cancel() + initialLoadJob = null + if (subscriptionIDs.isNotEmpty()) { subscriptionIDs.values.forEach { subId -> try { @@ -453,17 +465,26 @@ class LocationNotesManager private constructor() { subscribedGeohashes = emptySet() _state.value = State.IDLE } - + /** - * Cleanup resources + * End the nearby-notes session and discard location-correlated UI state. + * Unlike [cancel], this also clears the target so a later activation can + * safely subscribe to the same building geohash again. */ - fun cleanup() { + fun stop() { cancel() - scope.cancel() _notes.value = emptyList() noteIDs.clear() _geohash.value = null _initialLoadComplete.value = false _errorMessage.value = null } + + /** + * Cleanup resources + */ + fun cleanup() { + stop() + scope.cancel() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt new file mode 100644 index 00000000..a5f1a8ec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt @@ -0,0 +1,130 @@ +package com.bitchat.android.nostr + +import androidx.annotation.MainThread +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Session-scoped consent gate for nearby location notes. + * + * Merely rendering the mesh timeline must not open a building-precision Nostr + * subscription. A subscription is eligible only after an explicit reveal and + * while the app is foregrounded and at least one nearby-notes surface is active. + */ +@MainThread +class NearbyNotesController internal constructor( + private val subscribe: (String) -> Unit, + private val unsubscribe: () -> Unit, +) { + private val _revealed = MutableStateFlow(false) + val revealed: StateFlow = _revealed.asStateFlow() + + private var activeHolders = 0 + private var locationEnabled = false + private var locationAuthorized = false + private var appForeground = false + private var buildingGeohash: String? = null + private var subscribedGeohash: String? = null + + /** + * Unlocks nearby notes for this process session. Deactivation deliberately + * does not reset consent, matching the iOS privacy model. + */ + fun reveal() { + if (_revealed.value) return + _revealed.value = true + reconcileSubscription() + } + + /** Holds the subscription while a nearby-notes surface is visible. */ + fun activate() { + activeHolders += 1 + reconcileSubscription() + } + + /** Releases a matching [activate] hold and unsubscribes after the last one. */ + fun deactivate() { + activeHolders = (activeHolders - 1).coerceAtLeast(0) + reconcileSubscription() + } + + /** Closes the live subscription whenever the process leaves the foreground. */ + fun updateAppForeground(isForeground: Boolean) { + appForeground = isForeground + reconcileSubscription() + } + + /** + * Updates the privacy-sensitive inputs independently of view activation. + * Permission revocation, location disable, or loss of the building cell + * immediately closes any live subscription. + */ + fun updateAvailability( + locationEnabled: Boolean, + locationAuthorized: Boolean, + buildingGeohash: String?, + ) { + this.locationEnabled = locationEnabled + this.locationAuthorized = locationAuthorized + this.buildingGeohash = buildingGeohash + ?.trim() + ?.lowercase() + ?.takeIf { it.isNotEmpty() } + reconcileSubscription() + } + + fun offersRevealHint(): Boolean = + !_revealed.value && + locationEnabled && + locationAuthorized && + buildingGeohash != null + + private fun reconcileSubscription() { + val target = buildingGeohash.takeIf { + activeHolders > 0 && + appForeground && + _revealed.value && + locationEnabled && + locationAuthorized + } + + if (subscribedGeohash != null && subscribedGeohash != target) { + unsubscribe() + subscribedGeohash = null + } + + if (target != null && subscribedGeohash == null) { + subscribe(target) + subscribedGeohash = target + } + } + + companion object { + val shared: NearbyNotesController by lazy { + val manager = LocationNotesManager.getInstance() + NearbyNotesController( + subscribe = manager::setGeohash, + unsubscribe = manager::stop, + ) + } + } +} + +/** + * Building precision is location-notes precision and remains private before a + * reveal. Explicit bookmarks remain eligible because saving one is itself an + * intentional location act. + */ +internal fun geohashesForSampling( + availableChannels: List, + bookmarks: Collection, + notesRevealed: Boolean, +): List = buildSet { + availableChannels + .filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING } + .mapTo(this) { it.geohash } + addAll(bookmarks) +}.toList() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 78d5b571..0514217d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -12,19 +12,35 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.zIndex +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.ui.media.FullScreenImageViewer /** @@ -86,6 +102,67 @@ fun ChatScreen(viewModel: ChatViewModel) { // Get location channel info for timeline switching val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val context = LocalContext.current + val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } + val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle() + val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) + val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val nearbyNotes by remember { LocationNotesManager.getInstance() } + .notes + .collectAsStateWithLifecycle() + val buildingGeohash = availableLocationChannels + .firstOrNull { it.level == GeohashChannelLevel.BUILDING } + ?.geohash + val isMeshTimeline = + currentChannel == null && + selectedLocationChannel is ChannelID.Mesh && + selectedPrivatePeer == null && + privateChatSheetPeer == null + + val processLifecycleOwner = remember { ProcessLifecycleOwner.get() } + DisposableEffect(processLifecycleOwner, nearbyNotesController) { + val lifecycle = processLifecycleOwner.lifecycle + val observer = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(true) + } + + override fun onStop(owner: LifecycleOwner) { + nearbyNotesController.updateAppForeground(false) + } + } + + lifecycle.addObserver(observer) + nearbyNotesController.updateAppForeground( + lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED), + ) + + onDispose { + lifecycle.removeObserver(observer) + nearbyNotesController.updateAppForeground(false) + } + } + + DisposableEffect( + isMeshTimeline, + locationEnabled, + locationPermissionState, + buildingGeohash, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = buildingGeohash, + ) + if (isMeshTimeline) nearbyNotesController.activate() + onDispose { + if (isMeshTimeline) nearbyNotesController.deactivate() + } + } // Determine what messages to show based on current context (unified timelines) // Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet @@ -131,58 +208,91 @@ fun ChatScreen(viewModel: ChatViewModel) { ) // Messages area - takes up available space, will compress when keyboard appears - MessagesList( - messages = displayMessages, - currentUserNickname = nickname, - meshService = viewModel.meshServiceFacade, - modifier = Modifier.weight(1f), - forceScrollToBottom = forceScrollToBottom, - onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, - onNicknameClick = { fullSenderName -> - // Single click - mention user in text input - val currentText = messageText.text - - // Extract base nickname and hash suffix from full sender name - val (baseName, hashSuffix) = splitSuffix(fullSenderName) - - // Check if we're in a geohash channel to include hash suffix - val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - - val newText = when { - currentText.isEmpty() -> "$mentionText " - currentText.endsWith(" ") -> "$currentText$mentionText " - else -> "$currentText $mentionText " - } - - messageText = TextFieldValue( - text = newText, - selection = TextRange(newText.length) + Column(modifier = Modifier.weight(1f)) { + if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, ) - }, - onMessageLongPress = { message -> - // Message long press - open user action sheet with message context - // Extract base nickname from message sender (contains all necessary info) - val (baseName, _) = splitSuffix(message.sender) - selectedUserForSheet = baseName - selectedMessageForSheet = message - showUserSheet = true - }, - onCancelTransfer = { msg -> - viewModel.cancelMediaSend(msg.id) - }, - onImageClick = { currentPath, allImagePaths, initialIndex -> - viewerImagePaths = allImagePaths - initialViewerIndex = initialIndex - showFullScreenImageViewer = true } - ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + MessagesList( + messages = displayMessages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + modifier = Modifier.fillMaxSize(), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { fullSenderName -> + // Single click - mention user in text input + val currentText = messageText.text + + // Extract base nickname and hash suffix from full sender name + val (baseName, hashSuffix) = splitSuffix(fullSenderName) + + // Check if we're in a geohash channel to include hash suffix + val selectedLocationChannel = viewModel.selectedLocationChannel.value + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + + val newText = when { + currentText.isEmpty() -> "$mentionText " + currentText.endsWith(" ") -> "$currentText$mentionText " + else -> "$currentText $mentionText " + } + + messageText = TextFieldValue( + text = newText, + selection = TextRange(newText.length), + ) + }, + onMessageLongPress = { message -> + // Message long press - open user action sheet with message context + // Extract base nickname from message sender (contains all necessary info) + val (baseName, _) = splitSuffix(message.sender) + selectedUserForSheet = baseName + selectedMessageForSheet = message + showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true + }, + ) + + if ( + displayMessages.isEmpty() && + isMeshTimeline && + !nearbyNotesRevealed && + locationEnabled && + locationPermissionState == + LocationChannelManager.PermissionState.AUTHORIZED && + buildingGeohash != null + ) { + NearbyNotesRevealHint( + onClick = nearbyNotesController::reveal, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } // Input area - stays at bottom // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { @@ -253,7 +363,10 @@ fun ChatScreen(viewModel: ChatViewModel) { onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, - onLocationNotesClick = { showLocationNotesSheet = true } + onLocationNotesClick = { + nearbyNotesController.reveal() + showLocationNotesSheet = true + } ) // Divider under header - positioned after status bar + header height @@ -374,6 +487,68 @@ fun ChatScreen(viewModel: ChatViewModel) { } } +@Composable +private fun NearbyNotesRevealHint( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val actionLabel = stringResource(R.string.nearby_notes_reveal) + TextButton( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 24.dp) + .semantics { contentDescription = actionLabel }, + ) { + Text( + text = "📍 $actionLabel", + modifier = Modifier.clearAndSetSemantics { }, + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + } +} + +@Composable +private fun NearbyNotesStrip( + noteCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "📍 " + if (noteCount == 1) { + stringResource(R.string.nearby_notes_one) + } else { + stringResource(R.string.nearby_notes_many, noteCount) + }, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.primary, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + Text( + text = "›", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 18.sp, + ) + } + } +} + @Composable fun ChatInputSection( messageText: TextFieldValue, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..0732d912 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -34,6 +34,8 @@ import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -61,6 +63,7 @@ fun LocationChannelsSheet( // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() @@ -534,9 +537,13 @@ fun LocationChannelsSheet( } // Sampling management: update sampling when channels/bookmarks change - LaunchedEffect(isPresented, availableChannels, bookmarks) { + LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) { if (isPresented) { - val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() + val geohashes = geohashesForSampling( + availableChannels = availableChannels, + bookmarks = bookmarks, + notesRevealed = notesRevealed, + ) viewModel.beginGeohashSampling(geohashes) } else { viewModel.endGeohashSampling() diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 29ead6a6..6a224cf2 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -32,6 +32,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NearbyNotesController import java.text.SimpleDateFormat import java.util.* import java.util.Calendar @@ -58,12 +59,15 @@ fun LocationNotesSheet( // Managers val notesManager = remember { LocationNotesManager.getInstance() } val locationManager = remember { LocationChannelManager.getInstance(context) } + val nearbyNotesController = remember { NearbyNotesController.shared } // State val notes by notesManager.notes.collectAsStateWithLifecycle() val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle() val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size @@ -94,15 +98,24 @@ fun LocationNotesSheet( locationManager.refreshChannels() } - // Effect to set geohash when sheet opens - LaunchedEffect(geohash) { - notesManager.setGeohash(geohash) - } - - // Cleanup when sheet closes - DisposableEffect(Unit) { + // Opening the notes sheet is an explicit reveal. The balanced hold lets + // the mesh timeline keep the shared subscription alive after dismissal. + DisposableEffect( + geohash, + locationEnabled, + permissionState, + nearbyNotesController, + ) { + nearbyNotesController.updateAvailability( + locationEnabled = locationEnabled, + locationAuthorized = + permissionState == LocationChannelManager.PermissionState.AUTHORIZED, + buildingGeohash = geohash, + ) + nearbyNotesController.activate() + nearbyNotesController.reveal() onDispose { - notesManager.cancel() + nearbyNotesController.deactivate() } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..c81bdb26 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s فتح قسم حول + تحقّق من الملاحظات المتروكة هنا + تُركت ملاحظة واحدة هنا — انقر للقراءة + تُركت %d ملاحظات هنا — انقر للقراءة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..95b3662a 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s পরিচিতি খুলুন + এখানে রাখা নোট আছে কি না দেখুন + এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন + এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..a80dc4c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -402,4 +402,7 @@ Du hast %1$s verifiziert verifiziert %1$s Info öffnen + nachsehen, ob hier notizen hinterlassen wurden + 1 notiz hier hinterlassen — tippen zum lesen + %d notizen hier hinterlassen — tippen zum lesen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..157ea1b4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,4 +401,7 @@ Verificaste a %1$s verificado %1$s Abrir Acerca de + buscar notas dejadas aquí + 1 nota dejada aquí — toca para leer + %d notas dejadas aquí — toca para leer diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..8d7d6ae9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s باز کردن درباره + یادداشت‌های باقی‌مانده در اینجا را بررسی کنید + ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..829079a6 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s Buksan ang Tungkol + tingnan kung may mga note na naiwan dito + 1 note ang naiwan dito — i-tap para basahin + %d note ang naiwan dito — i-tap para basahin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..46e9c023 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -414,4 +414,7 @@ Vous avez vérifié %1$s vérifié %1$s Ouvrir À propos + vérifier s\'il y a des notes laissées ici + 1 note laissée ici — appuyez pour lire + %d notes laissées ici — appuyez pour lire diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a78d83b6..3793942b 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s פתיחת אודות + בדיקה אם הושארו כאן פתקים + פתק אחד הושאר כאן — הקש לקריאה + %d פתקים הושארו כאן — הקש לקריאה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..ffb4dac0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s परिचय खोलें + देखें कि यहाँ नोट छोड़े गए हैं या नहीं + यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें + यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..e67caded 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s Buka Tentang + periksa catatan yang ditinggalkan di sini + 1 catatan ditinggalkan di sini — ketuk untuk membaca + %d catatan ditinggalkan di sini — ketuk untuk membaca diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..92bf4ad0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -434,4 +434,7 @@ Hai verificato %1$s verificato %1$s Apri Informazioni + controlla se ci sono note lasciate qui + 1 nota lasciata qui — tocca per leggere + %d note lasciate qui — tocca per leggere diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1b25aa95 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -401,4 +401,7 @@ %1$s を検証しました %1$s を検証しました このアプリについてを開く + ここに残されたメモを確認 + ここに1件のメモがあります — タップして読む + ここに%d件のメモがあります — タップして読む diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..c7620e19 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s აპის შესახებ გახსნა + აქ დატოვებული ჩანაწერების შემოწმება + აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..8c0cc624 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s 정보 열기 + 여기 남겨진 쪽지 확인 + 여기 남겨진 쪽지 1개 — 탭하여 읽기 + 여기 남겨진 쪽지 %d개 — 탭하여 읽기 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..ddff38ad 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -414,4 +414,7 @@ You verified %1$s verified %1$s Sokafy ny momba + hizaha raha misy naoty navela teto + naoty 1 no navela teto — tsindrio raha hamaky + naoty %d no navela teto — tsindrio raha hamaky diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 9b6f2bb8..add7b467 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Buka Perihal + semak nota yang ditinggalkan di sini + 1 nota ditinggalkan di sini — ketik untuk baca + %d nota ditinggalkan di sini — ketik untuk baca diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..3e50146a 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s परिचय खोल्नुहोस् + यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् + यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् + यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..38385885 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -432,4 +432,7 @@ You verified %1$s verified %1$s Info openen + kijk of hier notities zijn achtergelaten + 1 notitie hier achtergelaten — tik om te lezen + %d notities hier achtergelaten — tik om te lezen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..e92d2d51 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s ایپ بارے کھولو + ایتھے چھڈے نوٹس ویکھو + ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو + ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1579f9b3..f1a67349 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s Otwórz informacje + sprawdź, czy zostawiono tutaj notatki + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + %d notatek zostawionych tutaj — stuknij, aby przeczytać diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..67503cc6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -400,4 +400,7 @@ Verificado Você verificou %1$s verificou %1$s + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..3bcad4ab 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -401,4 +401,7 @@ Você verificou %1$s verificou %1$s Abrir Sobre + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..9ce4a560 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -390,4 +390,7 @@ Вы проверили %1$s проверен %1$s Открыть раздел «О приложении» + проверить, есть ли здесь заметки + здесь оставлена 1 заметка — нажмите, чтобы прочитать + здесь оставлено заметок: %d — нажмите, чтобы прочитать diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..566f940e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Öppna Om + kolla om anteckningar lämnats här + 1 anteckning lämnad här — tryck för att läsa + %d anteckningar lämnade här — tryck för att läsa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index a47d4dbd..05bd4750 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s அறிமுகத்தைத் திற + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..817cd335 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s เปิดเกี่ยวกับ + ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ + มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..93888483 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Hakkında’yı aç + buraya bırakılan notlara bak + buraya 1 not bırakıldı — okumak için dokun + buraya %d not bırakıldı — okumak için dokun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9ce840ac..9ca2d2d0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Відкрити розділ «Про застосунок» + перевірити, чи залишено тут нотатки + тут залишено 1 нотатку — торкніться, щоб прочитати + тут залишено %d нотаток — торкніться, щоб прочитати diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..3f1c735a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s تعارف کھولیں + دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں + یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں + یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..4dd4ad37 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Mở phần Giới thiệu + kiểm tra ghi chú để lại ở đây + có 1 ghi chú để lại ở đây — chạm để đọc + có %d ghi chú để lại ở đây — chạm để đọc diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 69bff70c..438131b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 - diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1dcf6b27..328696a0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看這裡留下的留言 + 這裡留有 1 則留言 — 點按閱讀 + 這裡留有 %d 則留言 — 點按閱讀 - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..f8879cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -413,4 +413,7 @@ 你已验证 %1$s 已验证 %1$s 打开“关于” + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..b8086f0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,10 @@ region + check for notes left here + 1 note left here — tap to read + + %d notes left here — tap to read #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt new file mode 100644 index 00000000..6bd86042 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt @@ -0,0 +1,159 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NearbyNotesControllerTest { + private val subscriptions = mutableListOf() + private var unsubscribeCount = 0 + + private fun controller() = NearbyNotesController( + subscribe = subscriptions::add, + unsubscribe = { unsubscribeCount += 1 }, + ) + + private fun foregroundController() = controller().also { + it.updateAppForeground(true) + } + + @Test + fun `active mesh timeline does not subscribe before explicit reveal`() { + val controller = foregroundController() + + controller.updateAvailability( + locationEnabled = true, + locationAuthorized = true, + buildingGeohash = "u4pruydq", + ) + controller.activate() + + assertTrue(controller.offersRevealHint()) + assertTrue(subscriptions.isEmpty()) + + controller.reveal() + + assertFalse(controller.offersRevealHint()) + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `reveal remains dormant until a nearby notes surface is active`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + + controller.reveal() + + assertTrue(subscriptions.isEmpty()) + + controller.activate() + + assertEquals(listOf("u4pruydq"), subscriptions) + } + + @Test + fun `last deactivate unsubscribes exactly once`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.reveal() + controller.activate() + controller.activate() + + controller.deactivate() + assertEquals(0, unsubscribeCount) + + controller.deactivate() + controller.deactivate() + + assertEquals(1, unsubscribeCount) + } + + @Test + fun `backgrounding closes the subscription and foregrounding restores it`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAppForeground(false) + + assertEquals(1, unsubscribeCount) + assertTrue(controller.revealed.value) + + controller.updateAppForeground(false) + assertEquals(1, unsubscribeCount) + + controller.updateAppForeground(true) + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + } + + @Test + fun `disable and permission revocation close the live subscription`() { + val controller = foregroundController() + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(false, true, "u4pruydq") + assertEquals(1, unsubscribeCount) + + controller.updateAvailability(true, true, "u4pruydq") + assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions) + + controller.updateAvailability(true, false, "u4pruydq") + assertEquals(2, unsubscribeCount) + } + + @Test + fun `moving building cells releases old subscription before retargeting`() { + val events = mutableListOf() + val controller = NearbyNotesController( + subscribe = { events += "subscribe:$it" }, + unsubscribe = { events += "unsubscribe" }, + ) + controller.updateAppForeground(true) + controller.updateAvailability(true, true, "u4pruydq") + controller.activate() + controller.reveal() + + controller.updateAvailability(true, true, "u4pruydr") + + assertEquals( + listOf( + "subscribe:u4pruydq", + "unsubscribe", + "subscribe:u4pruydr", + ), + events, + ) + } + + @Test + fun `building sampling is excluded until reveal while bookmarks remain eligible`() { + val channels = listOf( + GeohashChannel(GeohashChannelLevel.BUILDING, "u4pruydq"), + GeohashChannel(GeohashChannelLevel.BLOCK, "u4pruyd"), + GeohashChannel(GeohashChannelLevel.CITY, "u4pru"), + ) + + assertEquals( + listOf("u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = false, + ), + ) + assertEquals( + listOf("u4pruydq", "u4pruyd", "u4pru", "saved123"), + geohashesForSampling( + availableChannels = channels, + bookmarks = listOf("saved123"), + notesRevealed = true, + ), + ) + } +} From 4a34408db9b3d1fd04b7a0880148d5ea885e2024 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:45:07 +0300 Subject: [PATCH 32/32] Enhancement/apk sharing (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add QR code generator for Wi-Fi and URLs This commit introduces a `QrCodeGenerator` utility object to create QR code bitmaps for both Wi-Fi credentials and URLs. Key features: - **`generateWifiQr`**: Creates a QR code using the standard `WIFI:` format, allowing other devices to connect to a hotspot by scanning the code. It properly escapes special characters in the SSID and password. - **`generateUrlQr`**: Generates a standard QR code for any given URL. - **Implementation**: Uses the `zxing` library to encode the data and converts the resulting `BitMatrix` into an Android `Bitmap`. * feat: Add manager for universal APK sharing This commit introduces a comprehensive system for fetching, downloading, caching, and managing a "universal" APK of the app, intended for offline sharing with new users. The core components are: - `GitHubReleaseClient`: A new client to fetch the latest release information from the project's GitHub repository. It specifically looks for a universal APK asset in the release, parses its download URL, and attempts to extract its SHA256 checksum from the release notes. - `UniversalApkManager`: Manages the entire lifecycle of the universal APK. It handles: - Checking for new versions by comparing the cached APK version against the latest GitHub release. - Downloading the APK with progress reporting. - Verifying the downloaded file against the SHA256 checksum, if available. - Caching the APK and its metadata (version, checksum, size) locally. - Cleaning up old APK versions to conserve space. * feat: Add offline APK sharing via Wi-Fi hotspot This commit introduces a comprehensive feature for sharing the BitChat application offline using a self-hosted Wi-Fi Direct hotspot. This enables mesh network expansion by allowing users to distribute the app without requiring an internet connection. Key components: - **`HotspotManager`**: A new class that manages the creation and lifecycle of a Wi-Fi P2P (Wi-Fi Direct) group. It handles generating secure credentials (SSID/password), acquiring WakeLocks, and monitoring connected peers. It supports custom credentials on Android 10+ and falls back to system-generated ones on older versions. - **`ApkWebServer`**: A lightweight HTTP server based on `NanoHTTPD` that serves the APK file and a user-friendly HTML landing page to connected devices. - **`ApkSharingUtils`**: A utility to detect whether the app is installed as a single or split APK, collect the necessary files, and copy them to a cache directory for sharing. - **`ApkInstaller`**: A utility using the `PackageInstaller` API to handle the installation of single or split APKs received from another user. - **`HotspotActivity`**: A new Compose-based UI that guides the user through starting the hotspot, displays connection details (Wi-Fi credentials, QR codes for Wi-Fi and the download URL), and shows the number of connected peers. It also handles the necessary runtime permissions (`NEARBY_WIFI_DEVICES` or `ACCESS_FINE_LOCATION`). - **Configuration**: - Adds necessary Wi-Fi and P2P permissions to `AndroidManifest.xml`. - Defines a `FileProvider` path for APK sharing in `file_paths.xml`. - Adds numerous string resources for the new UI. * feat: Add offline and online app sharing features This commit introduces a comprehensive feature set for sharing the application, both offline via a Wi-Fi hotspot and online through standard Android sharing mechanisms. Key additions: - **Prepare for Sharing UI:** - Adds a "Prepare App for Sharing" option in the settings sheet. - This feature downloads a universal APK from a remote source, suitable for all Android devices. - The UI displays the status: not downloaded, downloading (with progress), ready, or if an update is available. - Users can download, update, or delete the cached universal APK. - **Offline Sharing via Wi-Fi Hotspot:** - Adds a "Share via Wi-Fi Hotspot" option. - This launches a new `HotspotActivity` to share the prepared universal APK with nearby devices without an internet connection. - A dialog informs the user if the APK hasn't been prepared yet. - **Online & Local Sharing:** - Adds an option to share via Bluetooth, email, etc., using the standard Android share sheet. - This method shares the *installed* version of the app, which may be a split APK. - An explanatory dialog is shown first, instructing the receiver on how to install split APKs if necessary. - Implements logic to correctly package and share single or multiple split APK files using `FileProvider`. * feat: Add NanoHTTPD for hotspot APK sharing This commit introduces the `nanohttpd` library, which will be used to implement an HTTP server for sharing the application's APK over a local hotspot. The specific dependency added is `org.nanohttpd:nanohttpd:2.3.1`. * refactor: Improve hotspot and APK sharing stability This commit introduces several fixes and refinements to the hotspot sharing and APK handling features, improving stability, user experience, and robustness. Key changes: - **Hotspot Flow:** - Automatically starts the hotspot after the user grants the required Wi-Fi permission, removing the need for a second button press. - Ensures all `HotspotManager` callbacks in `HotspotViewModel` are executed within `viewModelScope` to prevent threading issues and ensure safe UI updates. - Fixes a potential `BroadcastReceiver` leak in `HotspotManager` by tracking its registration state, preventing crashes and resource leaks when stopping the hotspot. - Changes the hotspot `WakeLock` to be non-expiring to prevent the CPU from sleeping while the hotspot is active. - **APK Handling & Installation:** - Adds a pre-download disk space check in `UniversalApkManager` to prevent download failures on devices with insufficient storage. - Improves the file move logic after download by falling back to a copy-and-delete strategy if a direct rename fails, making it more robust across different filesystems. - Introduces `InstallResultReceiver` to provide clear Toast notifications to the user about the success or failure of an APK installation, including specific error reasons (e.g., "Not enough storage"). - **Performance & UI:** - Caches the generated HTML in `ApkWebServer` to improve performance by avoiding regeneration on every request. - Throttles the APK download progress updates to prevent UI jankiness from too-frequent state changes. - Moves hardcoded strings in the "Share App" UI to `strings.xml` for better localization and maintenance. * feat: Refactor APK sharing to use universal APK This commit refactors the "Share App" functionality to exclusively use the new universal APK system, removing the previous logic that shared the installed split APKs. This simplifies the sharing process and ensures a consistent, single-file sharing experience for all users. Key changes: - Deletes `ApkSharingUtils.kt`, which was responsible for detecting and copying split APKs from the device's installation directory. - Updates `AboutSheet.kt` to use `UniversalApkManager` for all sharing actions (Hotspot and "Quick Share"). - Simplifies the sharing intent logic, as it now only needs to handle a single APK file (`ACTION_SEND`) instead of multiple files (`ACTION_SEND_MULTIPLE`). - The UI for sharing options (Hotspot, Quick Share) is now dynamically hidden until the universal APK is prepared, preventing user confusion. - Replaces hardcoded strings with string resources for better localization. * refactor: Remove InstallResultReceiver Deletes the `InstallResultReceiver` broadcast receiver. This component was responsible for handling the results of an APK installation initiated via `PackageInstaller`, but it is no longer used in the current implementation. * feat: Move APK download to resumable WorkManager pipeline Replaces the ViewModel-scoped coroutine download with a WorkManager-backed downloader so downloads survive app backgrounding and process death: - New ApkDownloader interface with WorkManagerApkDownloader implementation and ApkDownloadWorker (CoroutineWorker); transient IO errors return Result.retry() and resume via HTTP Range requests from the partial file. - UniversalApkManager gains resume support (Range header + persisted resume metadata) and verifies the downloaded APK is signed with the same certificate as the running app (no hardcoded fingerprint; debug-signed builds skip enforcement). - APK downloads now go through the shared OkHttpProvider so they respect the app's Tor proxy configuration instead of leaking the direct IP. - AboutSheet logic extracted into ApkDownloadViewModel (MVI: state/event/ effect), removing ~240 lines of UI-embedded logic. - Removes unused ApkInstaller (receivers install via the system installer). Co-Authored-By: Claude Fable 5 * fix: Harden hotspot server and release metadata fetching - ApkWebServer only serves the exact /bitchat.apk path instead of any *.apk-suffixed URI. - GitHubReleaseClient uses the shared OkHttpProvider (respects Tor proxy) and drops the loose 'any lone 64-hex string in the release notes is the checksum' fallback, which was spoofable. Co-Authored-By: Claude Fable 5 * fix: Address review findings in HotspotManager - Keep polling for group info while the group is still forming instead of silently stopping when the first requestGroupInfo() returns null, with a 15s formation timeout (Codex P1). - Release wake/wifi locks and unregister the broadcast receiver on terminal startup failures via failStartup(), so a failed attempt no longer leaks resources or blocks subsequent attempts (Codex P2). - Use PARTIAL_WAKE_LOCK with a 30-minute timeout instead of the deprecated FULL_WAKE_LOCK held indefinitely. Co-Authored-By: Claude Fable 5 * fix: harden offline APK sharing * fix: Handle fully-downloaded temp file before adding Range header If the process died after download_temp.apk was fully written but before verification/promotion, the next attempt sent "Range: bytes=-", GitHub answered 416, and the worker retried the same request forever, leaving the user stuck on an unresumable download. - Skip the network entirely when the temp file already holds the full asset and go straight to checksum/signature verification. - Treat an HTTP 416 response as an invalid resume offset: discard the partial state so the retry restarts from scratch instead of looping. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: Allow sharing when the GitHub release lags the installed version Upstream bumps versionName in main before tagging the GitHub release, so there is a recurring window where the installed app is newer than the latest published universal APK. The hard version guard disabled the whole sharing feature during that window (including for reviewers building this branch at 1.7.5 while GitHub's latest is 1.7.4). An older release is still a genuine, signed, checksum-verified universal artifact, and Android already refuses downgrade installs on receivers, so: - checkForUpdate now logs (instead of erroring) when the latest release is older than the installed app and proceeds normally. - downloadUniversalApk no longer fails for an older-than-installed release. - A cached artifact stays shareable regardless of the installed version. The cached-artifact preference (never replace a newer cached APK with an older one) and all signature/checksum verification are unchanged. Co-Authored-By: Claude Fable 5 * feat: Run APK download as foreground (dataSync) work Long transfers — especially over Tor — can exceed WorkManager's ~10-minute background execution window, getting the worker stopped and rescheduled repeatedly. Promote the download to foreground work with a progress notification (cancel action included) so it can run to completion. - setForeground() with FOREGROUND_SERVICE_TYPE_DATA_SYNC; the manifest already holds the FOREGROUND_SERVICE_DATA_SYNC permission, and the WorkManager SystemForegroundService is merged with type dataSync. - If Android 12+ rejects the promotion (app backgrounded), the worker logs and continues as regular background work, relying on Range-resume. - Notification updates are throttled to 5% steps and degrade gracefully without POST_NOTIFICATIONS. Addresses the Codex review finding on ApkDownloadWorker. Co-Authored-By: Claude Fable 5 * fix: Move temp APK into place instead of copying it replaceFileSafely copied the source into a .new candidate before the atomic move, doubling peak disk usage: with free space between 1.5x and 2x the APK size, the download completed and then promotion failed on the copy, retrying against the same full temp file. Source and target always live in the same cache directory, so a direct ATOMIC_MOVE (rename) needs no extra space and keeps the same guarantee: it either fully succeeds or leaves both files intact. The existing 1.5x margin in checkDiskSpace is now genuinely sufficient. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: Base the disk space check on remaining bytes when resuming The check ran before resume state was read and always demanded 1.5x the full APK size. Bytes already sitting in download_temp.apk have already consumed storage, so on a low-storage device an interrupted download could fail every resume with "Insufficient storage" even when only a small tail was left to fetch. Read the resume state first and check space for the remaining bytes only. A fresh download still checks the full size, and a complete temp file needs no extra space since promotion is a rename. Addresses the Codex review finding on UniversalApkManager. Co-Authored-By: Claude Fable 5 * fix: cancel APK downloads promptly --------- Co-authored-by: Claude Fable 5 Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- app/build.gradle.kts | 27 + app/src/main/AndroidManifest.xml | 26 +- .../bitchat/android/hotspot/ApkWebServer.kt | 323 +++++++ .../android/hotspot/HotspotActivity.kt | 684 +++++++++++++++ .../bitchat/android/hotspot/HotspotManager.kt | 503 +++++++++++ .../android/hotspot/HotspotViewModel.kt | 154 ++++ .../android/hotspot/QrCodeGenerator.kt | 126 +++ .../com/bitchat/android/net/ArtiTorManager.kt | 21 + .../java/com/bitchat/android/ui/AboutSheet.kt | 507 ++++++++++- .../android/ui/ApkDownloadViewModel.kt | 341 ++++++++ .../android/ui/debug/DebugSettingsSheet.kt | 109 +++ .../bitchat/android/util/ApkDownloadWorker.kt | 161 ++++ .../com/bitchat/android/util/ApkDownloader.kt | 36 + .../android/util/DistributionInfoProvider.kt | 191 ++++ .../android/util/GitHubReleaseClient.kt | 338 +++++++ .../android/util/UniversalApkManager.kt | 825 ++++++++++++++++++ .../android/util/WorkManagerApkDownloader.kt | 93 ++ app/src/main/res/values/strings.xml | 67 ++ app/src/main/res/xml/file_paths.xml | 4 + .../util/DistributionInfoProviderTest.kt | 57 ++ .../android/util/GitHubReleaseClientTest.kt | 99 +++ gradle.properties | 8 +- gradle/libs.versions.toml | 12 + 23 files changed, 4700 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt create mode 100644 app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloader.kt create mode 100644 app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt create mode 100644 app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ee54989..84ea0cd9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,21 @@ plugins { alias(libs.plugins.kotlin.compose) } +val githubReleaseCertSha256 = providers + .environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256") + .orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256")) + .orElse("") +val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get() + .replace(":", "") + .trim() + .lowercase() +require( + normalizedGithubReleaseCertSha256.isEmpty() || + normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}")) +) { + "BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint" +} + android { namespace = "com.bitchat.android" compileSdk = libs.versions.compileSdk.get().toInt() @@ -16,6 +31,11 @@ android { targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 36 versionName = "1.7.5" + buildConfigField( + "String", + "GITHUB_RELEASE_CERT_SHA256", + "\"$normalizedGithubReleaseCertSha256\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -71,6 +91,7 @@ android { } buildFeatures { compose = true + buildConfig = true } packaging { resources { @@ -134,6 +155,12 @@ dependencies { // WebSocket implementation(libs.okhttp) + // WorkManager for background APK downloads + implementation(libs.androidx.work.runtime.ktx) + + // HTTP Server for hotspot APK sharing + implementation(libs.nanohttpd) + // Arti (Tor in Rust) Android bridge - custom build from latest source // Built with rustls, 16KB page size support, and onio//un service client // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c732bbc..0c71b94c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,13 +22,18 @@ - + - - + + + + + @@ -142,5 +147,20 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt new file mode 100644 index 00000000..140f2971 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt @@ -0,0 +1,323 @@ +package com.bitchat.android.hotspot + +import android.content.Context +import android.util.Log +import fi.iki.elonen.NanoHTTPD +import java.io.File +import java.io.FileInputStream + +/** + * Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot. + * Based on NanoHTTPD. + */ +class ApkWebServer( + private val context: Context, + private val apkFile: File, + private val port: Int = DEFAULT_PORT +) : NanoHTTPD(port) { + + companion object { + private const val TAG = "ApkWebServer" + const val DEFAULT_PORT = 9999 + } + + private val appVersion: String by lazy { + try { + context.packageManager + .getPackageArchiveInfo(apkFile.absolutePath, 0) + ?.versionName + ?: "Unknown" + } catch (e: Exception) { + "Unknown" + } + } + + // Cache the HTML landing page (generated once, reused for all requests) + private val cachedHtml: String by lazy { + generateLandingPageHtml() + } + + override fun serve(session: IHTTPSession): Response { + val uri = session.uri ?: "/" + + Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}") + + return when { + uri == "/bitchat.apk" -> { + serveApk() + } + uri == "/favicon.ico" -> { + newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found") + } + else -> { + serveLandingPage() + } + } + } + + /** + * Serve the APK file. + */ + private fun serveApk(): Response { + return try { + if (!apkFile.exists()) { + Log.e(TAG, "APK file not found: ${apkFile.path}") + return newFixedLengthResponse( + Response.Status.NOT_FOUND, + "text/plain", + "APK file not found" + ) + } + + Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)") + + val inputStream = FileInputStream(apkFile) + val response = newFixedLengthResponse( + Response.Status.OK, + "application/vnd.android.package-archive", + inputStream, + apkFile.length() + ) + + response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"") + response.addHeader("Accept-Ranges", "bytes") + + response + } catch (e: Exception) { + Log.e(TAG, "Error serving APK", e) + newFixedLengthResponse( + Response.Status.INTERNAL_ERROR, + "text/plain", + "Error serving APK: ${e.message}" + ) + } + } + + /** + * Serve the HTML landing page. + */ + private fun serveLandingPage(): Response { + return newFixedLengthResponse( + Response.Status.OK, + "text/html", + cachedHtml + ) + } + + /** + * Generate HTML landing page. + */ + private fun generateLandingPageHtml(): String { + val apkSizeMb = apkFile.length() / 1024 / 1024 + + return """ + + + + + + Download BitChat + + + +
+ +

BitChat

+

Secure Mesh Messaging

+ +
+
+
Version
+
$appVersion
+
+
+
Size
+
${apkSizeMb} MB
+
+
+ + + 📥 Download BitChat + + +
+

📱 Installation Instructions

+
    +
  1. Tap the download button above
  2. +
  3. Wait for the download to complete
  4. +
  5. Open the downloaded APK file
  6. +
  7. If prompted, enable "Install from unknown sources" for your browser
  8. +
  9. Follow the installation prompts
  10. +
+
+ +
+ ⚠️ Note: + If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed. +
+
+ + + """.trimIndent() + } + + /** + * Start the server. + */ + fun startServer() { + try { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false) + Log.d(TAG, "Web server started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + throw e + } + } + + /** + * Stop the server. + */ + fun stopServer() { + try { + stop() + Log.d(TAG, "Web server stopped") + } catch (e: Exception) { + Log.e(TAG, "Error stopping web server", e) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt new file mode 100644 index 00000000..795cce34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -0,0 +1,684 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatTheme +import com.bitchat.android.util.UniversalApkManager +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import java.io.File + +/** + * Activity for managing Wi-Fi P2P hotspot for offline APK sharing. + * Pure Compose implementation, no fragments. + */ +class HotspotActivity : ComponentActivity() { + + companion object { + const val EXTRA_APK_PATH = "apk_path" + private const val TAG = "HotspotActivity" + } + + private val viewModel: HotspotViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get APK path from intent + val apkPath = intent.getStringExtra(EXTRA_APK_PATH) + val apkFile = if (apkPath != null) { + File(apkPath) + } else { + // Fallback: Try to get cached APK + UniversalApkManager(this).getCachedApk() + } + + if (apkFile == null || !apkFile.exists()) { + // No APK available, show error and finish + finish() + return + } + + setContent { + BitchatTheme { + HotspotScreen( + viewModel = viewModel, + apkFile = apkFile, + onClose = { finish() } + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Handle notification action to stop hotspot + if (intent.action == "STOP_HOTSPOT") { + viewModel.stopHotspot() + finish() + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotspotScreen( + viewModel: HotspotViewModel, + apkFile: File, + onClose: () -> Unit +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Share BitChat", + fontFamily = FontFamily.Monospace + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Crossfade( + targetState = state, + label = "HotspotStateCrossfade", + modifier = Modifier.padding(padding) + ) { currentState -> + when (currentState) { + is HotspotViewModel.HotspotState.Intro -> { + IntroScreen( + onStartHotspot = { viewModel.startHotspot(apkFile) } + ) + } + is HotspotViewModel.HotspotState.Starting -> { + LoadingScreen() + } + is HotspotViewModel.HotspotState.Active -> { + ActiveHotspotScreen(state = currentState) + } + is HotspotViewModel.HotspotState.Error -> { + ErrorScreen( + message = currentState.message, + onRetry = { viewModel.resetToIntro() }, + onClose = onClose + ) + } + } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun IntroScreen(onStartHotspot: () -> Unit) { + // Determine which permission to request based on Android version + val requiredPermission = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.NEARBY_WIFI_DEVICES + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Manifest.permission.ACCESS_FINE_LOCATION + else -> null // No runtime permission needed on Android < 10 + } + + val permissionState = requiredPermission?.let { + rememberPermissionState(it) { granted -> + if (granted) { + onStartHotspot() + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Offline App Sharing", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "How it works:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + InfoItem("1. Your device creates a Wi-Fi hotspot") + InfoItem("2. Others connect to your hotspot") + InfoItem("3. They scan a QR code or enter a URL") + InfoItem("4. BitChat downloads directly to their device") + } + } + + // Permission rationale (if needed) + if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "ℹ️ Permission Required", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + "BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline." + } else { + "BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "⚠️ Note", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + // Check permission before starting hotspot + if (permissionState == null || permissionState.status.isGranted) { + // No permission needed or already granted + onStartHotspot() + } else { + // Request permission (auto-start handled by onPermissionResult callback) + permissionState.launchPermissionRequest() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + // Starting the hotspot is the user's action. Android will ask + // for the required permission only when it has not already + // been granted. + text = "Start Hotspot", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun InfoItem(text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } +} + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Starting hotspot...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { + var selectedTab by remember { mutableStateOf(0) } + val tabs = listOf("Wi-Fi", "Website") + + Column( + modifier = Modifier.fillMaxSize() + ) { + // Status banner + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Hotspot Active", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "${state.connectedPeers} device(s) connected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + } + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } + } + + // Tabs + TabRow( + selectedTabIndex = selectedTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + + // Tab content + when (selectedTab) { + 0 -> WifiTabContent( + ssid = state.ssid, + password = state.password + ) + 1 -> WebsiteTabContent( + ipAddress = state.ipAddress, + port = state.port + ) + } + } +} + +@Composable +fun WifiTabContent(ssid: String, password: String) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 1: Connect to Wi-Fi", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "Have others scan this QR code to connect:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val wifiQr = remember(ssid, password, qrSize) { + QrCodeGenerator.generateWifiQr(ssid, password, qrSize) + } + + if (wifiQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = wifiQr.asImageBitmap(), + contentDescription = "Wi-Fi QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or enter manually:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // SSID + CredentialCard( + label = "Network Name (SSID)", + value = ssid, + onCopy = { + clipboardManager.setText(AnnotatedString(ssid)) + } + ) + + // Password + CredentialCard( + label = "Password", + value = password, + onCopy = { + clipboardManager.setText(AnnotatedString(password)) + } + ) + } +} + +@Composable +fun WebsiteTabContent(ipAddress: String, port: Int) { + val url = "http://$ipAddress:$port" + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + text = "Step 2: Download BitChat", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Text( + text = "After connecting to the Wi-Fi, scan this QR code:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + // QR Code + val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() } + val urlQr = remember(url, qrSize) { + QrCodeGenerator.generateUrlQr(url, qrSize) + } + + if (urlQr != null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(16.dp) + ) { + Image( + bitmap = urlQr.asImageBitmap(), + contentDescription = "Website URL QR Code", + modifier = Modifier.size(280.dp) + ) + } + } + + Text( + text = "Or open in browser:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // URL + CredentialCard( + label = "Website URL", + value = url, + onCopy = { + clipboardManager.setText(AnnotatedString(url)) + } + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "📱 Instructions", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "1. Make sure you're connected to the Wi-Fi network above\n" + + "2. Open a web browser on your device\n" + + "3. Visit the URL above or scan the QR code\n" + + "4. Tap 'Download BitChat'\n" + + "5. Install the downloaded APK", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun CredentialCard( + label: String, + value: String, + onCopy: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + IconButton(onClick = onCopy) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } +} + +@Composable +fun ErrorScreen( + message: String, + onRetry: () -> Unit, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "❌", + fontSize = 64.sp + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Error", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text("Try Again") + } + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton(onClick = onClose) { + Text("Close") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt new file mode 100644 index 00000000..bf2f3a93 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt @@ -0,0 +1,503 @@ +package com.bitchat.android.hotspot + +import android.Manifest +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pManager +import android.net.wifi.p2p.WifiP2pManager.* +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.util.Log +import androidx.core.content.ContextCompat +import java.net.NetworkInterface +import java.security.SecureRandom +import kotlin.random.Random + +/** + * Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing. + * Based on Briar's implementation. + */ +class HotspotManager(private val context: Context) { + + companion object { + private const val TAG = "HotspotMgr" + + // Retry configuration + private const val MAX_FRAMEWORK_ATTEMPTS = 5 + private const val RETRY_DELAY_MILLIS = 1000L + + // Group info polling interval + private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L + + // Give up if the group never forms within this window after creation succeeded + private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L + + // SSID and password configuration + private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat + private const val SSID_SUFFIX_LENGTH = 8 + private const val PASSWORD_LENGTH = 16 + + // Characters to use for random generation (excluding confusing ones) + private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I + } + + private val wifiP2pManager: WifiP2pManager? = + context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + + private var channel: Channel? = null + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null + + private val handler = Handler(Looper.getMainLooper()) + private val random = SecureRandom() + + private var currentGroup: WifiP2pGroup? = null + private var callback: HotspotCallback? = null + private var isStarting = false + private var hasNotifiedStarted = false // Track if we've notified the callback + private var isReceiverRegistered = false // Track receiver registration to prevent leaks + + // Saved credentials for reconnection + private var savedSsid: String? = null + private var savedPassword: String? = null + + // Broadcast receiver for Wi-Fi P2P events + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> { + val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1) + Log.d(TAG, "Wi-Fi P2P state changed: $state") + } + WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> { + Log.d(TAG, "Wi-Fi P2P connection changed") + requestGroupInfo() + } + } + } + } + + /** + * Start the Wi-Fi P2P hotspot. + */ + fun startHotspot(callback: HotspotCallback) { + if (isStarting) { + Log.w(TAG, "Hotspot already starting") + return + } + + if (wifiP2pManager == null) { + Log.e(TAG, "Wi-Fi P2P not available on this device") + callback.onError("Wi-Fi Direct not supported on this device") + return + } + + val missingPermission = requiredRuntimePermission()?.takeUnless { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + if (missingPermission != null) { + Log.w(TAG, "Cannot start hotspot without $missingPermission") + callback.onError("Nearby Wi-Fi permission is required to start the hotspot") + return + } + + this.callback = callback + isStarting = true + + Log.d(TAG, "Starting Wi-Fi P2P hotspot") + + // Register broadcast receiver (only if not already registered) + if (!isReceiverRegistered) { + val intentFilter = IntentFilter().apply { + addAction(WIFI_P2P_STATE_CHANGED_ACTION) + addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION) + } + context.registerReceiver(broadcastReceiver, intentFilter) + isReceiverRegistered = true + Log.d(TAG, "Broadcast receiver registered") + } + + // Acquire locks + acquireLocks() + + // Load or generate credentials + if (savedSsid == null || savedPassword == null) { + savedSsid = generateSsid() + savedPassword = generatePassword() + Log.d(TAG, "Generated new credentials: SSID=$savedSsid") + } else { + Log.d(TAG, "Using saved credentials: SSID=$savedSsid") + } + + // Start P2P framework with retries + startWifiP2pFramework(1) + } + + /** + * Stop the hotspot. + */ + fun stopHotspot() { + Log.d(TAG, "Stopping hotspot") + + isStarting = false + hasNotifiedStarted = false + + // Stop group info polling + handler.removeCallbacksAndMessages(null) + + // Remove group + channel?.let { ch -> + wifiP2pManager?.removeGroup(ch, object : ActionListener { + override fun onSuccess() { + Log.d(TAG, "Group removed successfully") + } + override fun onFailure(reason: Int) { + Log.w(TAG, "Failed to remove group: $reason") + } + }) + } + + // Release locks + releaseLocks() + + // Unregister receiver (only if registered) + if (isReceiverRegistered) { + try { + context.unregisterReceiver(broadcastReceiver) + isReceiverRegistered = false + Log.d(TAG, "Broadcast receiver unregistered") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Receiver was not registered", e) + isReceiverRegistered = false + } + } + + currentGroup = null + channel = null + callback = null + } + + /** + * Get current connection information. + */ + fun getConnectionInfo(): ConnectionInfo? { + val group = currentGroup ?: return null + val ipAddress = getAccessPointAddress() + + return ConnectionInfo( + ssid = group.networkName ?: savedSsid ?: "", + password = group.passphrase ?: savedPassword ?: "", + ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP + connectedPeers = group.clientList?.size ?: 0 + ) + } + + /** + * Start Wi-Fi P2P framework with retry logic. + */ + private fun startWifiP2pFramework(attempt: Int) { + if (attempt > MAX_FRAMEWORK_ATTEMPTS) { + Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts") + failStartup("Failed to start hotspot. Please try again.") + return + } + + Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)") + + channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null) + + if (channel == null) { + Log.e(TAG, "Failed to initialize P2P channel") + handler.postDelayed({ + startWifiP2pFramework(attempt + 1) + }, RETRY_DELAY_MILLIS) + return + } + + createGroup(attempt) + } + + /** + * Create Wi-Fi P2P group. + */ + @SuppressLint("MissingPermission") + private fun createGroup(attempt: Int) { + val ch = channel ?: return + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10+: Custom SSID and password + val config = WifiP2pConfig.Builder() + .setNetworkName(savedSsid!!) + .setPassphrase(savedPassword!!) + .setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility + .build() + + wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch)) + } else { + // Android 9 and below: System-generated SSID/password + wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch)) + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e) + failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.") + } + } + + private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener { + override fun onSuccess() { + if (channel !== requestChannel) { + Log.w(TAG, "Removing group created after hotspot was stopped") + wifiP2pManager?.removeGroup(requestChannel, null) + return + } + Log.d(TAG, "P2P group created successfully") + isStarting = false + // Don't call onHotspotStarted() yet - wait for group info + startGroupInfoPolling() + } + + override fun onFailure(reason: Int) { + if (channel != null) { + handleGroupCreationFailure(reason, attempt) + } + } + } + + /** + * Handle group creation failure with retry logic. + */ + private fun handleGroupCreationFailure(reason: Int, attempt: Int) { + val reasonStr = when (reason) { + ERROR -> "ERROR" + P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" + BUSY -> "BUSY" + else -> "UNKNOWN($reason)" + } + + Log.w(TAG, "Failed to create group: $reasonStr") + + if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) { + // Framework is busy, retry + Log.d(TAG, "P2P framework busy, retrying...") + handler.postDelayed({ + startWifiP2pFramework(attempt + 1) + }, RETRY_DELAY_MILLIS) + } else { + failStartup("Failed to create hotspot: $reasonStr") + } + } + + /** + * Terminal startup failure: release all resources (locks, receiver, handler + * callbacks) before notifying the callback, so a failed attempt doesn't leak + * and block subsequent attempts. + */ + private fun failStartup(message: String) { + val cb = callback + stopHotspot() + cb?.onError(message) + } + + /** + * Start polling for group info to track connected clients. + */ + private fun startGroupInfoPolling() { + requestGroupInfo() + + // Keep polling even while the group info is still null — the first + // requestGroupInfo() after createGroup() can legitimately return null + // while the group is forming. Give up only after a timeout. + var elapsedMillis = 0L + handler.postDelayed(object : Runnable { + override fun run() { + if (channel == null) return + + elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS + if (currentGroup == null && !hasNotifiedStarted && + elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS + ) { + Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms") + failStartup("Hotspot failed to start. Please try again.") + return + } + + requestGroupInfo() + handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + }, GROUP_INFO_POLL_INTERVAL_MILLIS) + } + + /** + * Request current group information. + */ + @SuppressLint("MissingPermission") + private fun requestGroupInfo() { + val ch = channel ?: return + + try { + wifiP2pManager?.requestGroupInfo(ch) { group -> + if (group != null) { + currentGroup = group + + // Update saved credentials if using system-generated ones + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + savedSsid = group.networkName + savedPassword = group.passphrase + } + + // Notify callback on FIRST successful group info retrieval + if (!hasNotifiedStarted) { + hasNotifiedStarted = true + Log.d(TAG, "Group info received, notifying callback") + callback?.onHotspotStarted() + } else { + // Subsequent updates + callback?.onConnectionInfoUpdated(getConnectionInfo()) + } + } else { + Log.w(TAG, "requestGroupInfo returned null group") + } + } + } catch (e: SecurityException) { + Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e) + failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.") + } + } + + private fun requiredRuntimePermission(): String? { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> + Manifest.permission.NEARBY_WIFI_DEVICES + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + Manifest.permission.ACCESS_FINE_LOCATION + else -> null + } + } + + /** + * Acquire WakeLock and WifiLock to keep hotspot active. + */ + private fun acquireLocks() { + try { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "BitChat:HotspotWakeLock" + ) + wakeLock?.acquire(30 * 60 * 1000L) + + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF + } else { + android.net.wifi.WifiManager.WIFI_MODE_FULL + } + wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock") + wifiLock?.acquire() + + Log.d(TAG, "Acquired WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error acquiring locks", e) + } + } + + /** + * Release WakeLock and WifiLock. + */ + private fun releaseLocks() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + + wifiLock?.let { + if (it.isHeld) { + it.release() + } + } + wifiLock = null + + Log.d(TAG, "Released WakeLock and WifiLock") + } catch (e: Exception) { + Log.e(TAG, "Error releasing locks", e) + } + } + + /** + * Get the IP address of the P2P access point. + * Looks for network interface starting with "p2p". + */ + private fun getAccessPointAddress(): String? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (iface.name.startsWith("p2p")) { + val addresses = iface.interfaceAddresses + for (addr in addresses) { + val address = addr.address + // IPv4 only (4 bytes) + if (address.address.size == 4) { + return address.hostAddress + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error getting access point address", e) + } + return null + } + + /** + * Generate random SSID. + * Format: DIRECT-BC-XXXXXXXX + */ + private fun generateSsid(): String { + val suffix = (1..SSID_SUFFIX_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + return "$SSID_PREFIX$suffix" + } + + /** + * Generate random password. + * 16 characters, excluding confusing characters. + */ + private fun generatePassword(): String { + return (1..PASSWORD_LENGTH) + .map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] } + .joinToString("") + } + + /** + * Connection information for the hotspot. + */ + data class ConnectionInfo( + val ssid: String, + val password: String, + val ipAddress: String, + val connectedPeers: Int + ) + + /** + * Callback interface for hotspot events. + */ + interface HotspotCallback { + fun onHotspotStarted() + fun onConnectionInfoUpdated(info: ConnectionInfo?) + fun onError(message: String) + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt new file mode 100644 index 00000000..50b04855 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotViewModel.kt @@ -0,0 +1,154 @@ +package com.bitchat.android.hotspot + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * ViewModel for managing hotspot state and lifecycle. + */ +class HotspotViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "HotspotViewModel" + } + + private val _state = MutableStateFlow(HotspotState.Intro) + val state: StateFlow = _state.asStateFlow() + + private var hotspotManager: HotspotManager? = null + private var webServer: ApkWebServer? = null + private val context = application.applicationContext + + /** + * Start the hotspot with the provided APK file. + */ + fun startHotspot(apkFile: File) { + if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) { + Log.w(TAG, "Hotspot already starting or active") + return + } + + Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}") + _state.value = HotspotState.Starting + + viewModelScope.launch { + try { + // Start hotspot + val manager = HotspotManager(context) + hotspotManager = manager + + manager.startHotspot(object : HotspotManager.HotspotCallback { + override fun onHotspotStarted() { + viewModelScope.launch { + Log.d(TAG, "Hotspot started successfully") + + // Get connection info + val info = manager.getConnectionInfo() + if (info == null) { + manager.stopHotspot() + _state.value = HotspotState.Error("Failed to get hotspot connection info") + return@launch + } + + // Start web server + try { + val server = ApkWebServer(context, apkFile) + server.startServer() + webServer = server + + Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}") + + // Update state with connection info + _state.value = HotspotState.Active( + ssid = info.ssid, + password = info.password, + ipAddress = info.ipAddress, + port = ApkWebServer.DEFAULT_PORT, + connectedPeers = info.connectedPeers + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to start web server", e) + manager.stopHotspot() + _state.value = HotspotState.Error("Failed to start web server: ${e.message}") + } + } + } + + override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) { + viewModelScope.launch { + // Update peer count if we're active + val currentState = _state.value + if (currentState is HotspotState.Active && info != null) { + _state.value = currentState.copy(connectedPeers = info.connectedPeers) + } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + Log.e(TAG, "Hotspot error: $message") + _state.value = HotspotState.Error(message) + } + } + }) + + } catch (e: Exception) { + Log.e(TAG, "Error starting hotspot", e) + hotspotManager?.stopHotspot() + _state.value = HotspotState.Error(e.message ?: "Unknown error") + } + } + } + + /** + * Stop the hotspot and web server. + */ + fun stopHotspot() { + Log.d(TAG, "Stopping hotspot") + + webServer?.stopServer() + webServer = null + + hotspotManager?.stopHotspot() + hotspotManager = null + + _state.value = HotspotState.Intro + } + + /** + * Reset to intro state (for retry after error). + */ + fun resetToIntro() { + stopHotspot() + _state.value = HotspotState.Intro + } + + override fun onCleared() { + super.onCleared() + Log.d(TAG, "ViewModel cleared, stopping hotspot") + stopHotspot() + } + + /** + * Hotspot state sealed class. + */ + sealed class HotspotState { + object Intro : HotspotState() + object Starting : HotspotState() + data class Active( + val ssid: String, + val password: String, + val ipAddress: String, + val port: Int, + val connectedPeers: Int + ) : HotspotState() + data class Error(val message: String) : HotspotState() + } +} diff --git a/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt new file mode 100644 index 00000000..69c2bdf1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt @@ -0,0 +1,126 @@ +package com.bitchat.android.hotspot + +import android.graphics.Bitmap +import android.util.Log +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter + +/** + * Utility for generating QR codes for Wi-Fi connection and URL. + */ +object QrCodeGenerator { + + private const val TAG = "QrCodeGenerator" + + /** + * Generate QR code for Wi-Fi connection. + * Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + * + * This format is recognized by most Android/iOS devices for instant Wi-Fi connection. + * + * @param ssid Wi-Fi network name + * @param password Wi-Fi password + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? { + if (ssid.isBlank() || password.isBlank()) { + Log.w(TAG, "SSID or password is blank") + return null + } + + // Escape special characters + val escapedSsid = escapeWifiString(ssid) + val escapedPassword = escapeWifiString(password) + + // Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};; + val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;" + + Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid") + + return generateQrBitmap(wifiString, sizePx) + } + + /** + * Generate QR code for URL. + * + * @param url Website URL (e.g., "http://192.168.49.1:9999") + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + fun generateUrlQr(url: String, sizePx: Int): Bitmap? { + if (url.isBlank()) { + Log.w(TAG, "URL is blank") + return null + } + + Log.d(TAG, "Generating URL QR code: $url") + + return generateQrBitmap(url, sizePx) + } + + /** + * Generate QR code bitmap from string data. + * + * @param data String data to encode + * @param sizePx Size of the QR code in pixels + * @return Bitmap of the QR code, or null on error + */ + private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) { + Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx") + return null + } + + return try { + val matrix = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + sizePx, + sizePx + ) + bitmapFromMatrix(matrix) + } catch (e: Exception) { + Log.e(TAG, "Error generating QR code", e) + null + } + } + + /** + * Convert BitMatrix to Bitmap. + * Pattern from VerificationSheet.kt. + */ + private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = if (matrix[x, y]) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + } + } + + return bitmap + } + + /** + * Escape special characters in Wi-Fi SSID/password for QR code format. + * Special characters that need escaping: \ ; , " : + */ + private fun escapeWifiString(input: String): String { + return input + .replace("\\", "\\\\") // Backslash must be escaped first + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\"", "\\\"") + .replace(":", "\\:") + } +} diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index 8dc0cad8..e3490576 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -168,6 +168,27 @@ class ArtiTorManager private constructor() { fun currentSocksAddress(): InetSocketAddress? = socksAddr + /** + * Wait until the currently selected HTTP route can be used. + * + * When Tor mode is enabled, [socksAddr] is intentionally published before + * bootstrap completes so clients fail closed instead of leaking traffic + * directly. Callers that initiate one-shot HTTP work should wait here rather + * than repeatedly connecting to a SOCKS port that is not listening yet. + */ + suspend fun awaitSelectedRoute(timeoutMs: Long): Boolean { + if (currentSocksAddress() == null || isProxyEnabled()) { + return true + } + + return withTimeoutOrNull(timeoutMs) { + statusFlow.first { + currentSocksAddress() == null || isProxyEnabled() + } + true + } ?: false + } + suspend fun applyMode(application: Application, mode: TorMode) { applyMutex.withLock { try { diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f137ac63..7a64ff50 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,40 +1,91 @@ package com.bitchat.android.ui +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Speed -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.nostr.NostrProofOfWork -import com.bitchat.android.nostr.PoWPreferenceManager -import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet +import com.bitchat.android.hotspot.HotspotActivity +import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager -import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.util.UniversalApkManager /** * Feature row for displaying app capabilities @@ -452,6 +503,358 @@ fun AboutSheet( } } else null ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Prepare App for Sharing Section === + val apkViewModel: ApkDownloadViewModel = viewModel() + val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() + val apkStatus = apkUiState.apkStatus + val downloadProgress = apkUiState.downloadProgress + + // Handle one-shot effects (navigation, toasts, share intents) + LaunchedEffect(Unit) { + apkViewModel.onEvent(ApkUiEvent.CheckStatus) + apkViewModel.effect.collect { effect -> + when (effect) { + is ApkUiEffect.NavigateToHotspot -> { + val intent = Intent(context, HotspotActivity::class.java) + intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath) + context.startActivity(intent) + } + is ApkUiEffect.ShareApk -> { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(Intent.EXTRA_STREAM, effect.apkUri) + clipData = android.content.ClipData.newRawUri("", effect.apkUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, effect.chooserTitle).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(chooser) + } + is ApkUiEffect.ShowToast -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + // Prepare App for Sharing Row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (apkStatus is ApkPreparationStatus.Ready) { + Icons.Default.Share + } else { + Icons.Default.CloudDownload + }, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = if (apkStatus is ApkPreparationStatus.Ready) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = when (val status = apkStatus) { + is ApkPreparationStatus.Loading -> stringResource(R.string.checking) + is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) + is ApkPreparationStatus.Ready -> { + val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) { + stringResource(R.string.prepare_apk_source_installed) + } else { + stringResource(R.string.prepare_apk_source_github) + } + stringResource(R.string.prepare_apk_status_ready) + + " • ${status.version} • ${status.sizeMB} MB\n$source" + } + is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})" + is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress) + is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded" + is ApkPreparationStatus.Error -> status.message + }, + style = MaterialTheme.typography.bodySmall, + color = when (apkStatus) { + is ApkPreparationStatus.Error -> colorScheme.error + is ApkPreparationStatus.Resumable -> colorScheme.primary + is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary + else -> colorScheme.onSurface.copy(alpha = 0.6f) + }, + lineHeight = 16.sp + ) + } + + // Action buttons + when (apkStatus) { + is ApkPreparationStatus.Downloading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + is ApkPreparationStatus.Ready -> { + if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { + androidx.compose.material3.IconButton( + onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } + is ApkPreparationStatus.UpdateAvailable -> { + androidx.compose.material3.IconButton( + onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + else -> {} + } + } + + // Prepare Dialog + if (apkUiState.showPrepareDialog) { + val status = apkStatus + val sizeMB: Int? = when (status) { + is ApkPreparationStatus.NotDownloaded -> status.sizeMB + is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB + else -> null + } + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, + title = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_title) + } else { + stringResource(R.string.prepare_apk_dialog_title) + }, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = if (status is ApkPreparationStatus.UpdateAvailable) { + stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) + } else if (sizeMB != null) { + stringResource(R.string.prepare_apk_dialog_message, sizeMB) + } else { + stringResource(R.string.prepare_apk_dialog_message_unknown_size) + }, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button(onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDownload) + }) { + Text(stringResource(R.string.prepare_apk_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Delete Dialog + if (apkUiState.showDeleteDialog) { + val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0 + AlertDialog( + onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }, + title = { + Text( + text = stringResource(R.string.prepare_apk_delete_confirm), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(R.string.prepare_apk_delete_message, sizeMB), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + apkViewModel.onEvent(ApkUiEvent.ConfirmDelete) + }, + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) { + Text(stringResource(R.string.cancel)) + } + }, + containerColor = colorScheme.surface + ) + } + + // Show sharing rows only when APK is ready + val canShareAPK = apkStatus is ApkPreparationStatus.Ready || + apkStatus is ApkPreparationStatus.UpdateAvailable + + AnimatedVisibility( + visible = canShareAPK, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Hotspot Row === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_via), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_via_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // === Share via Bluetooth/Email Row (Fallback) === + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.hotspot_share_other), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = stringResource(R.string.hotspot_share_other_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 16.sp + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + + // APK Share Dialog + ApkShareExplanationDialog( + show = apkUiState.showShareApkDialog, + onConfirm = { + apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare) + }, + onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) } + ) + } + } + } } @@ -741,3 +1144,91 @@ fun PasswordPromptDialog( ) } } + + +/** + * Dialog explaining APK sharing feature before sharing + */ +@Composable +private fun ApkShareExplanationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (show) { + val colorScheme = MaterialTheme.colorScheme + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = stringResource(R.string.share_apk_title), + style = MaterialTheme.typography.titleLarge, + color = colorScheme.onSurface + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.share_apk_explanation), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + + // Info box with receiver instructions + Surface( + color = colorScheme.primaryContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.share_apk_receiver_instructions), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 18.sp + ) + } + } + } + }, + confirmButton = { + Button(onClick = onConfirm) { + Text( + text = stringResource(R.string.share_apk_confirm), + style = MaterialTheme.typography.bodyMedium + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface + ) + } + }, + containerColor = colorScheme.surface, + tonalElevation = 8.dp + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt new file mode 100644 index 00000000..0b4e0005 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -0,0 +1,341 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.core.content.FileProvider +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.bitchat.android.R +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.UniversalApkManager +import com.bitchat.android.util.WorkManagerApkDownloader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// --- State --- + +sealed class ApkPreparationStatus { + object Loading : ApkPreparationStatus() + data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus() + data class Ready( + val version: String, + val sizeMB: Int, + val source: UniversalApkManager.ApkSource + ) : ApkPreparationStatus() + data class UpdateAvailable( + val currentVersion: String, + val newVersion: String, + val newSizeMB: Int + ) : ApkPreparationStatus() + object Downloading : ApkPreparationStatus() + data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() + data class Error(val message: String) : ApkPreparationStatus() +} + +data class ApkUiState( + val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val downloadProgress: Int = 0, + val showPrepareDialog: Boolean = false, + val showDeleteDialog: Boolean = false, + val showShareApkDialog: Boolean = false +) + +// --- Events (UI → ViewModel) --- + +sealed class ApkUiEvent { + object CheckStatus : ApkUiEvent() + object PrepareRowClicked : ApkUiEvent() + object ConfirmDownload : ApkUiEvent() + object DismissPrepareDialog : ApkUiEvent() + object DeleteClicked : ApkUiEvent() + object ConfirmDelete : ApkUiEvent() + object DismissDeleteDialog : ApkUiEvent() + object HotspotShareClicked : ApkUiEvent() + object AppShareClicked : ApkUiEvent() + object ConfirmAppShare : ApkUiEvent() + object DismissShareDialog : ApkUiEvent() + object CancelDownload : ApkUiEvent() +} + +// --- Effects (ViewModel → UI, one-shot) --- + +sealed class ApkUiEffect { + data class NavigateToHotspot(val apkPath: String) : ApkUiEffect() + data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect() + data class ShowToast(val message: String) : ApkUiEffect() +} + +/** + * ViewModel for APK download/status/share logic following MVI pattern. + * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. + */ +class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "ApkDownloadVM" + } + + private val apkManager = UniversalApkManager(application) + private val downloader: ApkDownloader = WorkManagerApkDownloader(application) + + private val _state = MutableStateFlow(ApkUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _effect = Channel(Channel.BUFFERED) + val effect = _effect.receiveAsFlow() + + init { + observeDownloader() + } + + fun onEvent(event: ApkUiEvent) { + when (event) { + is ApkUiEvent.CheckStatus -> checkStatus() + is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked() + is ApkUiEvent.ConfirmDownload -> onConfirmDownload() + is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) } + is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) } + is ApkUiEvent.ConfirmDelete -> onConfirmDelete() + is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) } + is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked() + is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) } + is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare() + is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) } + is ApkUiEvent.CancelDownload -> onCancelDownload() + } + } + + private fun onPrepareRowClicked() { + when (_state.value.apkStatus) { + is ApkPreparationStatus.NotDownloaded, + is ApkPreparationStatus.UpdateAvailable, + is ApkPreparationStatus.Error -> { + _state.update { it.copy(showPrepareDialog = true) } + } + is ApkPreparationStatus.Resumable -> { + startDownload() + } + else -> {} + } + } + + private fun onConfirmDownload() { + _state.update { it.copy(showPrepareDialog = false) } + startDownload() + } + + private fun onConfirmDelete() { + _state.update { it.copy(showDeleteDialog = false) } + downloader.cancelDownload() + apkManager.deleteCachedApk() + checkStatus() + } + + private fun onHotspotShareClicked() { + val apkFile = apkManager.getCachedApk() + if (apkFile != null) { + viewModelScope.launch { + _effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath)) + } + } else { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + } + } + + private fun onConfirmAppShare() { + _state.update { it.copy(showShareApkDialog = false) } + viewModelScope.launch(Dispatchers.IO) { + try { + val apkFile = apkManager.getCachedApk() + if (apkFile == null || !apkFile.exists()) { + sendToast(getString(R.string.apk_not_ready_please_prepare_it_first)) + return@launch + } + + val context = getApplication() + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + _effect.send( + ApkUiEffect.ShareApk( + apkUri = uri, + chooserTitle = getString(R.string.share_apk_chooser_title) + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error preparing APK share", e) + sendToast(getString(R.string.share_apk_error)) + } + } + } + + private fun onCancelDownload() { + downloader.cancelDownload() + checkStatus() + } + + private fun startDownload() { + val partial = apkManager.getPartialDownloadProgress() + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Downloading, + downloadProgress = partial ?: 0 + ) + } + downloader.startDownload() + } + + private fun checkStatus() { + viewModelScope.launch { + // WorkManager is the source of truth for active work. A queued or + // newly started job legitimately has no partial file yet, so never + // infer that it is orphaned from cache contents. + if (_state.value.apkStatus is ApkPreparationStatus.Downloading) { + return@launch + } + + val resolvedStatus = resolveApkStatus() + _state.update { current -> + if (current.apkStatus is ApkPreparationStatus.Downloading) { + current + } else { + current.copy(apkStatus = resolvedStatus) + } + } + } + } + + private fun observeDownloader() { + viewModelScope.launch { + downloader.downloadState.collect { downloadState -> + when (downloadState) { + is ApkDownloader.DownloadState.Idle -> { + // Don't overwrite — status set by checkStatus() + } + is ApkDownloader.DownloadState.Downloading -> { + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Downloading, + downloadProgress = downloadState.progressPercent + ) + } + } + is ApkDownloader.DownloadState.Success -> { + val info = apkManager.getCachedApkInfo() + _state.update { + it.copy( + apkStatus = ApkPreparationStatus.Ready( + version = downloadState.version, + sizeMB = downloadState.sizeMB, + source = info?.source ?: UniversalApkManager.ApkSource.GITHUB + ), + downloadProgress = 100 + ) + } + } + is ApkDownloader.DownloadState.Failed -> { + _state.update { + if (downloadState.resumablePercent != null) { + it.copy( + apkStatus = ApkPreparationStatus.Resumable( + progressPercent = downloadState.resumablePercent, + message = downloadState.message + ), + downloadProgress = downloadState.resumablePercent + ) + } else { + it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message)) + } + } + } + } + } + } + } + + private fun sendToast(message: String) { + viewModelScope.launch { + _effect.send(ApkUiEffect.ShowToast(message)) + } + } + + private fun getString(resId: Int): String { + return getApplication().getString(resId) + } + + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { + try { + val updateStatus = apkManager.checkForUpdate() + when (updateStatus) { + is UniversalApkManager.UpdateStatus.NotDownloaded -> { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) + ) + } else { + ApkPreparationStatus.NotDownloaded( + sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + ) + } + } + is UniversalApkManager.UpdateStatus.UpToDate -> { + val info = apkManager.getCachedApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source + ) + } else { + ApkPreparationStatus.Error("Cached APK info not found") + } + } + is UniversalApkManager.UpdateStatus.UpdateAvailable -> { + ApkPreparationStatus.UpdateAvailable( + currentVersion = updateStatus.currentVersion, + newVersion = updateStatus.latestRelease.versionName, + newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + ) + } + is UniversalApkManager.UpdateStatus.Error -> { + // A cached artifact stays shareable even when the update + // check fails or the release lags the installed version. + val info = apkManager.getCachedApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source + ) + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) + ) + } else { + ApkPreparationStatus.Error(updateStatus.message) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error checking APK status", e) + ApkPreparationStatus.Error( + e.message ?: getString(R.string.prepare_apk_error_github) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index be950316..32427c44 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.debug +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -45,6 +48,9 @@ import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.util.DistributionInfoProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun MeshTopologySection( @@ -102,6 +108,95 @@ fun MeshTopologySection( } } +@Composable +private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionInfo?) { + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + + Surface( + shape = RoundedCornerShape(12.dp), + color = colorScheme.surfaceVariant.copy(alpha = 0.2f) + ) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) + Text( + "Distribution info", + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + + if (info == null) { + Text( + "Inspecting installed package…", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } else { + DistributionInfoRow("Install source", info.installSource) + info.installerPackage?.let { + DistributionInfoRow("Installer package", it) + } + DistributionInfoRow("Package format", info.packageFormat) + DistributionInfoRow("APK architecture", info.architecture) + DistributionInfoRow("Sharing source", info.sharingSource) + DistributionInfoRow("Version", "${info.versionName} (${info.versionCode})") + DistributionInfoRow("Signing channel", info.signingChannel) + DistributionInfoRow( + label = "Certificate SHA-256", + value = info.certificateSha256 ?: "Unavailable" + ) + + if (info.certificateSha256 != null) { + TextButton( + onClick = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip( + ClipData.newPlainText( + "BitChat signing certificate SHA-256", + info.certificateSha256 + ) + ) + Toast.makeText(context, "Certificate fingerprint copied", Toast.LENGTH_SHORT).show() + }, + contentPadding = PaddingValues(horizontal = 0.dp) + ) { + Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace) + } + } + } + } + } +} + +@Composable +private fun DistributionInfoRow(label: String, value: String) { + val colorScheme = MaterialTheme.colorScheme + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.55f) + ) + Text( + value, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.9f) + ) + } +} + private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -129,6 +224,9 @@ fun DebugSettingsSheet( val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() val context = LocalContext.current + var distributionInfo by remember { + mutableStateOf(null) + } val bleEnabled by manager.bleEnabled.collectAsState() val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() @@ -217,6 +315,14 @@ fun DebugSettingsSheet( } } + LaunchedEffect(isPresented) { + if (isPresented) { + distributionInfo = withContext(Dispatchers.IO) { + runCatching { DistributionInfoProvider.inspect(context) }.getOrNull() + } + } + } + val scope = rememberCoroutineScope() if (!isPresented) return @@ -246,6 +352,9 @@ fun DebugSettingsSheet( color = colorScheme.onSurface.copy(alpha = 0.7f) ) } + item { + DistributionInfoSection(distributionInfo) + } // Verbose logging toggle item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt new file mode 100644 index 00000000..b8285321 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -0,0 +1,161 @@ +package com.bitchat.android.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.bitchat.android.R + +/** + * WorkManager worker that downloads the universal APK in the background. + * Survives app backgrounding and process death. Transient network errors are + * retried with backoff; partial downloads resume via HTTP Range requests. + * + * Runs as foreground (dataSync) work when possible so slow transfers (e.g. + * over Tor) are not killed by WorkManager's background execution window. + */ +class ApkDownloadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + companion object { + const val TAG = "ApkDownloadWorker" + const val WORK_NAME = "apk_download" + + // Progress keys + const val KEY_PROGRESS = "progress" + const val KEY_VERSION = "version" + const val KEY_SIZE_MB = "size_mb" + const val KEY_ERROR = "error" + const val KEY_RESUMABLE_PERCENT = "resumable_percent" + + private const val MAX_RETRIES = 3 + + private const val CHANNEL_ID = "apk_download" + private const val NOTIFICATION_ID = 4201 + private const val NOTIFY_STEP_PERCENT = 5 + } + + private val apkManager = UniversalApkManager(applicationContext) + private val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting APK download work") + + // Promote to foreground so long transfers aren't stopped by the + // ~10-minute background execution window. Android 12+ can reject the + // promotion when the app is backgrounded — continue as regular + // background work and rely on Range-resume in that case. + try { + setForeground(createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0)) + } catch (e: Exception) { + Log.w(TAG, "Could not promote download to foreground work", e) + } + + val result = apkManager.downloadUniversalApk { progress -> + setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build()) + updateNotification(progress) + } + + return if (result.isSuccess) { + val info = apkManager.getCachedApkInfo() + val outputData = Data.Builder() + .putString(KEY_VERSION, info?.version ?: "") + .putInt(KEY_SIZE_MB, ((info?.size ?: 0L) / 1024 / 1024).toInt()) + .build() + Result.success(outputData) + } else { + val error = result.exceptionOrNull() + + // Retry transient network errors with backoff; the partial file + // is kept on disk, so the retry resumes where it left off. + val isRetryable = when (error) { + is GitHubReleaseClient.ReleaseFetchException -> error.retryable + is java.io.IOException -> true + else -> false + } + if (isRetryable && runAttemptCount < MAX_RETRIES) { + Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error) + return Result.retry() + } + + val partial = apkManager.getPartialDownloadProgress() + val outputData = Data.Builder() + .putString(KEY_ERROR, error?.message ?: "Download failed") + .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) + .build() + Result.failure(outputData) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo(apkManager.getPartialDownloadProgress() ?: 0) + } + + private fun createForegroundInfo(progress: Int): ForegroundInfo { + ensureChannel() + val notification = buildNotification(progress) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun buildNotification(progress: Int): android.app.Notification { + val cancelIntent = WorkManager.getInstance(applicationContext) + .createCancelPendingIntent(id) + + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(applicationContext.getString(R.string.apk_download_notification_title)) + .setSmallIcon(R.drawable.ic_notification) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(100, progress, progress <= 0) + .addAction( + android.R.drawable.ic_delete, + applicationContext.getString(android.R.string.cancel), + cancelIntent + ) + .build() + } + + private fun updateNotification(progress: Int) { + if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return + lastNotifiedProgress = progress + try { + notificationManager.notify(NOTIFICATION_ID, buildNotification(progress)) + } catch (e: Exception) { + // Missing POST_NOTIFICATIONS permission just drops the update; + // the download itself is unaffected. + Log.w(TAG, "Could not update download notification", e) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.apk_download_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt new file mode 100644 index 00000000..3bf234ae --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -0,0 +1,36 @@ +package com.bitchat.android.util + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for APK download operations. + * Abstracts the download mechanism so it can be swapped + * (e.g., WorkManager, ForegroundService, plain coroutine). + */ +interface ApkDownloader { + + /** + * Current download state as an observable flow. + */ + val downloadState: Flow + + /** + * Start or resume a download. If a partial download exists, it resumes automatically. + */ + fun startDownload() + + /** + * Cancel an in-progress download. The partial file is kept for future resume. + */ + fun cancelDownload() + + /** + * Download state reported by the downloader. + */ + sealed class DownloadState { + object Idle : DownloadState() + data class Downloading(val progressPercent: Int) : DownloadState() + data class Success(val version: String, val sizeMB: Int) : DownloadState() + data class Failed(val message: String, val resumablePercent: Int?) : DownloadState() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt new file mode 100644 index 00000000..87fe3fb7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/DistributionInfoProvider.kt @@ -0,0 +1,191 @@ +package com.bitchat.android.util + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import com.bitchat.android.BuildConfig +import java.io.File +import java.security.MessageDigest +import java.util.zip.ZipFile + +/** + * Read-only diagnostics describing how the currently running app was packaged + * and installed. These values are facts about the installed artifact, not + * settings that can be changed at runtime. + */ +object DistributionInfoProvider { + private val UNIVERSAL_RELEASE_ABIS = setOf( + "arm64-v8a", + "armeabi-v7a", + "x86_64", + "x86" + ) + + fun inspect(context: Context): DistributionInfo { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + signingFlags() + ) + val applicationInfo = context.applicationInfo + val splitApks = applicationInfo.splitSourceDirs.orEmpty() + val installerPackage = installerPackageName(context) + val certificateSha256 = signingCertificateSha256(packageInfo) + val installedApkCanBeSharedUniversally = splitApks.isEmpty() && + isUniversalApk(File(applicationInfo.sourceDir)) + + return DistributionInfo( + installSource = installSourceLabel(installerPackage), + installerPackage = installerPackage, + packageFormat = if (splitApks.isEmpty()) "Standalone APK" else "Split APK set", + architecture = architectureLabel(applicationInfo.sourceDir, splitApks), + sharingSource = if (installedApkCanBeSharedUniversally) { + "Current installed APK" + } else { + "Verified GitHub universal APK" + }, + versionName = packageInfo.versionName ?: BuildConfig.VERSION_NAME, + versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + }, + signingChannel = signingChannel(installerPackage, certificateSha256), + certificateSha256 = certificateSha256 + ) + } + + private fun installerPackageName(context: Context): String? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager + .getInstallSourceInfo(context.packageName) + .installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(context.packageName) + } + } catch (_: Exception) { + null + } + } + + private fun installSourceLabel(installerPackage: String?): String { + return when (installerPackage) { + "com.android.vending" -> "Google Play" + "com.amazon.venezia" -> "Amazon Appstore" + "org.fdroid.fdroid" -> "F-Droid" + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.permissioncontroller" -> "Android package installer" + null -> if (BuildConfig.DEBUG) "ADB / local install" else "Unknown / local install" + else -> installerPackage + } + } + + private fun architectureLabel(baseApkPath: String, splitApkPaths: Array): String { + val apkPaths = listOf(baseApkPath) + splitApkPaths + val packagedAbis = buildSet { + apkPaths.forEach { path -> + addAll(nativeAbisInApk(File(path))) + addAll(abisInSplitName(File(path).name)) + } + } + + return when { + packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) -> + "Universal (${packagedAbis.joinToString()})" + packagedAbis.size > 1 -> "Multi-ABI (${packagedAbis.joinToString()})" + packagedAbis.size == 1 -> packagedAbis.single() + splitApkPaths.isNotEmpty() -> "Device ABI (${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"})" + else -> "Universal (no native ABI payload)" + } + } + + /** + * An APK with no native payload works across ABIs. When native libraries + * are present, require every ABI produced by the release workflow. + */ + fun isUniversalApk(apk: File): Boolean { + val packagedAbis = nativeAbisInApk(apk) + return packagedAbis.isEmpty() || packagedAbis.containsAll(UNIVERSAL_RELEASE_ABIS) + } + + internal fun nativeAbisInApk(apk: File): Set { + if (!apk.isFile) return emptySet() + return try { + ZipFile(apk).use { zip -> + buildSet { + val entries = zip.entries() + while (entries.hasMoreElements()) { + val path = entries.nextElement().name + if (path.startsWith("lib/")) { + path.split('/').getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let(::add) + } + } + } + } + } catch (_: Exception) { + emptySet() + } + } + + private fun abisInSplitName(fileName: String): Set { + val normalizedName = fileName.replace('_', '-') + return Build.SUPPORTED_ABIS + .filter { abi -> normalizedName.contains(abi.replace('_', '-'), ignoreCase = true) } + .toSet() + } + + private fun signingChannel(installerPackage: String?, certificateSha256: String?): String { + if (BuildConfig.DEBUG) return "Debug" + if (installerPackage == "com.android.vending") return "Google Play" + + val pinnedGitHubCert = BuildConfig.GITHUB_RELEASE_CERT_SHA256 + .replace(":", "") + .lowercase() + .takeIf { it.matches(Regex("[a-f0-9]{64}")) } + return if (certificateSha256 != null && certificateSha256 == pinnedGitHubCert) { + "GitHub release" + } else { + "Release / unknown channel" + } + } + + private fun signingCertificateSha256(packageInfo: PackageInfo): String? { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.signingInfo?.apkContentsSigners + } else { + @Suppress("DEPRECATION") + packageInfo.signatures + } + val signature = signatures?.firstOrNull() ?: return null + return MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it) } + } + + private fun signingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + } + + data class DistributionInfo( + val installSource: String, + val installerPackage: String?, + val packageFormat: String, + val architecture: String, + val sharingSource: String, + val versionName: String, + val versionCode: Long, + val signingChannel: String, + val certificateSha256: String? + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt new file mode 100644 index 00000000..699637bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -0,0 +1,338 @@ +package com.bitchat.android.util + +import android.util.Log +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Client for fetching BitChat release information from GitHub API. + */ +object GitHubReleaseClient { + private const val TAG = "GitHubAPI" + private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" + private const val USER_AGENT = "BitChat-Android" + private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L + private const val MAX_FETCH_ATTEMPTS = 3 + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L + + private val fetchMutex = Mutex() + + @Volatile + private var cachedRelease: CachedRelease? = null + + private val client + get() = OkHttpProvider.httpClient().newBuilder() + // GitHub requests may travel through Tor, where a 15-second total + // timeout is too aggressive during circuit establishment. + .callTimeout(45, TimeUnit.SECONDS) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Fetch the latest release information from GitHub. + * Successful metadata is cached briefly so the status screen and download + * worker use the same release snapshot instead of making duplicate calls. + */ + suspend fun fetchLatestRelease(forceRefresh: Boolean = false): Result = + withContext(Dispatchers.IO) { + fetchMutex.withLock { + if (!forceRefresh) { + cachedRelease + ?.takeIf { System.currentTimeMillis() - it.fetchedAtMillis < CACHE_TTL_MILLIS } + ?.let { return@withLock Result.success(it.release) } + } + + if (!awaitSelectedNetworkRoute()) { + return@withLock Result.failure( + ReleaseFetchException( + message = "Tor is still connecting. Try again when Tor is ready.", + retryable = true + ) + ) + } + + var lastFailure: Throwable = ReleaseFetchException( + "Failed to fetch the latest release from GitHub" + ) + + repeat(MAX_FETCH_ATTEMPTS) { attempt -> + val result = fetchLatestReleaseOnce() + result.onSuccess { release -> + cachedRelease = CachedRelease(release, System.currentTimeMillis()) + return@withLock Result.success(release) + } + lastFailure = result.exceptionOrNull() ?: lastFailure + + if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) { + return@withLock Result.failure(lastFailure) + } + + delay(1_000L shl attempt) + } + + Result.failure(lastFailure) + } + } + + /** + * Wait for Tor when it is the selected route. This deliberately does not + * fall back to a direct connection because doing so would violate the + * user's Tor preference. + */ + suspend fun awaitSelectedNetworkRoute(): Boolean { + return ArtiTorManager.getInstance() + .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) + } + + private fun fetchLatestReleaseOnce(): Result { + return try { + Log.d(TAG, "Fetching latest release from GitHub API") + val request = Request.Builder() + .url(GITHUB_API_URL) + .addHeader("User-Agent", USER_AGENT) + .addHeader("Accept", "application/vnd.github+json") + .addHeader("X-GitHub-Api-Version", "2022-11-28") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val remaining = response.header("X-RateLimit-Remaining") + val resetAt = response.header("X-RateLimit-Reset") + val message = when { + response.code == 403 && remaining == "0" -> + "GitHub API rate limit exceeded. Try again after reset time $resetAt." + response.code == 429 -> + "GitHub API rate limit exceeded. Please try again later." + else -> + "GitHub release request failed: HTTP ${response.code} ${response.message}" + } + Log.e(TAG, message) + return Result.failure( + ReleaseFetchException( + message = message, + httpCode = response.code, + retryable = response.code == 403 || + response.code == 408 || + response.code == 429 || + response.code >= 500 + ) + ) + } + + val body = response.body?.string() + if (body.isNullOrBlank()) { + return Result.failure( + ReleaseFetchException( + message = "GitHub returned an empty response", + retryable = true + ) + ) + } + + val release = parseRelease(body) + ?: return Result.failure( + ReleaseFetchException( + message = "GitHub's latest release has no universal APK asset", + retryable = false + ) + ) + Result.success(release) + } + } catch (e: IOException) { + Log.e(TAG, "Network error fetching release", e) + Result.failure( + ReleaseFetchException( + "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}", + cause = e + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error fetching release", e) + Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e)) + } + } + + private fun isRetryable(error: Throwable): Boolean { + return error !is ReleaseFetchException || error.retryable + } + + /** + * Parse GitHub API JSON response into Release object. + */ + internal fun parseRelease(jsonString: String): Release? { + try { + val json = JSONObject(jsonString) + val tagName = json.optString("tag_name", "") + val versionName = tagName.removePrefix("v") // Remove "v" prefix if present + + if (versionName.isBlank()) { + Log.e(TAG, "No version tag found in release") + return null + } + + Log.d(TAG, "Found release: $versionName") + + // Parse assets array to find universal APK + val assets = json.optJSONArray("assets") + if (assets == null || assets.length() == 0) { + Log.e(TAG, "No assets found in release") + return null + } + + // Look for universal APK (usually named "app-universal-release.apk") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.optString("name", "") + + if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) { + val downloadUrl = asset.optString("browser_download_url", "") + val size = asset.optLong("size", 0L) + + if (downloadUrl.isBlank()) { + Log.e(TAG, "Universal APK found but no download URL") + continue + } + + // Prefer GitHub's asset digest when available, then fall + // back to release notes used by older releases. + val body = json.optString("body", "") + val assetDigest = asset.optString("digest", "") + .takeIf { it.startsWith("sha256:", ignoreCase = true) } + ?.substringAfter(":") + ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) } + ?.lowercase() + val sha256 = assetDigest ?: extractSha256FromBody(body, name) + + Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)") + + return Release( + tagName = tagName, + versionName = versionName, + universalApkUrl = downloadUrl, + universalApkSha256 = sha256, + universalApkSize = size, + universalApkName = name + ) + } + } + + Log.e(TAG, "No universal APK found in release assets") + return null + + } catch (e: Exception) { + Log.e(TAG, "Error parsing release JSON", e) + return null + } + } + + /** + * Extract SHA256 checksum from release body/notes. + * Looks for patterns like: + * - sha256:abc123... + * - SHA256: abc123... + * - app-universal-release.apk: abc123... + */ + private fun extractSha256FromBody(body: String, apkName: String): String? { + if (body.isBlank()) return null + + try { + // Pattern 1: Look for "sha256:" followed by hash + val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) + sha256Pattern.find(body)?.let { match -> + return match.groupValues[1].lowercase() + } + + // Pattern 2: Look for APK name followed by hash + val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) + apkPattern.find(body)?.let { match -> + return match.groupValues[1].lowercase() + } + + Log.w(TAG, "Could not extract SHA256 from release body") + return null + + } catch (e: Exception) { + Log.w(TAG, "Error extracting SHA256", e) + return null + } + } + + /** + * Check if a newer version is available. + * @param currentVersion Current installed/cached version + * @param latestRelease Latest release from GitHub + * @return true if latestRelease is newer + */ + fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean { + return isNewerVersion(currentVersion, latestRelease.versionName) + } + + internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean { + return try { + // Simple version comparison (assumes semantic versioning) + // Remove any non-numeric prefixes + val current = currentVersion.removePrefix("v").trim() + val latest = candidateVersion.removePrefix("v").trim() + + if (current == latest) { + return false + } + + // Split by dots and compare each part + val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } + val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(currentParts.size, latestParts.size) + + for (i in 0 until maxLength) { + val currentPart = currentParts.getOrNull(i) ?: 0 + val latestPart = latestParts.getOrNull(i) ?: 0 + + if (latestPart > currentPart) { + return true + } else if (latestPart < currentPart) { + return false + } + } + + false + } catch (e: Exception) { + Log.e(TAG, "Error comparing versions", e) + false + } + } + + /** + * Release information from GitHub. + */ + data class Release( + val tagName: String, + val versionName: String, + val universalApkUrl: String, + val universalApkSha256: String?, + val universalApkSize: Long, + val universalApkName: String + ) + + class ReleaseFetchException( + message: String, + val httpCode: Int? = null, + val retryable: Boolean = true, + cause: Throwable? = null + ) : IOException(message, cause) + + private data class CachedRelease( + val release: Release, + val fetchedAtMillis: Long + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt new file mode 100644 index 00000000..ab1e97f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -0,0 +1,825 @@ +package com.bitchat.android.util + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import com.bitchat.android.BuildConfig +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Manages downloading, caching, and verifying the universal APK for offline sharing. + */ +class UniversalApkManager(private val context: Context) { + + companion object { + private const val TAG = "UniversalApk" + private const val CACHE_DIR_NAME = "universal_apk" + private const val METADATA_FILE_NAME = "universal_apk_info.json" + private const val PROGRESS_FILE_NAME = "download_progress.json" + private const val APK_FILE_PREFIX = "bitchat-universal-" + + // Download buffer size (128KB) + private const val BUFFER_SIZE = 128 * 1024 + } + + private val cacheDir: File + get() = File(context.cacheDir, CACHE_DIR_NAME).also { it.mkdirs() } + + private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) + private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME) + + // Download client: inherits Tor proxy settings but with no call timeout + // for large file downloads that can take minutes + private val downloadClient + get() = OkHttpProvider.httpClient().newBuilder() + .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .build() + + /** + * Get information about the cached universal APK, if it exists. + */ + fun getCachedApkInfo(): ApkInfo? { + return try { + if (!metadataFile.exists()) { + return null + } + + val json = JSONObject(metadataFile.readText()) + val version = json.optString("version", "") + val checksum = json.optString("checksum", "") + val downloadDate = json.optLong("downloadDate", 0L) + val size = json.optLong("size", 0L) + val fileName = json.optString("fileName", "") + val source = runCatching { + ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name)) + }.getOrDefault(ApkSource.GITHUB) + + if (version.isBlank() || fileName.isBlank()) { + return null + } + + val apkFile = File(cacheDir, fileName) + if (!apkFile.exists()) { + Log.w(TAG, "Metadata exists but APK file not found: ${apkFile.path}") + return null + } + + ApkInfo( + version = version, + checksum = checksum, + downloadDate = downloadDate, + size = size, + file = apkFile, + source = source + ) + } catch (e: Exception) { + Log.e(TAG, "Error reading cached APK info", e) + null + } + } + + /** + * Get the cached APK file, if it exists. + */ + fun getCachedApk(): File? { + return getCachedApkInfo()?.file + } + + /** + * Check if a partial (resumable) download exists. + * Returns the progress percentage (0-100) or null if no partial download. + */ + fun getPartialDownloadProgress(): Int? { + val tempFile = File(cacheDir, "download_temp.apk") + val resumeInfo = loadResumeInfo() + if (tempFile.exists() && resumeInfo != null) { + val expectedSize = resumeInfo.optLong("expectedSize", 0L) + if (expectedSize > 0) { + return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) + } + } + return null + } + + /** + * Check for updates from GitHub. + * @return UpdateStatus indicating if update is available, current version, etc. + */ + suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { + try { + // A genuinely universal standalone APK is already an installable + // sharing artifact. Architecture-specific standalone APKs and split + // installs still need the universal GitHub artifact. + val installedApkInfo = cacheInstalledApkIfPreferred() + if (installedApkInfo != null) { + return@withContext UpdateStatus.UpToDate(installedApkInfo.version) + } + + val cachedInfo = getCachedApkInfo() + val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> + return@withContext UpdateStatus.Error( + error.message ?: "Failed to fetch latest release from GitHub" + ) + } + // The GitHub release may briefly lag behind the installed version + // (upstream bumps versionName in main before tagging the release). + // An older release is still a genuine, signed, universal artifact — + // recipients with a newer install can't be downgraded by Android + // anyway — so share it rather than disabling the feature. + if (isOlderThanInstalledVersion(latestRelease.versionName)) { + Log.i( + TAG, + "GitHub universal APK ${latestRelease.versionName} is older than installed " + + "app ${installedVersionName()}; sharing it until the matching release ships" + ) + } + + if (cachedInfo == null) { + // No cached APK + return@withContext UpdateStatus.NotDownloaded(latestRelease) + } + + // Compare versions + val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease) + + if (isNewer) { + UpdateStatus.UpdateAvailable( + currentVersion = cachedInfo.version, + latestRelease = latestRelease + ) + } else { + UpdateStatus.UpToDate(cachedInfo.version) + } + + } catch (e: Exception) { + Log.e(TAG, "Error checking for update", e) + UpdateStatus.Error(e.message ?: "Unknown error") + } + } + + /** + * Check if there's enough disk space to download the APK. + * Requires 1.5x the file size for safety margin (temp + final file). + * @throws IOException if insufficient space + */ + private fun checkDiskSpace(requiredSize: Long) { + val availableSpace = cacheDir.usableSpace + val requiredWithMargin = (requiredSize * 1.5).toLong() + + if (availableSpace < requiredWithMargin) { + val requiredMB = requiredWithMargin / 1024 / 1024 + val availableMB = availableSpace / 1024 / 1024 + val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" + Log.e(TAG, error) + throw IOException(error) + } + } + + /** + * Download the universal APK from GitHub with resume support. + * @param progressCallback Called with progress percentage (0-100) + * @return Result with File on success, or error message + */ + suspend fun downloadUniversalApk( + progressCallback: ((Int) -> Unit)? = null + ): Result = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Starting universal APK download") + + // Fetch latest release info + // Reuses the short-lived release metadata cache populated by the + // status check. If this worker is running after process death, the + // client performs a retried network fetch instead. + val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> + return@withContext Result.failure(error) + } + + if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) { + return@withContext Result.failure( + IOException("Tor is still connecting. Try the download again when Tor is ready.") + ) + } + + val url = release.universalApkUrl + val expectedSize = release.universalApkSize + + Log.d(TAG, "Downloading from: $url") + Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") + + val tempFile = File(cacheDir, "download_temp.apk") + + // Check for resumable download + var existingBytes = 0L + if (tempFile.exists()) { + val resumeInfo = loadResumeInfo() + if (resumeInfo != null && + resumeInfo.optString("url") == url && + resumeInfo.optString("versionName") == release.versionName + ) { + existingBytes = tempFile.length() + Log.d(TAG, "Resuming download from $existingBytes bytes") + } else { + Log.d(TAG, "Stale temp file found, starting fresh") + tempFile.delete() + progressFile.delete() + } + } + + // Bytes already in the temp file have already consumed storage, so + // a resume only needs room for the remaining tail. Promotion is a + // rename and needs no extra space. + checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0)) + + // A temp file that already holds the full asset means the process + // died between download and verification. Requesting + // "Range: bytes=-" for it would get HTTP 416 forever, so skip + // the network and let checksum/signature verification decide its fate. + if (expectedSize > 0 && existingBytes >= expectedSize) { + Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification") + } else { + val requestBuilder = Request.Builder() + .url(url) + .addHeader("User-Agent", "BitChat-Android") + + if (existingBytes > 0) { + requestBuilder.addHeader("Range", "bytes=$existingBytes-") + Log.d(TAG, "Added Range header: bytes=$existingBytes-") + } + + val request = requestBuilder.build() + downloadToTempFile( + call = downloadClient.newCall(request), + tempFile = tempFile, + url = url, + expectedSize = expectedSize, + versionName = release.versionName, + existingBytes = existingBytes, + progressCallback = progressCallback + ) + } + + // Verify checksum if available + if (release.universalApkSha256 != null) { + Log.d(TAG, "Verifying checksum...") + val isValid = verifyChecksum(tempFile, release.universalApkSha256) + if (!isValid) { + tempFile.delete() + progressFile.delete() + return@withContext Result.failure( + Exception("Checksum verification failed. Downloaded file may be corrupted.") + ) + } + Log.d(TAG, "Checksum verified successfully") + } else { + Log.w(TAG, "No checksum available for verification") + } + + // Verify the downloaded APK against trusted signing certificates. + Log.d(TAG, "Verifying APK signature...") + if (!verifyApkSignature(tempFile)) { + tempFile.delete() + progressFile.delete() + return@withContext Result.failure( + Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.") + ) + } + Log.d(TAG, "Signature verified successfully") + + if (!DistributionInfoProvider.isUniversalApk(tempFile)) { + tempFile.delete() + progressFile.delete() + return@withContext Result.failure( + Exception( + "GitHub asset is architecture-specific, not universal. " + + "Release packaging must be corrected." + ) + ) + } + + // Move to final location without deleting the currently usable APK + // first. Old versions are removed only after the replacement and + // metadata have both been committed. + val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk" + val finalFile = File(cacheDir, finalFileName) + replaceFileSafely(tempFile, finalFile) + + // Clean up resume metadata on success + progressFile.delete() + + // Save metadata + saveMetadata( + version = release.versionName, + checksum = release.universalApkSha256 ?: "", + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.GITHUB + ) + cleanupOldApks(except = finalFile) + + Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") + Result.success(finalFile) + + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + Log.e(TAG, "Network error downloading APK", e) + Result.failure(e) + } catch (e: Exception) { + Log.e(TAG, "Error downloading APK", e) + Result.failure(e) + } + } + + /** + * Streams an HTTP response into [tempFile] while keeping the coroutine + * suspended for the lifetime of the response body. Cancelling the worker + * therefore cancels the OkHttp call and promptly unblocks a pending read. + */ + private suspend fun downloadToTempFile( + call: Call, + tempFile: File, + url: String, + expectedSize: Long, + versionName: String, + existingBytes: Long, + progressCallback: ((Int) -> Unit)? + ) = suspendCancellableCoroutine { continuation -> + fun completeSuccessfully() { + continuation.resumeWith(Result.success(Unit)) + } + + fun completeWithError(error: Throwable) { + continuation.resumeWith(Result.failure(error)) + } + + continuation.invokeOnCancellation { + call.cancel() + } + + try { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + completeWithError(e) + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + if (response.code == 416) { + // Our offset is no longer valid for this asset; discard + // the partial state so the retry starts from scratch. + Log.w(TAG, "Server rejected resume range, restarting download") + tempFile.delete() + progressFile.delete() + throw IOException( + "Resume rejected by server. Download will restart." + ) + } + if (!response.isSuccessful && response.code != 206) { + throw IOException( + "Download failed: ${response.code} ${response.message}" + ) + } + + val body = response.body + ?: throw IOException("Empty response body") + + // Handle resume: 206 = partial content (append), 200 = full + // content (overwrite). + val append = response.code == 206 + val resumedBytes = if (!append && existingBytes > 0) { + Log.d( + TAG, + "Server didn't honor Range request, starting from scratch" + ) + 0L + } else { + existingBytes + } + + saveResumeInfo(url, expectedSize, versionName) + + if (resumedBytes > 0 && expectedSize > 0) { + val initialProgress = + ((resumedBytes * 100) / expectedSize).toInt() + progressCallback?.invoke(initialProgress) + } + + body.byteStream().use { input -> + FileOutputStream(tempFile, append).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = resumedBytes + var lastProgress = if (expectedSize > 0) { + ((resumedBytes * 100) / expectedSize).toInt() + } else { + 0 + } + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + if (expectedSize > 0) { + val progress = + ((totalBytesRead * 100) / expectedSize).toInt() + if (progress != lastProgress) { + lastProgress = progress + progressCallback?.invoke(progress) + } + } + } + + Log.d( + TAG, + "Download complete: ${totalBytesRead / 1024 / 1024}MB" + ) + } + } + } + completeSuccessfully() + } catch (e: Exception) { + completeWithError(e) + } + } + }) + } catch (e: Exception) { + completeWithError(e) + } + } + + /** + * Cache the APK this process was installed from only when it is both + * standalone and universal. A base APK from a split install is incomplete, + * while an ABI-specific APK would unnecessarily limit recipients. + */ + private fun cacheInstalledApkIfPreferred(): ApkInfo? { + return try { + val applicationInfo = context.applicationInfo + if (!applicationInfo.splitSourceDirs.isNullOrEmpty()) { + return null + } + + val installedApk = File(applicationInfo.sourceDir) + if (!installedApk.isFile || installedApk.length() <= 0L) { + return null + } + if (!DistributionInfoProvider.isUniversalApk(installedApk)) { + Log.d(TAG, "Installed APK is architecture-specific; using GitHub universal APK") + discardArchitectureLimitedInstalledCache() + return null + } + + val installedVersion = installedVersionName() + val cachedInfo = getCachedApkInfo() + + // Keep an already cached artifact if it is the same version or + // newer. Otherwise prefer the running build so sharing cannot + // silently downgrade recipients to an older GitHub release. + if (cachedInfo != null && + !GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion) + ) { + return cachedInfo + } + + checkDiskSpace(installedApk.length()) + val safeVersion = installedVersion.replace(Regex("[^A-Za-z0-9._-]"), "_") + val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" + val finalFile = File(cacheDir, finalFileName) + val pendingFile = File(cacheDir, "$finalFileName.new") + + installedApk.inputStream().use { input -> + FileOutputStream(pendingFile).use { output -> + input.copyTo(output, BUFFER_SIZE) + } + } + replaceFileSafely(pendingFile, finalFile) + + val checksum = calculateChecksum(finalFile) + saveMetadata( + version = installedVersion, + checksum = checksum, + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.INSTALLED + ) + cleanupOldApks(except = finalFile) + + Log.d(TAG, "Cached running standalone APK for offline sharing") + getCachedApkInfo() + } catch (e: Exception) { + Log.w(TAG, "Running APK cannot be used as a standalone sharing artifact", e) + null + } + } + + private fun discardArchitectureLimitedInstalledCache() { + val cachedInfo = getCachedApkInfo() ?: return + if (cachedInfo.source != ApkSource.INSTALLED || + DistributionInfoProvider.isUniversalApk(cachedInfo.file) + ) { + return + } + + cachedInfo.file.delete() + metadataFile.delete() + Log.d(TAG, "Removed architecture-specific installed APK from universal sharing cache") + } + + private fun installedVersionName(): String { + return context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName + ?.takeIf { it.isNotBlank() } + ?: BuildConfig.VERSION_NAME + } + + private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean { + return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName()) + } + + /** + * Verify the downloaded APK against either the running app's signing lineage + * or the pinned GitHub release certificate. The latter supports Play installs + * when GitHub distribution uses a separate, explicitly trusted release key. + * Debug builds without a configured pin accept any signed (never unsigned) APK. + */ + private fun verifyApkSignature(apkFile: File): Boolean { + return try { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, signingFlags()) + ?: run { + Log.e(TAG, "Could not parse APK for signature verification") + return false + } + val apkCerts = signatureDigests(packageInfo) + if (apkCerts.isEmpty()) { + Log.e(TAG, "No signatures found in downloaded APK") + return false + } + + val ownCerts = signatureDigests( + context.packageManager.getPackageInfo(context.packageName, signingFlags()) + ) + val pinnedReleaseCert = normalizeCertificateDigest( + BuildConfig.GITHUB_RELEASE_CERT_SHA256 + ) + val trustedCerts = ownCerts + listOfNotNull(pinnedReleaseCert) + + // Debug builds may use a different local signing key, but still + // require the downloaded artifact itself to be signed. Production + // builds must match either this installation's signing lineage or + // the explicitly pinned GitHub release certificate. + if (BuildConfig.DEBUG && pinnedReleaseCert == null) { + Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK") + return true + } + + if (trustedCerts.isEmpty()) { + Log.e(TAG, "No trusted APK signing certificates are configured") + return false + } + + val matches = apkCerts.intersect(trustedCerts).isNotEmpty() + if (!matches) { + Log.e(TAG, "Signature mismatch!") + Log.e(TAG, "Trusted cert(s): $trustedCerts") + Log.e(TAG, "APK cert(s): $apkCerts") + } + matches + } catch (e: Exception) { + Log.e(TAG, "Error verifying APK signature", e) + false + } + } + + private fun signingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + } + + private fun signatureDigests(packageInfo: android.content.pm.PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = packageInfo.signingInfo ?: return emptySet() + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + packageInfo.signatures + } + if (signatures.isNullOrEmpty()) return emptySet() + + val digest = MessageDigest.getInstance("SHA-256") + return signatures.map { sig -> + digest.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) } + }.toSet() + } + + private fun normalizeCertificateDigest(value: String): String? { + return value + .replace(":", "") + .trim() + .lowercase() + .takeIf { it.matches(Regex("[a-f0-9]{64}")) } + } + + /** + * Verify the SHA256 checksum of a file. + */ + suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) { + try { + val checksum = calculateChecksum(file) + val matches = checksum.equals(expectedSha256, ignoreCase = true) + + if (!matches) { + Log.e(TAG, "Checksum mismatch!") + Log.e(TAG, "Expected: $expectedSha256") + Log.e(TAG, "Actual: $checksum") + } + + matches + } catch (e: Exception) { + Log.e(TAG, "Error verifying checksum", e) + false + } + } + + private fun calculateChecksum(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Delete the cached universal APK. + */ + fun deleteCachedApk(): Boolean { + return try { + val info = getCachedApkInfo() + if (info != null) { + info.file.delete() + metadataFile.delete() + progressFile.delete() + Log.d(TAG, "Deleted cached APK: ${info.version}") + true + } else { + Log.w(TAG, "No cached APK to delete") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error deleting cached APK", e) + false + } + } + + /** + * Clean up old APK files (keep only the current one). + */ + private fun cleanupOldApks(except: File) { + try { + cacheDir.listFiles()?.forEach { file -> + if (file != except && + file.name.startsWith(APK_FILE_PREFIX) && + file.name.endsWith(".apk") + ) { + file.delete() + Log.d(TAG, "Cleaned up old APK: ${file.name}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error cleaning up old APKs", e) + } + } + + /** + * Save metadata about the downloaded APK. + */ + private fun saveMetadata( + version: String, + checksum: String, + size: Long, + fileName: String, + source: ApkSource + ) { + val json = JSONObject().apply { + put("version", version) + put("checksum", checksum) + put("downloadDate", System.currentTimeMillis()) + put("size", size) + put("fileName", fileName) + put("source", source.name) + } + + val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new") + pendingMetadata.writeText(json.toString()) + replaceFileSafely(pendingMetadata, metadataFile) + Log.d(TAG, "Saved metadata: $version") + } + + private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) { + try { + val json = JSONObject().apply { + put("url", url) + put("expectedSize", expectedSize) + put("versionName", versionName) + } + progressFile.writeText(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "Error saving resume info", e) + } + } + + private fun loadResumeInfo(): JSONObject? { + return try { + if (progressFile.exists()) { + JSONObject(progressFile.readText()) + } else null + } catch (e: Exception) { + Log.e(TAG, "Error loading resume info", e) + null + } + } + + /** + * Commit [source] to [target] without removing a valid target first. + * Both files live in the same cache directory, so this is a rename, not a + * copy — no extra disk space is needed and ATOMIC_MOVE either fully + * succeeds or leaves both files intact. + */ + private fun replaceFileSafely(source: File, target: File) { + try { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + /** + * Information about a cached APK. + */ + data class ApkInfo( + val version: String, + val checksum: String, + val downloadDate: Long, + val size: Long, + val file: File, + val source: ApkSource + ) + + enum class ApkSource { + INSTALLED, + GITHUB + } + + /** + * Update check status. + */ + sealed class UpdateStatus { + data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus() + data class UpToDate(val currentVersion: String) : UpdateStatus() + data class UpdateAvailable( + val currentVersion: String, + val latestRelease: GitHubReleaseClient.Release + ) : UpdateStatus() + data class Error(val message: String) : UpdateStatus() + } +} diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt new file mode 100644 index 00000000..feb31172 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -0,0 +1,93 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.work.Constraints +import androidx.work.BackoffPolicy +import com.bitchat.android.R +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.concurrent.TimeUnit + +/** + * WorkManager-backed implementation of [ApkDownloader]. + * Downloads survive app backgrounding, process death, and device reboots. + */ +class WorkManagerApkDownloader(context: Context) : ApkDownloader { + + private val appContext = context.applicationContext + private val workManager = WorkManager.getInstance(appContext) + private val apkManager = UniversalApkManager(appContext) + + override val downloadState: Flow = + workManager.getWorkInfosForUniqueWorkFlow(ApkDownloadWorker.WORK_NAME) + .map { workInfos -> mapWorkInfoToState(workInfos.firstOrNull()) } + + override fun startDownload() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15, + TimeUnit.SECONDS + ) + .addTag(ApkDownloadWorker.TAG) + .build() + + workManager.enqueueUniqueWork( + ApkDownloadWorker.WORK_NAME, + ExistingWorkPolicy.KEEP, + request + ) + } + + override fun cancelDownload() { + workManager.cancelUniqueWork(ApkDownloadWorker.WORK_NAME) + } + + private fun mapWorkInfoToState(workInfo: WorkInfo?): ApkDownloader.DownloadState { + if (workInfo == null) return ApkDownloader.DownloadState.Idle + + return when (workInfo.state) { + WorkInfo.State.ENQUEUED, + WorkInfo.State.BLOCKED -> { + // Waiting for constraints (network). Show existing partial progress if any. + val partial = apkManager.getPartialDownloadProgress() + ApkDownloader.DownloadState.Downloading(partial ?: 0) + } + WorkInfo.State.RUNNING -> { + val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0) + ApkDownloader.DownloadState.Downloading(progress) + } + WorkInfo.State.SUCCEEDED -> { + val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: "" + val sizeMB = workInfo.outputData.getInt(ApkDownloadWorker.KEY_SIZE_MB, 0) + ApkDownloader.DownloadState.Success(version, sizeMB) + } + WorkInfo.State.FAILED -> { + val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed" + val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1) + ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null) + } + WorkInfo.State.CANCELLED -> { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkDownloader.DownloadState.Failed( + appContext.getString(R.string.prepare_apk_download_cancelled), + partial + ) + } else { + ApkDownloader.DownloadState.Idle + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8086f0a..bd316e59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,71 @@ Privacy Protected cancel + + Share BitChat + Share installation file for offline distribution + Share BitChat App + This will share the BitChat installation file(s) so others can install the app without internet access. Perfect for mesh network expansion! + The receiver will need to:\n• Enable \"Install from unknown sources\" in Android settings\n• Uninstall BitChat first if already installed (signatures differ) + Share App + Share BitChat via… + Failed to prepare app for sharing. Please try again. + + + Prepare App for Sharing + App Ready for Offline Sharing + Download universal APK for offline sharing + Not ready • Tap to download + Ready to share + Sharing source: this installed APK + Sharing source: verified GitHub universal APK + Downloading… %1$d%% + Update available + Prepare + Update + Delete + Version %1$s • %2$d MB + Download Universal APK? + This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once. + The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading. + Download + Downloading Universal APK + Downloading %1$d MB… + Verifying checksum… + Universal APK ready! + Network error. Check your connection. + Checksum verification failed. Please try again. + Not enough storage space. + Failed to fetch release info from GitHub. + Delete cached APK? + This will free up ~%1$d MB of storage. + Update Available + A newer version (%1$s) is available. Current: %2$s + Please prepare the app for sharing first. + Download interrupted + Download cancelled + Downloading universal APK + APK downloads + + + Share via Hotspot + Create Wi-Fi hotspot to share offline + Share via Quick Share + Use standard Android sharing + + + Install Received APK + Install BitChat from received files + Install BitChat Update + Install BitChat from the received APK file(s)? This allows offline app distribution in mesh networks. + Permission Required + BitChat needs permission to install packages for self-distribution. Please enable \"Install unknown apps\" in the next screen. + Install + Grant Permission + Select APK Files + Failed to install APK. Please try again. + No APK files selected. + Warning Location Services @@ -449,6 +514,8 @@ Join Cancel Tor not available in this build + Checking... + APK not ready. Please prepare it first. diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 725040b7..d85ee1d1 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -6,4 +6,8 @@ + + diff --git a/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt new file mode 100644 index 00000000..c30054fa --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DistributionInfoProviderTest.kt @@ -0,0 +1,57 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(RobolectricTestRunner::class) +class DistributionInfoProviderTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `arm64-only APK is not universal`() { + val apk = createApk("lib/arm64-v8a/libbitchat.so") + + assertFalse(DistributionInfoProvider.isUniversalApk(apk)) + } + + @Test + fun `APK containing every release ABI is universal`() { + val apk = createApk( + "lib/arm64-v8a/libbitchat.so", + "lib/armeabi-v7a/libbitchat.so", + "lib/x86_64/libbitchat.so", + "lib/x86/libbitchat.so" + ) + + assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + } + + @Test + fun `APK without native libraries is architecture independent`() { + val apk = createApk("classes.dex") + + assertTrue(DistributionInfoProvider.isUniversalApk(apk)) + } + + private fun createApk(vararg entries: String): File { + val apk = temporaryFolder.newFile("test-${System.nanoTime()}.apk") + ZipOutputStream(apk.outputStream()).use { zip -> + entries.forEach { path -> + zip.putNextEntry(ZipEntry(path)) + zip.write(byteArrayOf(1)) + zip.closeEntry() + } + } + return apk + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt new file mode 100644 index 00000000..e51990d9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GitHubReleaseClientTest { + + @Test + fun `parses universal apk and GitHub asset digest`() { + val digest = "a".repeat(64) + val release = GitHubReleaseClient.parseRelease( + """ + { + "tag_name": "v1.7.6", + "body": "", + "assets": [ + { + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://example.test/bitchat.apk", + "size": 49283072, + "digest": "sha256:$digest" + } + ] + } + """.trimIndent() + ) + + requireNotNull(release) + assertEquals("1.7.6", release.versionName) + assertEquals(49_283_072L, release.universalApkSize) + assertEquals(digest, release.universalApkSha256) + } + + @Test + fun `falls back to checksum in release notes`() { + val digest = "b".repeat(64) + val release = GitHubReleaseClient.parseRelease( + """ + { + "tag_name": "1.7.6", + "body": "bitchat-android-universal.apk: $digest", + "assets": [ + { + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://example.test/bitchat.apk", + "size": 10 + } + ] + } + """.trimIndent() + ) + + assertEquals(digest, requireNotNull(release).universalApkSha256) + } + + @Test + fun `rejects releases without a universal apk`() { + val release = GitHubReleaseClient.parseRelease( + """ + { + "tag_name": "v1.7.6", + "assets": [ + { + "name": "bitchat-android-arm64.apk", + "browser_download_url": "https://example.test/arm64.apk", + "size": 10 + } + ] + } + """.trimIndent() + ) + + assertNull(release) + } + + @Test + fun `compares release versions`() { + val release = GitHubReleaseClient.Release( + tagName = "v1.7.6", + versionName = "1.7.6", + universalApkUrl = "https://example.test/bitchat.apk", + universalApkSha256 = null, + universalApkSize = 10, + universalApkName = "bitchat-android-universal.apk" + ) + + assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release)) + assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release)) + assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release)) + assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5")) + assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4")) + } +} diff --git a/gradle.properties b/gradle.properties index c5ca74c1..e3461bd0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,13 @@ android.nonTransitiveRClass=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +# Public SHA-256 fingerprint of the certificate used by the existing GitHub +# universal APK releases. This is not a secret; it lets the app reject an APK +# signed by an unexpected publisher. +BITCHAT_GITHUB_RELEASE_CERT_SHA256=3b03fa66a5451321100792f5b55a7b4966d5c8dc10c6daa40aa95ea489531bca + # JVM heap size configuration to prevent OutOfMemoryError -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4a8ad015..95e83d07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,12 @@ tor-android-binary = "0.4.4.6" # Google Play Services gms-location = "21.4.0" +# WorkManager +work-runtime = "2.10.1" + +# NanoHTTPD (hotspot APK sharing) +nanohttpd = "2.3.1" + # Security security-crypto = "1.1.0" @@ -111,6 +117,12 @@ tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref # Google Play Services gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" } +# WorkManager +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work-runtime" } + +# NanoHTTPD +nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" } + # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }