From 2a11d52d3c0c19391ab5b45444959dd6216f18fb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Dec 2025 06:06:53 +0000 Subject: [PATCH 01/24] Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 549 ++++++++++++++------------- 1 file changed, 282 insertions(+), 267 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 2c07fe69..2288010a 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,270 +1,285 @@ Relay URL,Latitude,Longitude -relay02.lnfi.network,39.0997,-94.5786 -srtrelay.c-stellar.net,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -relay.uid.ovh,43.6532,-79.3832 -relay.cypherflow.ai,48.8566,2.35222 -wot.tealeaf.dev,33.7488,-84.3877 -relay.divine.video,43.6532,-79.3832 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.camalolo.com,24.1469,120.684 -wot.sudocarlos.com,51.5072,-0.127586 -nostr.vulpem.com,49.4543,11.0746 -relay2.angor.io,48.1046,11.6002 -relay.nostr.nisshiee.org,37.3387,-121.885 -wot.nostr.party,36.1627,-86.7816 -nostr.luisschwab.net,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -strfry.felixzieger.de,50.1013,8.62643 -relay.agora.social,50.7383,15.0648 -nostr.n7ekb.net,47.4941,-122.294 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.stream.labs.h3.se,59.4016,17.9455 -nostr.azzamo.net,52.2633,21.0283 -relay.nostrhub.tech,49.0291,8.35696 -relay.21e6.cz,50.7383,15.0648 -nostr-02.czas.top,51.2277,6.77346 -nostr.czas.top,50.1109,8.68213 -wot.nostr.place,32.7767,-96.797 -strfry.shock.network,39.0438,-77.4874 -relay.orangepill.ovh,49.1689,-0.358841 -relay.illuminodes.com,47.6061,-122.333 -bcast.girino.org,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.coincrowd.fund,39.0438,-77.4874 -relay.damus.io,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3676,4.90414 -nostr.88mph.life,60.1699,24.9384 -relay.sharegap.net,43.6532,-79.3832 -purplerelay.com,50.1109,8.68213 -ribo.us.nostria.app,41.5868,-93.625 -nostr.snowbla.de,60.1699,24.9384 -relay.internationalright-wing.org,-22.5986,-48.8003 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.red5d.dev,43.6532,-79.3832 -orangepiller.org,60.1699,24.9384 -relay.nosto.re,51.1792,5.89444 -relay04.lnfi.network,39.0997,-94.5786 -nostr.now,36.55,139.733 -relay.binaryrobot.com,43.6532,-79.3832 -premium.primal.net,43.6532,-79.3832 -relay.davidebtc.me,51.5072,-0.127586 -purpura.cloud,43.6532,-79.3832 -nostr.mikoshi.de,50.1109,8.68213 -nostr.simplex.icu,51.5121,-0.0005238 -articles.layer3.news,37.3387,-121.885 -nostr.spicyz.io,43.6532,-79.3832 -nostrcheck.me,43.6532,-79.3832 -relay.holzeis.me,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay.nostr-check.me,43.6532,-79.3832 -nproxy.kristapsk.lv,60.1699,24.9384 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostrelay.memory-art.xyz,43.6532,-79.3832 -orangesync.tech,50.1109,8.68213 -nostr.data.haus,50.4754,12.3683 -vitor.nostr1.com,40.7057,-74.0136 -nostr.overmind.lol,43.6532,-79.3832 -nostr-relay.online,43.6532,-79.3832 -relay.bitcoindistrict.org,43.6532,-79.3832 -relay.javi.space,43.4633,11.8796 -fanfares.nostr1.com,40.7057,-74.0136 -temp.iris.to,43.6532,-79.3832 -relay.guggero.org,47.3769,8.54169 -relay.degmods.com,50.4754,12.3683 -relay.nostr.net,43.6532,-79.3832 -relay.wolfcoil.com,35.6092,139.73 -relay.sigit.io,50.4754,12.3683 -shu04.shugur.net,25.2604,55.2989 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.kalf.org,52.3676,4.90414 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.rblb.it,43.7094,10.6582 -nostr.ovia.to,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay.agorist.space,52.3734,4.89406 -relay.jeffg.fyi,43.6532,-79.3832 -relay.ditto.pub,43.6532,-79.3832 -nostr.huszonegy.world,47.4979,19.0402 -nostr.coincards.com,53.5501,-113.469 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -nostream.breadslice.com,1.35208,103.82 -ribo.eu.nostria.app,52.3676,4.90414 -nostr.oxtr.dev,50.4754,12.3683 -nostr.jerrynya.fun,31.2304,121.474 -black.nostrcity.club,48.8575,2.35138 -wheat.happytavern.co,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.zone667.com,60.1699,24.9384 -theoutpost.life,64.1476,-21.9392 -nostrelay.circum.space,52.3676,4.90414 -relay.origin.land,35.6673,139.751 -relay.lumina.rocks,49.0291,8.35695 -relay.chorus.community,50.1109,8.68213 -kotukonostr.onrender.com,37.7775,-122.397 -nostrcheck.tnsor.network,43.6532,-79.3832 -relay01.lnfi.network,39.0997,-94.5786 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.nsnip.io,60.1699,24.9384 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -relay.ru.ac.th,13.7607,100.627 -relay.thibautduchene.fr,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -wot.dtonon.com,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -wot.brightbolt.net,47.6735,-116.781 -nostr.bond,50.1109,8.68213 -relay.vrtmrz.net,43.6532,-79.3832 -dev-relay.lnfi.network,39.0997,-94.5786 -wot.sebastix.social,51.1792,5.89444 -nostr.hifish.org,47.4043,8.57398 -divine.diy,43.6532,-79.3832 -bitcoiner.social,39.1585,-94.5728 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.snort.social,53.3498,-6.26031 -shu05.shugur.net,48.8566,2.35222 -relay.nostrcheck.me,43.6532,-79.3832 -relay.0xchat.com,1.35208,103.82 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.getsafebox.app,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -relay.primal.net,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -nostr.tuckerbradford.com,43.663,-70.2569 -nostr.rikmeijer.nl,50.4754,12.3683 -nostr.agentcampfire.com,52.3676,4.90414 -shu02.shugur.net,21.4902,39.2246 -librerelay.aaroniumii.com,43.6532,-79.3832 -fenrir-s.notoshi.win,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -ribo.af.nostria.app,-26.2056,28.0337 -relay.bitcoinartclock.com,50.4754,12.3683 -relay.nostrdice.com,-33.8688,151.209 -strfry.elswa-dev.online,50.1109,8.68213 -nostr.21crypto.ch,47.5356,8.73209 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.lightning.pub,39.0438,-77.4874 -nostr-02.yakihonne.com,1.32123,103.695 -relay.mccormick.cx,52.3563,4.95714 -ynostr.yael.at,60.1699,24.9384 -nostr.faultables.net,43.6532,-79.3832 -prl.plus,56.9677,24.1056 -santo.iguanatech.net,40.8302,-74.1299 -nostr.bitcoiner.social,39.1585,-94.5728 -nostr-relay.corb.net,38.8353,-104.822 -relay.letsfo.com,51.098,17.0321 -nostr.calitabby.net,39.9268,-75.0246 -strfry.bonsai.com,37.8715,-122.273 -wot.nostr.net,43.6532,-79.3832 -bcast.seutoba.com.br,43.6532,-79.3832 -wot.sovbit.host,64.1466,-21.9426 -relay2.ngengine.org,43.6532,-79.3832 -r.bitcoinhold.net,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 -freeben666.fr,43.7221,7.15296 -relay.notoshi.win,13.311,101.112 -yabu.me,35.6092,139.73 -dev-nostr.bityacht.io,25.0797,121.234 -nostr.commonshub.brussels,49.4543,11.0746 -relay.nostx.io,43.6532,-79.3832 -relay.nostr.band,60.1699,24.9384 -relay.barine.co,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -satsage.xyz,37.3986,-121.964 -relay.nostr.wirednet.jp,34.706,135.493 -relay.artx.market,43.652,-79.3633 -nostr2.girino.org,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.satlantis.io,32.8769,-80.0114 -nostr.zoracle.org,45.6018,-121.185 -nostr.bilthon.dev,25.8128,-80.2377 -relay.minibolt.info,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -offchain.pub,47.6743,-117.112 -shu01.shugur.net,21.4902,39.2246 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.noones.com,50.1109,8.68213 -relay.nostriot.com,41.5695,-83.9786 -nostr-relay.zimage.com,34.0549,-118.243 -relay.siamdev.cc,13.9178,100.424 -nostr.4rs.nl,49.0291,8.35696 -relay.coinos.io,43.6532,-79.3832 -khatru.nostrver.se,51.1792,5.89444 -x.kojira.io,43.6532,-79.3832 -wot.yesnostr.net,50.9871,2.12554 -nostr.casa21.space,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -relay.mattybs.lol,43.6532,-79.3832 -nostr-01.yakihonne.com,1.32123,103.695 -nostr.tac.lol,47.4748,-122.273 -relay.nuts.cash,34.0362,-118.443 -relay.cosmicbolt.net,37.3986,-121.964 -nostr.diakod.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -relay.fr13nd5.com,52.5233,13.3426 -relay.trustroots.org,43.6532,-79.3832 -relay.thebluepulse.com,49.4521,11.0767 -relay.wavefunc.live,34.0362,-118.443 -relay.wavlake.com,41.2619,-95.8608 -relay.libernet.app,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.fountain.fm,39.0997,-94.5786 -relay.hook.cafe,43.6532,-79.3832 -notemine.io,52.2026,20.9397 -relay.electriclifestyle.com,26.2897,-80.1293 -inbox.azzamo.net,52.2633,21.0283 -nostr.hekster.org,37.3986,-121.964 -relay.routstr.com,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -wot.dergigi.com,64.1476,-21.9392 -nostr-03.dorafactory.org,1.35208,103.82 -relay.fundstr.me,42.3601,-71.0589 -relayone.soundhsa.com,33.1384,-95.6011 -nostr.plantroon.com,50.1013,8.62643 -relay.ngengine.org,43.6532,-79.3832 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.angor.io,48.1046,11.6002 -relay.toastr.net,40.8054,-74.0241 -relay.nostrhub.fr,48.1045,11.6004 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.bitesize-media.com,49.4543,11.0746 -relay.usefusion.ai,38.7134,-78.1591 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -nostr.ps1829.com,33.8851,130.883 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.openhoofd.nl,51.9229,4.40833 -nostr.blankfors.se,60.1699,24.9384 -strfry.openhoofd.nl,51.9229,4.40833 -relay.magiccity.live,25.8128,-80.2377 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -slick.mjex.me,39.048,-77.4817 -nostr.davidebtc.me,51.5072,-0.127586 alien.macneilmediagroup.com,43.6532,-79.3832 -pyramid.treegaze.com,43.6532,-79.3832 -relay03.lnfi.network,39.0997,-94.5786 -relay.evanverma.com,40.8302,-74.1299 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -no.str.cr,9.92857,-84.0528 -relay.jabato.space,52.52,13.405 -adre.su,59.9311,30.3609 -relay.hasenpfeffr.com,39.0438,-77.4874 -relay.etch.social,41.2619,-95.8608 -schnorr.me,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.nostr.place,32.7767,-96.797 -nostr.sathoarder.com,48.5734,7.75211 -orly.ft.hn,50.4754,12.3683 -relay.samt.st,40.8302,-74.1299 +nostr.simplex.icu,51.5121,-0.0005238 +nostr.satstralia.com,64.1476,-21.9392 nostr-relay.amethyst.name,39.0438,-77.4874 +bcast.girino.org,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +dev-relay.lnfi.network,39.0997,-94.5786 +temp.iris.to,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5986,-48.8003 +relay.bitcoindistrict.org,43.6532,-79.3832 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +bucket.coracle.social,37.7775,-122.397 +relay.minibolt.info,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +cyberspace.nostr1.com,40.7128,-74.006 +relay.jeffg.fyi,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +relay.stream.labs.h3.se,59.4016,17.9455 +nostr.rikmeijer.nl,50.4754,12.3683 +relay.getsafebox.app,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +relay.hasenpfeffr.com,39.0438,-77.4874 +wot.dtonon.com,43.6532,-79.3832 +nostrcheck.me,43.6532,-79.3832 +strfry.bonsai.com,37.8715,-122.273 +relay.degmods.com,50.4754,12.3683 +relay.nostx.io,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.lightning.pub,39.0438,-77.4874 +nostr.jerrynya.fun,31.2304,121.474 +satsage.xyz,37.3986,-121.964 +relay-rpi.edufeed.org,49.4521,11.0767 +nostr.bond,50.1109,8.68213 +relay.lumina.rocks,49.0291,8.35695 +relay.samt.st,40.8302,-74.1299 +nostr.snowbla.de,60.1699,24.9384 +nostr.red5d.dev,43.6532,-79.3832 +nostr.tadryanom.me,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.calitabby.net,39.9268,-75.0246 +relay.hook.cafe,43.6532,-79.3832 +relay.21e6.cz,50.7383,15.0648 +nostr-relay.online,43.6532,-79.3832 +inbox.azzamo.net,52.2633,21.0283 +relay.chakany.systems,43.6532,-79.3832 +relay.coinos.io,43.6532,-79.3832 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.siamdev.cc,13.9178,100.424 +relay04.lnfi.network,39.0997,-94.5786 +wot.sovbit.host,64.1466,-21.9426 +bcast.seutoba.com.br,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 +wot.nostr.place,32.7767,-96.797 +nos.xmark.cc,50.6924,3.20113 +nostr-relay.corb.net,38.8353,-104.822 +relay5.bitransfer.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relay.fountain.fm,39.0997,-94.5786 +santo.iguanatech.net,40.8302,-74.1299 +bitcoiner.social,39.1585,-94.5728 +relay.nostr.place,32.7767,-96.797 +relay.fr13nd5.com,52.5233,13.3426 +nostr.myshosholoza.co.za,52.3676,4.90414 +nostr.kalf.org,52.3676,4.90414 +nostr.chaima.info,51.223,6.78245 +relay.usefusion.ai,38.7134,-78.1591 +nostr.faultables.net,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +nostr.4rs.nl,49.0291,8.35696 +adre.su,59.9311,30.3609 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +r.bitcoinhold.net,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +relay.cosmicbolt.net,37.3986,-121.964 +freelay.sovbit.host,64.1476,-21.9392 +relay.npubhaus.com,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +nostr.agentcampfire.com,52.3676,4.90414 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +strfry.felixzieger.de,50.1013,8.62643 +orly.ft.hn,50.4754,12.3683 +nostr.hifish.org,47.4043,8.57398 +relayone.soundhsa.com,33.1384,-95.6011 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.trustroots.org,43.6532,-79.3832 +soloco.nl,43.6532,-79.3832 +nostr.mikoshi.de,47.8786,11.911 +relay.holzeis.me,43.6532,-79.3832 +wot.sebastix.social,51.1792,5.89444 +nostr.azzamo.net,52.2633,21.0283 +wot.shaving.kiwi,43.6532,-79.3832 +nostr.night7.space,50.4754,12.3683 +nproxy.kristapsk.lv,60.1699,24.9384 +freeben666.fr,43.7221,7.15296 +relay.libernet.app,43.6532,-79.3832 +nostr.88mph.life,51.5072,-0.127586 +wot.tealeaf.dev,33.7488,-84.3877 +nostr.sathoarder.com,48.5734,7.75211 +relay.0xchat.com,1.35208,103.82 +relay.fundstr.me,42.3601,-71.0589 +nostr.lkjsxc.com,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.jabato.space,52.52,13.405 +nostr.davidebtc.me,51.5072,-0.127586 +relay.evanverma.com,40.8302,-74.1299 +wot.dergigi.com,64.1476,-21.9392 +relay.seq1.net,43.6532,-79.3832 +wot.yesnostr.net,50.9871,2.12554 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr.rblb.it,43.7094,10.6582 +ribo.us.nostria.app,41.5868,-93.625 +relay.nostar.org,43.6532,-79.3832 +purplerelay.com,50.1109,8.68213 +nostr-01.yakihonne.com,1.32123,103.695 +relay.jmoose.rocks,60.1699,24.9384 +bitsat.molonlabe.holdings,51.4012,-1.3147 +nos.lol,50.4754,12.3683 +shu02.shugur.net,21.4902,39.2246 +fenrir-s.notoshi.win,43.6532,-79.3832 +relay.puresignal.news,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +relay.etch.social,41.2619,-95.8608 +nostr.camalolo.com,24.1469,120.684 +nostr-2.21crypto.ch,47.5356,8.73209 +nostrelay.memory-art.xyz,43.6532,-79.3832 +relay.upleb.uk,51.9194,19.1451 +relay.sharegap.net,43.6532,-79.3832 +relay.2nix.de,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +relay.mattybs.lol,43.6532,-79.3832 +relayb.uid.ovh,43.6532,-79.3832 +nostr.hekster.org,37.3986,-121.964 +shu04.shugur.net,25.2604,55.2989 +nostrelay.circum.space,52.3676,4.90414 +nostr.tavux.tech,48.8575,2.35138 +nostr.thebiglake.org,32.71,-96.6745 +khatru.nostrver.se,51.1792,5.89444 +relay.damus.io,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay2.angor.io,48.1046,11.6002 +nostr.spicyz.io,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +nostr.now,36.55,139.733 +shu01.shugur.net,21.4902,39.2246 +spatia-arcana.com,34.0362,-118.443 +nos4smartnkind.tech,40.1872,44.5152 +nostr-relay.xbytez.io,50.6924,3.20113 +wheat.happytavern.co,43.6532,-79.3832 +relay.notoshi.win,13.311,101.112 +theoutpost.life,64.1476,-21.9392 +nostr.tuckerbradford.com,43.663,-70.2569 +nostrcheck.tnsor.network,43.6532,-79.3832 +nostr.coincards.com,53.5501,-113.469 +relay.cypherflow.ai,48.8566,2.35222 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.satlantis.io,32.8769,-80.0114 +relay.agora.social,50.7383,15.0648 +notemine.io,52.2026,20.9397 +relay.nostr.band,60.1699,24.9384 +relay.orangepill.ovh,49.1689,-0.358841 +strfry.shock.network,39.0438,-77.4874 +relay.dwadziesciajeden.pl,52.2297,21.0122 +articles.layer3.news,37.3387,-121.885 +nostr.czas.top,50.1109,8.68213 +relay.olas.app,50.4754,12.3683 +orangepiller.org,60.1699,24.9384 +nostr-02.yakihonne.com,1.32123,103.695 +relay.thebluepulse.com,49.4521,11.0767 +relay.binaryrobot.com,43.6532,-79.3832 +prl.plus,56.9677,24.1056 +strfry.openhoofd.nl,51.9229,4.40833 +nostr.girino.org,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr-02.czas.top,51.2277,6.77346 +nostrelites.org,41.8781,-87.6298 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr-relay.zimage.com,34.0549,-118.243 +wot.soundhsa.com,33.1384,-95.6011 +relay.letsfo.com,51.098,17.0321 +relay.vrtmrz.net,43.6532,-79.3832 +ribo.eu.nostria.app,52.3676,4.90414 +relay.nostr.wirednet.jp,34.706,135.493 +relay.snort.social,53.3498,-6.26031 +nostr.huszonegy.world,47.4979,19.0402 +nostream.breadslice.com,1.35208,103.82 +relay.credenso.cafe,43.3601,-80.3127 +relay.tagayasu.xyz,43.6715,-79.38 +nostr.stakey.net,52.3676,4.90414 +relay.artx.market,43.652,-79.3633 +relay.sigit.io,50.4754,12.3683 +librerelay.aaroniumii.com,43.6532,-79.3832 +nostr.noones.com,50.1109,8.68213 +pyramid.treegaze.com,43.6532,-79.3832 +relay.crostr.com,43.6532,-79.3832 +relay03.lnfi.network,39.0997,-94.5786 +no.str.cr,9.92857,-84.0528 +relay.nostrhub.tech,49.0291,8.35696 +nostr.overmind.lol,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +relay.chorus.community,50.1109,8.68213 +relay.zone667.com,60.1699,24.9384 +relay01.lnfi.network,39.0997,-94.5786 +nostr.ps1829.com,33.8851,130.883 +offchain.pub,47.6743,-117.112 +nostr.plantroon.com,50.1013,8.62643 +relay.nostr-check.me,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +black.nostrcity.club,48.8575,2.35138 +relay.uid.ovh,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.nostriot.com,41.5695,-83.9786 +wot.brightbolt.net,47.6735,-116.781 +relay.toastr.net,40.8054,-74.0241 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.zoracle.org,45.6018,-121.185 +relay.wolfcoil.com,35.6092,139.73 +premium.primal.net,43.6532,-79.3832 +nostr.roundrockbitcoiners.com,40.8054,-74.0241 +relay.thibautduchene.fr,43.6532,-79.3832 +relay.illuminodes.com,47.6061,-122.333 +relay.ngengine.org,43.6532,-79.3832 +dev-nostr.bityacht.io,25.0797,121.234 +wot.sudocarlos.com,51.5072,-0.127586 +relay.islandbitcoin.com,12.8498,77.6545 +nostr.blankfors.se,60.1699,24.9384 +relay2.ngengine.org,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +alienos.libretechsystems.xyz,55.4724,9.87335 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.divine.video,43.6532,-79.3832 +nostr.tac.lol,47.4748,-122.273 +relay.zombi.cloudrodion.com,48.8566,2.35222 +relay.mostro.network,40.8302,-74.1299 +relay.arx-ccn.com,50.4754,12.3683 +relay.davidebtc.me,51.5072,-0.127586 +wot.codingarena.top,50.4754,12.3683 +relay.bitcoinartclock.com,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +relay.nostrdice.com,-33.8688,151.209 +wot.nostr.net,43.6532,-79.3832 +relay02.lnfi.network,39.0997,-94.5786 +relayrs.notoshi.win,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +nostr.openhoofd.nl,51.9229,4.40833 +relay.wavefunc.live,34.0362,-118.443 +nostr.coincrowd.fund,39.0438,-77.4874 +relay.mccormick.cx,52.3563,4.95714 +slick.mjex.me,39.048,-77.4817 +relay.nostrcheck.me,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +relay.primal.net,43.6532,-79.3832 +vitor.nostr1.com,40.7057,-74.0136 +relay.bitcoinveneto.org,64.1466,-21.9426 +public.crostr.com,43.6532,-79.3832 +nostr.ovia.to,43.6532,-79.3832 +ithurtswhenip.ee,51.223,6.78245 +relay.origin.land,35.6673,139.751 +relay.nostrzh.org,43.6532,-79.3832 +orangesync.tech,50.1109,8.68213 +nostr.bitcoiner.social,39.1585,-94.5728 +relay.javi.space,43.4633,11.8796 +relay.electriclifestyle.com,26.2897,-80.1293 +x.kojira.io,43.6532,-79.3832 +relay.endfiat.money,43.6532,-79.3832 +relay.magiccity.live,25.8128,-80.2377 +chat-relay.zap-work.com,43.6532,-79.3832 +nostr-03.dorafactory.org,1.35208,103.82 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.routstr.com,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +nostr.diakod.com,43.6532,-79.3832 +relay.guggero.org,47.3769,8.54169 +nostr.0x7e.xyz,47.4988,8.72369 +yabu.me,35.6092,139.73 +relay.ru.ac.th,13.7607,100.627 +relay.moinsen.com,50.4754,12.3683 +relay.nuts.cash,34.0362,-118.443 +relay.ditto.pub,43.6532,-79.3832 From 743bbd0b1c1edc6d7728eb0b89eaae7334202ebf Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:55:41 +0700 Subject: [PATCH 02/24] bump targetSdk (#526) --- app/lint-baseline.xml | 11 ----------- gradle/libs.versions.toml | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index cd316d72..4bc0bb5c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -243,17 +243,6 @@ column="35"/> - - - - Date: Sun, 21 Dec 2025 06:06:56 +0000 Subject: [PATCH 03/24] Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 548 +++++++++++++-------------- 1 file changed, 273 insertions(+), 275 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 2288010a..03e8c247 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,285 +1,283 @@ Relay URL,Latitude,Longitude -alien.macneilmediagroup.com,43.6532,-79.3832 -nostr.simplex.icu,51.5121,-0.0005238 -nostr.satstralia.com,64.1476,-21.9392 -nostr-relay.amethyst.name,39.0438,-77.4874 -bcast.girino.org,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -wot.nostr.party,36.1627,-86.7816 -dev-relay.lnfi.network,39.0997,-94.5786 -temp.iris.to,43.6532,-79.3832 -relay.internationalright-wing.org,-22.5986,-48.8003 -relay.bitcoindistrict.org,43.6532,-79.3832 -srtrelay.c-stellar.net,43.6532,-79.3832 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -bucket.coracle.social,37.7775,-122.397 -relay.minibolt.info,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -relay.jeffg.fyi,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -relay.stream.labs.h3.se,59.4016,17.9455 -nostr.rikmeijer.nl,50.4754,12.3683 -relay.getsafebox.app,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -relay.hasenpfeffr.com,39.0438,-77.4874 -wot.dtonon.com,43.6532,-79.3832 -nostrcheck.me,43.6532,-79.3832 -strfry.bonsai.com,37.8715,-122.273 -relay.degmods.com,50.4754,12.3683 -relay.nostx.io,43.6532,-79.3832 -strfry.elswa-dev.online,50.1109,8.68213 -relay.lightning.pub,39.0438,-77.4874 -nostr.jerrynya.fun,31.2304,121.474 -satsage.xyz,37.3986,-121.964 -relay-rpi.edufeed.org,49.4521,11.0767 -nostr.bond,50.1109,8.68213 -relay.lumina.rocks,49.0291,8.35695 -relay.samt.st,40.8302,-74.1299 -nostr.snowbla.de,60.1699,24.9384 -nostr.red5d.dev,43.6532,-79.3832 -nostr.tadryanom.me,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.calitabby.net,39.9268,-75.0246 -relay.hook.cafe,43.6532,-79.3832 -relay.21e6.cz,50.7383,15.0648 -nostr-relay.online,43.6532,-79.3832 -inbox.azzamo.net,52.2633,21.0283 -relay.chakany.systems,43.6532,-79.3832 -relay.coinos.io,43.6532,-79.3832 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.siamdev.cc,13.9178,100.424 -relay04.lnfi.network,39.0997,-94.5786 -wot.sovbit.host,64.1466,-21.9426 -bcast.seutoba.com.br,43.6532,-79.3832 -shu05.shugur.net,48.8566,2.35222 -wot.nostr.place,32.7767,-96.797 -nos.xmark.cc,50.6924,3.20113 -nostr-relay.corb.net,38.8353,-104.822 -relay5.bitransfer.org,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay.fountain.fm,39.0997,-94.5786 -santo.iguanatech.net,40.8302,-74.1299 -bitcoiner.social,39.1585,-94.5728 -relay.nostr.place,32.7767,-96.797 -relay.fr13nd5.com,52.5233,13.3426 -nostr.myshosholoza.co.za,52.3676,4.90414 -nostr.kalf.org,52.3676,4.90414 -nostr.chaima.info,51.223,6.78245 -relay.usefusion.ai,38.7134,-78.1591 -nostr.faultables.net,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -nostr.4rs.nl,49.0291,8.35696 -adre.su,59.9311,30.3609 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -r.bitcoinhold.net,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -relay.cosmicbolt.net,37.3986,-121.964 -freelay.sovbit.host,64.1476,-21.9392 -relay.npubhaus.com,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -nostr.agentcampfire.com,52.3676,4.90414 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -strfry.felixzieger.de,50.1013,8.62643 -orly.ft.hn,50.4754,12.3683 -nostr.hifish.org,47.4043,8.57398 -relayone.soundhsa.com,33.1384,-95.6011 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.trustroots.org,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -nostr.mikoshi.de,47.8786,11.911 -relay.holzeis.me,43.6532,-79.3832 -wot.sebastix.social,51.1792,5.89444 -nostr.azzamo.net,52.2633,21.0283 -wot.shaving.kiwi,43.6532,-79.3832 -nostr.night7.space,50.4754,12.3683 -nproxy.kristapsk.lv,60.1699,24.9384 -freeben666.fr,43.7221,7.15296 -relay.libernet.app,43.6532,-79.3832 -nostr.88mph.life,51.5072,-0.127586 -wot.tealeaf.dev,33.7488,-84.3877 -nostr.sathoarder.com,48.5734,7.75211 -relay.0xchat.com,1.35208,103.82 -relay.fundstr.me,42.3601,-71.0589 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.jabato.space,52.52,13.405 -nostr.davidebtc.me,51.5072,-0.127586 -relay.evanverma.com,40.8302,-74.1299 -wot.dergigi.com,64.1476,-21.9392 -relay.seq1.net,43.6532,-79.3832 -wot.yesnostr.net,50.9871,2.12554 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostr.rblb.it,43.7094,10.6582 -ribo.us.nostria.app,41.5868,-93.625 -relay.nostar.org,43.6532,-79.3832 -purplerelay.com,50.1109,8.68213 -nostr-01.yakihonne.com,1.32123,103.695 -relay.jmoose.rocks,60.1699,24.9384 -bitsat.molonlabe.holdings,51.4012,-1.3147 -nos.lol,50.4754,12.3683 -shu02.shugur.net,21.4902,39.2246 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -nostr.vulpem.com,49.4543,11.0746 -relay.etch.social,41.2619,-95.8608 -nostr.camalolo.com,24.1469,120.684 -nostr-2.21crypto.ch,47.5356,8.73209 -nostrelay.memory-art.xyz,43.6532,-79.3832 -relay.upleb.uk,51.9194,19.1451 -relay.sharegap.net,43.6532,-79.3832 -relay.2nix.de,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -relay.mattybs.lol,43.6532,-79.3832 -relayb.uid.ovh,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -shu04.shugur.net,25.2604,55.2989 -nostrelay.circum.space,52.3676,4.90414 -nostr.tavux.tech,48.8575,2.35138 -nostr.thebiglake.org,32.71,-96.6745 -khatru.nostrver.se,51.1792,5.89444 -relay.damus.io,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -relay2.angor.io,48.1046,11.6002 -nostr.spicyz.io,43.6532,-79.3832 -nostr.bilthon.dev,25.8128,-80.2377 -nostr.now,36.55,139.733 -shu01.shugur.net,21.4902,39.2246 -spatia-arcana.com,34.0362,-118.443 -nos4smartnkind.tech,40.1872,44.5152 -nostr-relay.xbytez.io,50.6924,3.20113 -wheat.happytavern.co,43.6532,-79.3832 -relay.notoshi.win,13.311,101.112 -theoutpost.life,64.1476,-21.9392 -nostr.tuckerbradford.com,43.663,-70.2569 -nostrcheck.tnsor.network,43.6532,-79.3832 -nostr.coincards.com,53.5501,-113.469 -relay.cypherflow.ai,48.8566,2.35222 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.satlantis.io,32.8769,-80.0114 -relay.agora.social,50.7383,15.0648 -notemine.io,52.2026,20.9397 -relay.nostr.band,60.1699,24.9384 -relay.orangepill.ovh,49.1689,-0.358841 -strfry.shock.network,39.0438,-77.4874 -relay.dwadziesciajeden.pl,52.2297,21.0122 -articles.layer3.news,37.3387,-121.885 -nostr.czas.top,50.1109,8.68213 -relay.olas.app,50.4754,12.3683 -orangepiller.org,60.1699,24.9384 -nostr-02.yakihonne.com,1.32123,103.695 -relay.thebluepulse.com,49.4521,11.0767 -relay.binaryrobot.com,43.6532,-79.3832 -prl.plus,56.9677,24.1056 -strfry.openhoofd.nl,51.9229,4.40833 -nostr.girino.org,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -nostr-02.czas.top,51.2277,6.77346 -nostrelites.org,41.8781,-87.6298 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr-relay.zimage.com,34.0549,-118.243 -wot.soundhsa.com,33.1384,-95.6011 -relay.letsfo.com,51.098,17.0321 -relay.vrtmrz.net,43.6532,-79.3832 -ribo.eu.nostria.app,52.3676,4.90414 -relay.nostr.wirednet.jp,34.706,135.493 -relay.snort.social,53.3498,-6.26031 -nostr.huszonegy.world,47.4979,19.0402 -nostream.breadslice.com,1.35208,103.82 -relay.credenso.cafe,43.3601,-80.3127 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.stakey.net,52.3676,4.90414 -relay.artx.market,43.652,-79.3633 -relay.sigit.io,50.4754,12.3683 -librerelay.aaroniumii.com,43.6532,-79.3832 -nostr.noones.com,50.1109,8.68213 -pyramid.treegaze.com,43.6532,-79.3832 -relay.crostr.com,43.6532,-79.3832 -relay03.lnfi.network,39.0997,-94.5786 -no.str.cr,9.92857,-84.0528 -relay.nostrhub.tech,49.0291,8.35696 -nostr.overmind.lol,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -relay.chorus.community,50.1109,8.68213 -relay.zone667.com,60.1699,24.9384 -relay01.lnfi.network,39.0997,-94.5786 -nostr.ps1829.com,33.8851,130.883 -offchain.pub,47.6743,-117.112 -nostr.plantroon.com,50.1013,8.62643 -relay.nostr-check.me,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -black.nostrcity.club,48.8575,2.35138 -relay.uid.ovh,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 relay.nostriot.com,41.5695,-83.9786 -wot.brightbolt.net,47.6735,-116.781 -relay.toastr.net,40.8054,-74.0241 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr.zoracle.org,45.6018,-121.185 -relay.wolfcoil.com,35.6092,139.73 -premium.primal.net,43.6532,-79.3832 -nostr.roundrockbitcoiners.com,40.8054,-74.0241 -relay.thibautduchene.fr,43.6532,-79.3832 relay.illuminodes.com,47.6061,-122.333 -relay.ngengine.org,43.6532,-79.3832 -dev-nostr.bityacht.io,25.0797,121.234 -wot.sudocarlos.com,51.5072,-0.127586 -relay.islandbitcoin.com,12.8498,77.6545 -nostr.blankfors.se,60.1699,24.9384 -relay2.ngengine.org,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.divine.video,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -relay.zombi.cloudrodion.com,48.8566,2.35222 -relay.mostro.network,40.8302,-74.1299 -relay.arx-ccn.com,50.4754,12.3683 -relay.davidebtc.me,51.5072,-0.127586 -wot.codingarena.top,50.4754,12.3683 -relay.bitcoinartclock.com,50.4754,12.3683 -nostr.nodesmap.com,59.3327,18.0656 -relay.nostrdice.com,-33.8688,151.209 -wot.nostr.net,43.6532,-79.3832 -relay02.lnfi.network,39.0997,-94.5786 -relayrs.notoshi.win,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -nostr.openhoofd.nl,51.9229,4.40833 -relay.wavefunc.live,34.0362,-118.443 -nostr.coincrowd.fund,39.0438,-77.4874 -relay.mccormick.cx,52.3563,4.95714 -slick.mjex.me,39.048,-77.4817 -relay.nostrcheck.me,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.primal.net,43.6532,-79.3832 -vitor.nostr1.com,40.7057,-74.0136 -relay.bitcoinveneto.org,64.1466,-21.9426 -public.crostr.com,43.6532,-79.3832 -nostr.ovia.to,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 +relay.hook.cafe,43.6532,-79.3832 +relay.controller-ai.com,52.3563,4.95714 +relay.hasenpfeffr.com,39.0438,-77.4874 +nostr-relay.zimage.com,34.0549,-118.243 +relay.mattybs.lol,43.6532,-79.3832 +nostr.rblb.it,43.7094,10.6582 relay.origin.land,35.6673,139.751 -relay.nostrzh.org,43.6532,-79.3832 -orangesync.tech,50.1109,8.68213 -nostr.bitcoiner.social,39.1585,-94.5728 -relay.javi.space,43.4633,11.8796 -relay.electriclifestyle.com,26.2897,-80.1293 +nostr2.girino.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +nostr.agentcampfire.com,52.3676,4.90414 +relay.olas.app,50.4754,12.3683 +soloco.nl,43.6532,-79.3832 +relay.usefusion.ai,38.7134,-78.1591 +nostr.lkjsxc.com,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +relay.getsafebox.app,43.6532,-79.3832 +nostr.nodesmap.com,59.3327,18.0656 +nostr.88mph.life,51.5072,-0.127586 +relay04.lnfi.network,39.0997,-94.5786 +relay.uid.ovh,43.6532,-79.3832 +nostr.coincards.com,53.5501,-113.469 +srtrelay.c-stellar.net,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +nostream.breadslice.com,1.35208,103.82 +nostr.hifish.org,47.4043,8.57398 +nostr.data.haus,50.4754,12.3683 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.superfriends.online,43.6532,-79.3832 x.kojira.io,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.magiccity.live,25.8128,-80.2377 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr-03.dorafactory.org,1.35208,103.82 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.routstr.com,43.6532,-79.3832 +nostr.czas.top,50.1109,8.68213 +relay.seq1.net,43.6532,-79.3832 nostr.luisschwab.net,43.6532,-79.3832 -nostr.diakod.com,43.6532,-79.3832 -relay.guggero.org,47.3769,8.54169 +temp.iris.to,43.6532,-79.3832 +nostr.azzamo.net,52.2633,21.0283 +orangepiller.org,60.1699,24.9384 +freeben666.fr,43.7221,7.15296 +strfry.elswa-dev.online,50.1109,8.68213 +wot.dtonon.com,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 nostr.0x7e.xyz,47.4988,8.72369 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.0xchat.com,1.35208,103.82 +nostr.kalf.org,52.3676,4.90414 +relay.cypherflow.ai,48.8566,2.35222 +chat-relay.zap-work.com,43.6532,-79.3832 +relay01.lnfi.network,39.0997,-94.5786 +nostr.commonshub.brussels,49.4543,11.0746 +nostr.rikmeijer.nl,50.4754,12.3683 +nostr.calitabby.net,39.9268,-75.0246 +relay.snort.social,53.3498,-6.26031 +nostr.sathoarder.com,48.5734,7.75211 +strfry.openhoofd.nl,51.9229,4.40833 yabu.me,35.6092,139.73 -relay.ru.ac.th,13.7607,100.627 -relay.moinsen.com,50.4754,12.3683 +relay.nostrhub.tech,49.0291,8.35696 +relay.mostro.network,40.8302,-74.1299 +bcast.seutoba.com.br,43.6532,-79.3832 +nostr.satstralia.com,64.1476,-21.9392 +nostr.21crypto.ch,47.5356,8.73209 +santo.iguanatech.net,40.8302,-74.1299 +notemine.io,52.2026,20.9397 +nostr.ps1829.com,33.8851,130.883 +relay.nostr.place,32.7767,-96.797 +alienos.libretechsystems.xyz,55.4724,9.87335 +nostr.bond,50.1109,8.68213 +relay.nostrdice.com,-33.8688,151.209 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-01.yakihonne.com,1.32123,103.695 +strfry.shock.network,39.0438,-77.4874 +relay.evanverma.com,40.8302,-74.1299 +relay.davidebtc.me,51.5072,-0.127586 +nostr-relay.online,43.6532,-79.3832 +bitsat.molonlabe.holdings,51.4012,-1.3147 +relay.angor.io,48.1046,11.6002 +nostr.openhoofd.nl,51.9229,4.40833 +relay.smies.me,33.7501,-84.3885 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.plantroon.com,50.1013,8.62643 +dev-relay.lnfi.network,39.0997,-94.5786 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr.camalolo.com,24.1469,120.684 +relay-dev.satlantis.io,40.8302,-74.1299 +relay.artx.market,43.652,-79.3633 +wot.sovbit.host,64.1466,-21.9426 +relay.fundstr.me,42.3601,-71.0589 +nostr.hekster.org,37.3986,-121.964 +relay.libernet.app,43.6532,-79.3832 +nostr.n7ekb.net,47.4941,-122.294 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr.mikoshi.de,47.74,12.0917 +relayone.soundhsa.com,33.1384,-95.6011 +relay.nosto.re,51.1792,5.89444 +relay.notoshi.win,13.311,101.112 +neuromancer.nettek.io,39.1429,-94.573 +nostr.ovia.to,43.6532,-79.3832 +relay.trustroots.org,43.6532,-79.3832 +relay.thebluepulse.com,49.4521,11.0767 +strfry.felixzieger.de,50.1013,8.62643 +relay.nostr.band,60.1699,24.9384 +relay2.angor.io,48.1046,11.6002 +relay.wavlake.com,41.2619,-95.8608 +ithurtswhenip.ee,51.223,6.78245 +shu05.shugur.net,48.8566,2.35222 +wot.shaving.kiwi,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.siamdev.cc,13.9178,100.424 +relay.ngengine.org,43.6532,-79.3832 +relay.sigit.io,50.4754,12.3683 +relay.electriclifestyle.com,26.2897,-80.1293 +relay.nostar.org,43.6532,-79.3832 +nostr.oxtr.dev,50.4754,12.3683 +nostr.vulpem.com,49.4543,11.0746 +relay02.lnfi.network,39.0997,-94.5786 +relay.upleb.uk,51.9194,19.1451 +relay.bitcoinartclock.com,50.4754,12.3683 +nostrelay.circum.space,52.3676,4.90414 +relay.camelus.app,45.5201,-122.99 +nostr-relay.cbrx.io,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +relay.lightning.pub,39.0438,-77.4874 +ribo.us.nostria.app,41.5868,-93.625 +relay.openfarmtools.org,60.1699,24.9384 +vault.iris.to,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.wolfcoil.com,35.6092,139.73 +adre.su,59.9311,30.3609 +relay.nostrzh.org,43.6532,-79.3832 +cyberspace.nostr1.com,40.7128,-74.006 +relay.divine.video,43.6532,-79.3832 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.cosmicbolt.net,37.3986,-121.964 +black.nostrcity.club,48.8575,2.35138 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.tagayasu.xyz,43.6715,-79.38 +nostr.now,36.55,139.733 +shu04.shugur.net,25.2604,55.2989 +relay.samt.st,40.8302,-74.1299 +relay03.lnfi.network,39.0997,-94.5786 +nostr-02.yakihonne.com,1.32123,103.695 +nostr.tac.lol,47.4748,-122.273 +theoutpost.life,64.1476,-21.9392 +relay.dwadziesciajeden.pl,52.2297,21.0122 +nostr.huszonegy.world,47.4979,19.0402 +relay.zombi.cloudrodion.com,48.8566,2.35222 +nr.yay.so,46.2126,6.1154 +offchain.pub,47.6743,-117.112 +relay.jeffg.fyi,43.6532,-79.3832 +relay.wavefunc.live,34.0362,-118.443 +relay.islandbitcoin.com,12.8498,77.6545 +nostr-relay.amethyst.name,39.0438,-77.4874 +inbox.azzamo.net,52.2633,21.0283 +nostr.tadryanom.me,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.tavux.tech,48.8575,2.35138 +nostr.blankfors.se,60.1699,24.9384 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +nos.xmark.cc,50.6924,3.20113 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +nostr-02.czas.top,51.2277,6.77346 +relay.puresignal.news,43.6532,-79.3832 +nostr.faultables.net,43.6532,-79.3832 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nos.lol,50.4754,12.3683 +wheat.happytavern.co,43.6532,-79.3832 +relays.diggoo.com,43.6532,-79.3832 +relay.chakany.systems,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +relay.vrtmrz.net,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +relay.damus.io,43.6532,-79.3832 +relay.npubhaus.com,43.6532,-79.3832 relay.nuts.cash,34.0362,-118.443 relay.ditto.pub,43.6532,-79.3832 +relay.agora.social,50.7383,15.0648 +librerelay.aaroniumii.com,43.6532,-79.3832 +ribo.eu.nostria.app,52.3676,4.90414 +vitor.nostr1.com,40.7057,-74.0136 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.etch.social,41.2619,-95.8608 +nostr.noones.com,50.1109,8.68213 +nostrcheck.tnsor.network,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +relay.coinos.io,43.6532,-79.3832 +relay.moinsen.com,50.4754,12.3683 +relay.letsfo.com,51.098,17.0321 +nostr-pub.wellorder.net,45.5201,-122.99 +relayone.geektank.ai,18.2148,-63.0574 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.orangepill.ovh,49.1689,-0.358841 +relay.chorus.community,50.1109,8.68213 +ynostr.yael.at,60.1699,24.9384 +relay.agorist.space,52.3734,4.89406 +khatru.nostrver.se,51.1792,5.89444 +relay.binaryrobot.com,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +relay.javi.space,43.4633,11.8796 +nostr.roundrockbitcoiners.com,40.8054,-74.0241 +relay.minibolt.info,43.6532,-79.3832 +relay.nostr.wirednet.jp,34.706,135.493 +wot.dergigi.com,64.1476,-21.9392 +nostr-03.dorafactory.org,1.35208,103.82 +bitcoiner.social,39.1585,-94.5728 +prl.plus,42.6978,23.3246 +nostr.night7.space,50.4754,12.3683 +nostr.notribe.net,40.8302,-74.1299 +no.str.cr,9.92857,-84.0528 +relay.21e6.cz,50.7383,15.0648 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +relay.endfiat.money,43.6532,-79.3832 +wot.nostr.place,32.7767,-96.797 +bcast.girino.org,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +relay.satlantis.io,32.8769,-80.0114 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +relay.nostr-check.me,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay2.ngengine.org,43.6532,-79.3832 +pyramid.treegaze.com,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +relay.fountain.fm,39.0997,-94.5786 +relay.mccormick.cx,52.3563,4.95714 +relay.routstr.com,43.6532,-79.3832 +relay.arx-ccn.com,50.4754,12.3683 +nostr.tuckerbradford.com,43.663,-70.2569 +nostr.zoracle.org,45.6018,-121.185 +nostr.jerrynya.fun,31.2304,121.474 +relay.nostr.net,43.6532,-79.3832 +relay.holzeis.me,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay.guggero.org,47.3769,8.54169 +nostr.red5d.dev,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +r.bitcoinhold.net,43.6532,-79.3832 +relay.nostrhub.fr,48.1045,11.6004 +wot.sebastix.social,51.1792,5.89444 +nostr.mom,50.4754,12.3683 +nos4smartnkind.tech,40.1872,44.5152 +nostr.casa21.space,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 +kotukonostr.onrender.com,37.7775,-122.397 +nostr-dev.wellorder.net,45.5201,-122.99 +purplerelay.com,50.1109,8.68213 +strfry.bonsai.com,37.8715,-122.273 +wot.yesnostr.net,50.9871,2.12554 +relay.lumina.rocks,49.0291,8.35695 +shu01.shugur.net,21.4902,39.2246 +nostrcheck.me,43.6532,-79.3832 +nostr-relay.corb.net,38.8353,-104.822 +dev-nostr.bityacht.io,25.0797,121.234 +nostr.simplex.icu,51.5121,-0.0005238 +relay.magiccity.live,25.8128,-80.2377 +public.crostr.com,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +relay.jabato.space,52.52,13.405 +wot.nostr.net,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +relay.toastr.net,40.8054,-74.0241 +nostrelay.memory-art.xyz,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +shu02.shugur.net,21.4902,39.2246 +nostr.davidebtc.me,51.5072,-0.127586 +premium.primal.net,43.6532,-79.3832 +nostr.chaima.info,51.223,6.78245 +nostr.snowbla.de,60.1699,24.9384 +relay.credenso.cafe,43.3601,-80.3127 +wot.soundhsa.com,33.1384,-95.6011 +nproxy.kristapsk.lv,60.1699,24.9384 +alien.macneilmediagroup.com,43.6532,-79.3832 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +nostr.bitcoiner.social,39.1585,-94.5728 +orly.ft.hn,50.4754,12.3683 +relay.threenine.services,51.5524,-0.29686 +nostr.myshosholoza.co.za,52.3676,4.90414 From ab555f25f509c0f0d9b307853e6a836ced1f09cd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 06:07:12 +0000 Subject: [PATCH 04/24] Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 564 ++++++++++++++------------- 1 file changed, 288 insertions(+), 276 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 03e8c247..ab075fb4 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,283 +1,295 @@ Relay URL,Latitude,Longitude -relay.nostriot.com,41.5695,-83.9786 -relay.illuminodes.com,47.6061,-122.333 -relay.hook.cafe,43.6532,-79.3832 -relay.controller-ai.com,52.3563,4.95714 -relay.hasenpfeffr.com,39.0438,-77.4874 -nostr-relay.zimage.com,34.0549,-118.243 -relay.mattybs.lol,43.6532,-79.3832 -nostr.rblb.it,43.7094,10.6582 -relay.origin.land,35.6673,139.751 -nostr2.girino.org,43.6532,-79.3832 -nostr-relay.nextblockvending.com,47.2343,-119.853 -nostr.agentcampfire.com,52.3676,4.90414 -relay.olas.app,50.4754,12.3683 -soloco.nl,43.6532,-79.3832 -relay.usefusion.ai,38.7134,-78.1591 -nostr.lkjsxc.com,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -relay.getsafebox.app,43.6532,-79.3832 -nostr.nodesmap.com,59.3327,18.0656 -nostr.88mph.life,51.5072,-0.127586 -relay04.lnfi.network,39.0997,-94.5786 -relay.uid.ovh,43.6532,-79.3832 -nostr.coincards.com,53.5501,-113.469 -srtrelay.c-stellar.net,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -nostream.breadslice.com,1.35208,103.82 -nostr.hifish.org,47.4043,8.57398 -nostr.data.haus,50.4754,12.3683 -relay.bullishbounty.com,43.6532,-79.3832 -nostr.superfriends.online,43.6532,-79.3832 -x.kojira.io,43.6532,-79.3832 -nostr.czas.top,50.1109,8.68213 -relay.seq1.net,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -nostr.azzamo.net,52.2633,21.0283 -orangepiller.org,60.1699,24.9384 -freeben666.fr,43.7221,7.15296 -strfry.elswa-dev.online,50.1109,8.68213 -wot.dtonon.com,43.6532,-79.3832 -relay.primal.net,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.0xchat.com,1.35208,103.82 -nostr.kalf.org,52.3676,4.90414 -relay.cypherflow.ai,48.8566,2.35222 -chat-relay.zap-work.com,43.6532,-79.3832 -relay01.lnfi.network,39.0997,-94.5786 -nostr.commonshub.brussels,49.4543,11.0746 -nostr.rikmeijer.nl,50.4754,12.3683 -nostr.calitabby.net,39.9268,-75.0246 -relay.snort.social,53.3498,-6.26031 -nostr.sathoarder.com,48.5734,7.75211 -strfry.openhoofd.nl,51.9229,4.40833 -yabu.me,35.6092,139.73 -relay.nostrhub.tech,49.0291,8.35696 -relay.mostro.network,40.8302,-74.1299 -bcast.seutoba.com.br,43.6532,-79.3832 -nostr.satstralia.com,64.1476,-21.9392 -nostr.21crypto.ch,47.5356,8.73209 -santo.iguanatech.net,40.8302,-74.1299 -notemine.io,52.2026,20.9397 -nostr.ps1829.com,33.8851,130.883 -relay.nostr.place,32.7767,-96.797 -alienos.libretechsystems.xyz,55.4724,9.87335 -nostr.bond,50.1109,8.68213 -relay.nostrdice.com,-33.8688,151.209 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr-01.yakihonne.com,1.32123,103.695 strfry.shock.network,39.0438,-77.4874 -relay.evanverma.com,40.8302,-74.1299 -relay.davidebtc.me,51.5072,-0.127586 -nostr-relay.online,43.6532,-79.3832 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.angor.io,48.1046,11.6002 -nostr.openhoofd.nl,51.9229,4.40833 -relay.smies.me,33.7501,-84.3885 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.plantroon.com,50.1013,8.62643 -dev-relay.lnfi.network,39.0997,-94.5786 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.camalolo.com,24.1469,120.684 -relay-dev.satlantis.io,40.8302,-74.1299 -relay.artx.market,43.652,-79.3633 -wot.sovbit.host,64.1466,-21.9426 -relay.fundstr.me,42.3601,-71.0589 -nostr.hekster.org,37.3986,-121.964 -relay.libernet.app,43.6532,-79.3832 -nostr.n7ekb.net,47.4941,-122.294 -relay.thibautduchene.fr,43.6532,-79.3832 -nostr.mikoshi.de,47.74,12.0917 -relayone.soundhsa.com,33.1384,-95.6011 -relay.nosto.re,51.1792,5.89444 -relay.notoshi.win,13.311,101.112 -neuromancer.nettek.io,39.1429,-94.573 -nostr.ovia.to,43.6532,-79.3832 -relay.trustroots.org,43.6532,-79.3832 -relay.thebluepulse.com,49.4521,11.0767 -strfry.felixzieger.de,50.1013,8.62643 -relay.nostr.band,60.1699,24.9384 -relay2.angor.io,48.1046,11.6002 -relay.wavlake.com,41.2619,-95.8608 -ithurtswhenip.ee,51.223,6.78245 -shu05.shugur.net,48.8566,2.35222 -wot.shaving.kiwi,43.6532,-79.3832 wot.nostr.party,36.1627,-86.7816 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.siamdev.cc,13.9178,100.424 -relay.ngengine.org,43.6532,-79.3832 -relay.sigit.io,50.4754,12.3683 -relay.electriclifestyle.com,26.2897,-80.1293 -relay.nostar.org,43.6532,-79.3832 -nostr.oxtr.dev,50.4754,12.3683 -nostr.vulpem.com,49.4543,11.0746 -relay02.lnfi.network,39.0997,-94.5786 -relay.upleb.uk,51.9194,19.1451 -relay.bitcoinartclock.com,50.4754,12.3683 -nostrelay.circum.space,52.3676,4.90414 -relay.camelus.app,45.5201,-122.99 -nostr-relay.cbrx.io,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -relay.lightning.pub,39.0438,-77.4874 -ribo.us.nostria.app,41.5868,-93.625 -relay.openfarmtools.org,60.1699,24.9384 -vault.iris.to,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -relay.wolfcoil.com,35.6092,139.73 -adre.su,59.9311,30.3609 -relay.nostrzh.org,43.6532,-79.3832 -cyberspace.nostr1.com,40.7128,-74.006 -relay.divine.video,43.6532,-79.3832 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.cosmicbolt.net,37.3986,-121.964 -black.nostrcity.club,48.8575,2.35138 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.now,36.55,139.733 -shu04.shugur.net,25.2604,55.2989 -relay.samt.st,40.8302,-74.1299 -relay03.lnfi.network,39.0997,-94.5786 -nostr-02.yakihonne.com,1.32123,103.695 -nostr.tac.lol,47.4748,-122.273 -theoutpost.life,64.1476,-21.9392 -relay.dwadziesciajeden.pl,52.2297,21.0122 -nostr.huszonegy.world,47.4979,19.0402 -relay.zombi.cloudrodion.com,48.8566,2.35222 -nr.yay.so,46.2126,6.1154 -offchain.pub,47.6743,-117.112 -relay.jeffg.fyi,43.6532,-79.3832 -relay.wavefunc.live,34.0362,-118.443 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-relay.amethyst.name,39.0438,-77.4874 -inbox.azzamo.net,52.2633,21.0283 -nostr.tadryanom.me,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.tavux.tech,48.8575,2.35138 nostr.blankfors.se,60.1699,24.9384 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -nos.xmark.cc,50.6924,3.20113 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -nostr-02.czas.top,51.2277,6.77346 -relay.puresignal.news,43.6532,-79.3832 -nostr.faultables.net,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -nos.lol,50.4754,12.3683 -wheat.happytavern.co,43.6532,-79.3832 -relays.diggoo.com,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -wot.sudocarlos.com,51.5072,-0.127586 -relay.vrtmrz.net,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.damus.io,43.6532,-79.3832 -relay.npubhaus.com,43.6532,-79.3832 -relay.nuts.cash,34.0362,-118.443 -relay.ditto.pub,43.6532,-79.3832 -relay.agora.social,50.7383,15.0648 -librerelay.aaroniumii.com,43.6532,-79.3832 -ribo.eu.nostria.app,52.3676,4.90414 -vitor.nostr1.com,40.7057,-74.0136 -nostr-2.21crypto.ch,47.5356,8.73209 -relay.etch.social,41.2619,-95.8608 -nostr.noones.com,50.1109,8.68213 -nostrcheck.tnsor.network,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.coinos.io,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -relay.letsfo.com,51.098,17.0321 -nostr-pub.wellorder.net,45.5201,-122.99 -relayone.geektank.ai,18.2148,-63.0574 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.orangepill.ovh,49.1689,-0.358841 -relay.chorus.community,50.1109,8.68213 -ynostr.yael.at,60.1699,24.9384 -relay.agorist.space,52.3734,4.89406 -khatru.nostrver.se,51.1792,5.89444 -relay.binaryrobot.com,43.6532,-79.3832 -relay.zone667.com,60.1699,24.9384 -relay.javi.space,43.4633,11.8796 -nostr.roundrockbitcoiners.com,40.8054,-74.0241 -relay.minibolt.info,43.6532,-79.3832 -relay.nostr.wirednet.jp,34.706,135.493 -wot.dergigi.com,64.1476,-21.9392 -nostr-03.dorafactory.org,1.35208,103.82 -bitcoiner.social,39.1585,-94.5728 -prl.plus,42.6978,23.3246 -nostr.night7.space,50.4754,12.3683 -nostr.notribe.net,40.8302,-74.1299 -no.str.cr,9.92857,-84.0528 -relay.21e6.cz,50.7383,15.0648 -relay.bitcoindistrict.org,43.6532,-79.3832 -nostr.bilthon.dev,25.8128,-80.2377 -relay.endfiat.money,43.6532,-79.3832 -wot.nostr.place,32.7767,-96.797 -bcast.girino.org,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay.satlantis.io,32.8769,-80.0114 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -relay.nostr-check.me,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -relay2.ngengine.org,43.6532,-79.3832 -pyramid.treegaze.com,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -relay.fountain.fm,39.0997,-94.5786 -relay.mccormick.cx,52.3563,4.95714 -relay.routstr.com,43.6532,-79.3832 -relay.arx-ccn.com,50.4754,12.3683 -nostr.tuckerbradford.com,43.663,-70.2569 -nostr.zoracle.org,45.6018,-121.185 -nostr.jerrynya.fun,31.2304,121.474 -relay.nostr.net,43.6532,-79.3832 -relay.holzeis.me,43.6532,-79.3832 -relay.wellorder.net,45.5201,-122.99 -relay.guggero.org,47.3769,8.54169 -nostr.red5d.dev,43.6532,-79.3832 -nostr-01.uid.ovh,43.6532,-79.3832 -r.bitcoinhold.net,43.6532,-79.3832 -relay.nostrhub.fr,48.1045,11.6004 -wot.sebastix.social,51.1792,5.89444 -nostr.mom,50.4754,12.3683 -nos4smartnkind.tech,40.1872,44.5152 -nostr.casa21.space,43.6532,-79.3832 -relay.nostrcheck.me,43.6532,-79.3832 -kotukonostr.onrender.com,37.7775,-122.397 -nostr-dev.wellorder.net,45.5201,-122.99 -purplerelay.com,50.1109,8.68213 -strfry.bonsai.com,37.8715,-122.273 -wot.yesnostr.net,50.9871,2.12554 -relay.lumina.rocks,49.0291,8.35695 -shu01.shugur.net,21.4902,39.2246 -nostrcheck.me,43.6532,-79.3832 -nostr-relay.corb.net,38.8353,-104.822 -dev-nostr.bityacht.io,25.0797,121.234 +nostr.now,36.55,139.733 nostr.simplex.icu,51.5121,-0.0005238 -relay.magiccity.live,25.8128,-80.2377 -public.crostr.com,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -relay.jabato.space,52.52,13.405 -wot.nostr.net,43.6532,-79.3832 -fanfares.nostr1.com,40.7057,-74.0136 -relay.toastr.net,40.8054,-74.0241 -nostrelay.memory-art.xyz,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -shu02.shugur.net,21.4902,39.2246 -nostr.davidebtc.me,51.5072,-0.127586 -premium.primal.net,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -nostr.snowbla.de,60.1699,24.9384 -relay.credenso.cafe,43.3601,-80.3127 -wot.soundhsa.com,33.1384,-95.6011 -nproxy.kristapsk.lv,60.1699,24.9384 -alien.macneilmediagroup.com,43.6532,-79.3832 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -nostr.bitcoiner.social,39.1585,-94.5728 -orly.ft.hn,50.4754,12.3683 relay.threenine.services,51.5524,-0.29686 +relay.barine.co,43.6532,-79.3832 +theoutpost.life,64.1476,-21.9392 +purplerelay.com,50.1109,8.68213 +nostrue.com,40.8054,-74.0241 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +shu02.shugur.net,21.4902,39.2246 +relayone.geektank.ai,18.2148,-63.0574 +relay03.lnfi.network,39.0997,-94.5786 +soloco.nl,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.azzamo.net,52.2633,21.0283 +nostr.red5d.dev,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +nostr.tadryanom.me,43.6532,-79.3832 +schnorr.me,43.6532,-79.3832 +ithurtswhenip.ee,51.223,6.78245 +orly.ft.hn,50.4754,12.3683 +relay02.lnfi.network,39.0997,-94.5786 +relay.nosto.re,51.1792,5.89444 +strfry.openhoofd.nl,51.9229,4.40833 +relays.diggoo.com,43.6532,-79.3832 +relay.nostrhub.tech,49.0291,8.35696 +relay.artx.market,43.652,-79.3633 +relay.sigit.io,50.4754,12.3683 +nostr-relay.psfoundation.info,39.0438,-77.4874 +wot.brightbolt.net,47.6735,-116.781 +relay.bitcoinartclock.com,50.4754,12.3683 +khatru.nostrver.se,51.1792,5.89444 +relay.cosmicbolt.net,37.3986,-121.964 +adre.su,59.9311,30.3609 +nostrelay.memory-art.xyz,43.6532,-79.3832 +temp.iris.to,43.6532,-79.3832 +nproxy.kristapsk.lv,60.1699,24.9384 +relay.ngengine.org,43.6532,-79.3832 +relay.puresignal.news,43.6532,-79.3832 +nostr.stakey.net,52.3676,4.90414 +nostr.commonshub.brussels,49.4543,11.0746 +wot.shaving.kiwi,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr-relay.corb.net,38.8353,-104.822 +wot.soundhsa.com,33.1384,-95.6011 +relay.jabato.space,52.52,13.405 +relay.credenso.cafe,43.3601,-80.3127 +relayone.soundhsa.com,33.1384,-95.6011 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.samt.st,40.8302,-74.1299 +relay.vrtmrz.net,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.npubhaus.com,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.wavefunc.live,34.0362,-118.443 +wot.sovbit.host,64.1466,-21.9426 +relay.smies.me,33.7501,-84.3885 +nostr.bilthon.dev,25.8128,-80.2377 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +santo.iguanatech.net,40.8302,-74.1299 +prl.plus,42.6978,23.3246 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostream.breadslice.com,1.35208,103.82 +nostr.notribe.net,40.8302,-74.1299 +relay.fountain.fm,39.0997,-94.5786 +orangepiller.org,60.1699,24.9384 +nostr.21crypto.ch,47.5356,8.73209 +nostr.camalolo.com,24.1469,120.684 +okn.czas.top,51.267,6.81738 +relay.wolfcoil.com,35.6092,139.73 +nostr.quali.chat,60.1699,24.9384 +nostr-relay.online,43.6532,-79.3832 +alienos.libretechsystems.xyz,55.4724,9.87335 +ribo.eu.nostria.app,52.3676,4.90414 +wot.yesnostr.net,50.9871,2.12554 +nostr.overmind.lol,43.6532,-79.3832 +relay.chakany.systems,43.6532,-79.3832 +relay.hasenpfeffr.com,39.0438,-77.4874 +nostrelites.org,41.8781,-87.6298 +strfry.bonsai.com,37.8715,-122.273 +relay.agora.social,50.7383,15.0648 +alien.macneilmediagroup.com,43.6532,-79.3832 +relay.getsafebox.app,43.6532,-79.3832 +relay.nuts.cash,34.0362,-118.443 +notemine.io,52.2026,20.9397 +relay.nostr.place,32.7767,-96.797 +relay.21e6.cz,50.7383,15.0648 +nostr.casa21.space,43.6532,-79.3832 +premium.primal.net,43.6532,-79.3832 +relay5.bitransfer.org,43.6532,-79.3832 +nostr.rblb.it,43.7094,10.6582 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.camelus.app,45.5201,-122.99 +relay.origin.land,35.6673,139.751 +relay.fr13nd5.com,52.5233,13.3426 +nos.lol,50.4754,12.3683 +wot.nostr.net,43.6532,-79.3832 +relay.javi.space,43.4633,11.8796 +relay2.ngengine.org,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +pyramid.treegaze.com,43.6532,-79.3832 +relay.letsfo.com,52.2633,21.0283 +relay.nostrzh.org,43.6532,-79.3832 +nostr.hekster.org,37.3986,-121.964 +relay.0xchat.com,1.35208,103.82 +nostr.czas.top,50.1109,8.68213 +nostr.superfriends.online,43.6532,-79.3832 +hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostr.88mph.life,51.5072,-0.127586 +strfry.ymir.cloud,34.0965,-117.585 +nostr.lkjsxc.com,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.chorus.community,50.1109,8.68213 +nostr-02.yakihonne.com,1.32123,103.695 +neuromancer.nettek.io,39.1429,-94.573 +relay04.lnfi.network,39.0997,-94.5786 +nostr-relay.amethyst.name,39.0438,-77.4874 +nostrcheck.tnsor.network,43.6532,-79.3832 +wot.nostr.place,32.7767,-96.797 +cyberspace.nostr1.com,40.7057,-74.0136 +relay.guggero.org,47.3769,8.54169 +nostr.calitabby.net,39.9268,-75.0246 +x.kojira.io,43.6532,-79.3832 +nostr.agentcampfire.com,52.3676,4.90414 +relay.bullishbounty.com,43.6532,-79.3832 +relay.nostrverse.net,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr.huszonegy.world,47.4979,19.0402 +relay.cypherflow.ai,48.8566,2.35222 +nostr-relay.xbytez.io,50.6924,3.20113 +nostr.faultables.net,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +relay.magiccity.live,25.8128,-80.2377 +relay.wellorder.net,45.5201,-122.99 +black.nostrcity.club,48.8575,2.35138 +relay.jeffg.fyi,43.6532,-79.3832 +freeben666.fr,43.7221,7.15296 +relay.nostr.wirednet.jp,34.706,135.493 +nostrelay.circum.space,52.3676,4.90414 +relay.islandbitcoin.com,12.8498,77.6545 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +relay.nostriot.com,41.5695,-83.9786 +relayrs.notoshi.win,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr.vulpem.com,49.4543,11.0746 +nostr.sathoarder.com,48.5734,7.75211 +wheat.happytavern.co,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr-02.czas.top,51.2277,6.77346 +vault.iris.to,43.6532,-79.3832 +ynostr.yael.at,60.1699,24.9384 +nostr.nodesmap.com,59.3327,18.0656 +nostr.n7ekb.net,47.4941,-122.294 +relayb.uid.ovh,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +nostr.0x7e.xyz,47.4988,8.72369 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.zone667.com,60.1699,24.9384 +nostr-dev.wellorder.net,45.5201,-122.99 +nos.xmark.cc,50.6924,3.20113 +relay.etch.social,41.2619,-95.8608 +nostr.na.social,43.6532,-79.3832 +relay.orangepill.ovh,49.1689,-0.358841 +relay.olas.app,50.4754,12.3683 +relay.holzeis.me,43.6532,-79.3832 +relay2.angor.io,48.1046,11.6002 +relay.degmods.com,50.4754,12.3683 +vitor.nostr1.com,40.7128,-74.006 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +relay.moinsen.com,50.4754,12.3683 +czas.xyz,48.8566,2.35222 +nostr.bitcoiner.social,39.1585,-94.5728 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +inbox.azzamo.net,52.2633,21.0283 +nostr.ovia.to,43.6532,-79.3832 nostr.myshosholoza.co.za,52.3676,4.90414 +fenrir-s.notoshi.win,43.6532,-79.3832 +relay-rpi.edufeed.org,49.4521,11.0767 +chat-relay.zap-work.com,43.6532,-79.3832 +nostr.bond,50.1109,8.68213 +relay01.lnfi.network,39.0997,-94.5786 +relay.seq1.net,43.6532,-79.3832 +nostr.rikmeijer.nl,50.4754,12.3683 +offchain.pub,47.6743,-117.112 +nostr.spaceshell.xyz,43.6532,-79.3832 +nos4smartnkind.tech,40.1872,44.5152 +kotukonostr.onrender.com,37.7775,-122.397 +nostr.ps1829.com,33.8851,130.883 +relay.lightning.pub,39.0438,-77.4874 +nostr-relay.gateway.in.th,15.2634,100.344 +relay.thebluepulse.com,49.4521,11.0767 +relay.malxte.de,52.52,13.405 +relay.usefusion.ai,38.7134,-78.1591 +nostr-relay.cbrx.io,43.6532,-79.3832 +shu01.shugur.net,21.4902,39.2246 +nostr.jerrynya.fun,31.2304,121.474 +dev-nostr.bityacht.io,25.0797,121.234 +bitsat.molonlabe.holdings,51.4012,-1.3147 +relay.primal.net,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +nostr-relay.nextblockvending.com,47.2343,-119.853 +v-relay.d02.vrtmrz.net,34.6937,135.502 +nostr.mikoshi.de,47.74,12.0917 +nostr.coincards.com,53.5501,-113.469 +relay.snort.social,53.3498,-6.26031 +bitcoiner.social,39.1585,-94.5728 +nostr.noones.com,50.1109,8.68213 +wot.dergigi.com,64.1476,-21.9392 +relay.lumina.rocks,49.0291,8.35695 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +strfry.felixzieger.de,50.1013,8.62643 +relay.satlantis.io,32.8769,-80.0114 +relay-dev.satlantis.io,40.8302,-74.1299 +nostr.davidebtc.me,51.5072,-0.127586 +relay.mattybs.lol,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 +relay.tagayasu.xyz,43.6715,-79.38 +nostr-relay.zimage.com,34.0549,-118.243 +nostrcheck.me,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +relay.siamdev.cc,13.9178,100.424 +r.bitcoinhold.net,43.6532,-79.3832 +skeme.vanderwarker.family,40.8218,-74.45 +articles.layer3.news,37.3387,-121.885 +no.str.cr,9.92857,-84.0528 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.comcomponent.com,34.7062,135.493 +relay.illuminodes.com,47.6061,-122.333 +nostr.openhoofd.nl,51.9229,4.40833 +nostr.plantroon.com,50.1013,8.62643 +slick.mjex.me,39.048,-77.4817 +relay.minibolt.info,43.6532,-79.3832 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +relay.nostrdice.com,-33.8688,151.209 +wot.dtonon.com,43.6532,-79.3832 +shu04.shugur.net,25.2604,55.2989 +nostr.tavux.tech,48.8575,2.35138 +nostr-02.uid.ovh,43.6532,-79.3832 +relay.nostrcheck.me,43.6532,-79.3832 +relay.nsnip.io,60.1699,24.9384 +relay.toastr.net,40.8054,-74.0241 +relay.divine.video,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +wot.sebastix.social,51.1792,5.89444 +relay.openfarmtools.org,60.1699,24.9384 +relay.damus.io,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +relay.arx-ccn.com,50.4754,12.3683 +nostr.zoracle.org,45.6018,-121.185 +nostr.tac.lol,47.4748,-122.273 +relay.coinos.io,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.davidebtc.me,51.5072,-0.127586 +ribo.us.nostria.app,41.5868,-93.625 +relay.upleb.uk,51.9194,19.1451 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.endfiat.money,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +relay.electriclifestyle.com,26.2897,-80.1293 +relay.nostrhub.fr,48.1045,11.6004 +relay.agorist.space,52.3734,4.89406 +freelay.sovbit.host,64.1476,-21.9392 +nostr.hifish.org,47.4043,8.57398 +nostr-01.yakihonne.com,1.32123,103.695 +relay.routstr.com,43.6532,-79.3832 +nr.yay.so,46.2126,6.1154 +bcast.seutoba.com.br,43.6532,-79.3832 +fanfares.nostr1.com,40.7128,-74.006 +nostr.girino.org,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +relay.notoshi.win,13.311,101.112 +nostrja-kari.heguro.com,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +satsage.xyz,37.3986,-121.964 +relay.evanverma.com,40.8302,-74.1299 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.mitchelltribe.com,39.0438,-77.4874 +relaynostr.breadslice.com,43.6532,-79.3832 +nostr.chaima.info,51.223,6.78245 +nostr.mom,50.4754,12.3683 +relay.nostr-check.me,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 From 903a4584a8f72988475a56bfcd604daf7aad99a0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:52:06 +0700 Subject: [PATCH 05/24] Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch --- app/src/main/AndroidManifest.xml | 6 ++++ .../java/com/bitchat/android/MainActivity.kt | 35 +++++++++++++++++++ .../android/service/AppShutdownCoordinator.kt | 32 +++++++++++++++-- .../android/service/MeshForegroundService.kt | 19 ++++++++++ .../com/bitchat/android/util/AppConstants.kt | 2 ++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b228fd34..f4ca2b89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,8 @@ + + @@ -46,6 +48,10 @@ + + = 33) { + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null, + android.content.Context.RECEIVER_NOT_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null + ) + } + // Check if this is a quit request from the notification if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") finish() return } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() // Enable edge-to-edge display for modern Android look enableEdgeToEdge() @@ -646,6 +677,8 @@ class MainActivity : OrientationAwareActivity() { finish() return } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { @@ -759,6 +792,8 @@ class MainActivity : OrientationAwareActivity() { override fun onDestroy() { super.onDestroy() + try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { } + // Cleanup location status manager try { locationStatusManager.cleanup() 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 d4e00104..1c3abaf9 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -9,10 +9,13 @@ import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicLong /** * Coordinates a full application shutdown: @@ -24,6 +27,15 @@ import kotlinx.coroutines.withTimeoutOrNull */ object AppShutdownCoordinator { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val shutdownToken = AtomicLong(0L) + @Volatile + private var shutdownJob: Job? = null + + fun cancelPendingShutdown() { + shutdownToken.incrementAndGet() + shutdownJob?.cancel() + shutdownJob = null + } fun requestFullShutdownAndKill( app: Application, @@ -32,7 +44,16 @@ object AppShutdownCoordinator { stopForeground: () -> Unit, stopService: () -> Unit ) { - scope.launch { + val token = shutdownToken.incrementAndGet() + shutdownJob?.cancel() + val job = scope.launch { + // Signal UI to finish gracefully before we kill the process + try { + val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + .setPackage(app.packageName) + app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) + } catch (_: Exception) { } + // Stop mesh (best-effort) try { mesh?.stopServices() } catch (_: Exception) { } @@ -56,12 +77,19 @@ object AppShutdownCoordinator { } // Stop the service itself + if (!isActive || shutdownToken.get() != token) return@launch try { stopService() } catch (_: Exception) { } // Hard kill the app process + if (!isActive || shutdownToken.get() != token) return@launch try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } try { System.exit(0) } catch (_: Exception) { } } + shutdownJob = job + job.invokeOnCompletion { + if (shutdownJob === job) { + shutdownJob = null + } + } } } - 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 7510fa06..e92274f8 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,7 @@ class MeshForegroundService : Service() { private val serviceJob = Job() private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false + private var isShuttingDown: Boolean = false override fun onCreate() { super.onCreate() @@ -133,6 +134,13 @@ class MeshForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (isShuttingDown && intent?.action == ACTION_START) { + AppShutdownCoordinator.cancelPendingShutdown() + isShuttingDown = false + } + if (isShuttingDown && intent?.action != ACTION_QUIT) { + return START_NOT_STICKY + } when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly @@ -145,6 +153,12 @@ class MeshForegroundService : Service() { return START_NOT_STICKY } ACTION_QUIT -> { + isShuttingDown = true + updateJob?.cancel() + updateJob = null + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false // Fully stop all background activity, stop Tor (without changing setting), then kill the app AppShutdownCoordinator.requestFullShutdownAndKill( app = application, @@ -208,6 +222,7 @@ class MeshForegroundService : Service() { } private fun ensureMeshStarted() { + if (isShuttingDown) return if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") @@ -218,6 +233,10 @@ class MeshForegroundService : Service() { } private fun updateNotification(force: Boolean) { + if (isShuttingDown) { + notificationManager.cancel(NOTIFICATION_ID) + return + } val count = meshService?.getActivePeerCount() ?: 0 val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 9881cc2f..ca031df9 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -115,6 +115,8 @@ object AppConstants { const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L + const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH" + const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH" } object Media { From 0c7505b588b38b14f5b674ef6ec4d286cdb0f5c4 Mon Sep 17 00:00:00 2001 From: aidenvalue Date: Sat, 3 Jan 2026 17:31:20 +0100 Subject: [PATCH 06/24] fix: wifi aware socket binding (#533) * 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. --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> Co-authored-by: aidenvalue <> --- app/src/main/AndroidManifest.xml | 9 + .../com/bitchat/android/BitchatApplication.kt | 5 + .../java/com/bitchat/android/MainActivity.kt | 57 +- .../android/mesh/BluetoothMeshService.kt | 6 +- .../onboarding/OnboardingCoordinator.kt | 1 + .../onboarding/PermissionExplanationScreen.kt | 2 + .../android/onboarding/PermissionManager.kt | 20 + .../android/service/MeshServiceHolder.kt | 6 + .../bitchat/android/services/MessageRouter.kt | 17 +- .../com/bitchat/android/ui/ChatViewModel.kt | 24 +- .../bitchat/android/ui/MediaSendingManager.kt | 2 + .../bitchat/android/ui/MeshDelegateHandler.kt | 5 +- .../bitchat/android/ui/SidebarComponents.kt | 21 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 46 + .../android/ui/debug/DebugSettingsSheet.kt | 98 +- .../android/wifi-aware/WifiAwareController.kt | 116 ++ .../wifi-aware/WifiAwareMeshService.kt | 1277 +++++++++++++++++ 18 files changed, 1722 insertions(+), 17 deletions(-) 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/WifiAwareMeshService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4ca2b89..e79fa8eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -19,6 +20,12 @@ + + + + + + @@ -47,6 +54,8 @@ + + + val svc = com.bitchat.android.wifiaware.WifiAwareController.getService() + if (running && svc != null) { + svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate { + override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) { + if (message.isPrivate) { + message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) } + } else if (message.channel != null) { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message) + } else { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + chatViewModel.didReceiveMessage(message) + } + override fun didUpdatePeerList(peers: List) { + chatViewModel.didUpdatePeerList(peers) + } + override fun didReceiveChannelLeave(channel: String, fromPeer: String) { + chatViewModel.didReceiveChannelLeave(channel, fromPeer) + } + override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { + chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID) + } + override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { + chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID) + } + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { + return chatViewModel.decryptChannelMessage(encryptedContent, channel) + } + override fun getNickname(): String? { + return chatViewModel.getNickname() + } + override fun isFavorite(peerID: String): Boolean { + return try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true + } catch (_: Exception) { false } + } + } + } + } + } + } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -347,6 +395,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 @@ -513,8 +567,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) 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 3de48407..c48ce087 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -94,6 +94,9 @@ class BluetoothMeshService(private val context: Context) { } catch (_: Exception) { 0.01 } } ) + + // Register as shared instance for Wi-Fi Aware transport + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) // Wire sync manager delegate gossipSyncManager.delegate = object : GossipSyncManager.Delegate { @@ -268,7 +271,8 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + val routed = RoutedPacket(signedPacket) + connectionManager.broadcastPacket(routed) } override fun relayPacket(routed: RoutedPacket) { 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 871cc892..ba4da701 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -209,6 +209,7 @@ class OnboardingCoordinator( return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" 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 2b84aefa..c00f35f3 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.material.icons.filled.Warning import androidx.compose.material3.* @@ -242,6 +243,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.PRECISE_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 ff0a160f..aedf85f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -67,6 +67,11 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) + // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + // Notification permission intentionally excluded to keep it optional return permissions @@ -177,6 +182,20 @@ class PermissionManager(private val context: Context) { ) ) + // Wi‑Fi Aware category (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + 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" + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -262,6 +281,7 @@ enum class PermissionType(val nameValue: String) { PRECISE_LOCATION("Precise 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/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index d271ab29..71dddb66 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -9,6 +9,12 @@ import com.bitchat.android.mesh.BluetoothMeshService */ object MeshServiceHolder { private const val TAG = "MeshServiceHolder" + @Volatile + var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null + private set + + fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr } + @Volatile var meshService: BluetoothMeshService? = null private set 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 8166487e..53db2fa6 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -72,9 +72,15 @@ class MessageRouter private constructor( val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true val hasEstablished = mesh.hasEstablishedSession(toPeerID) + // Check Wi‑Fi Aware availability as a secondary transport + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } if (hasMesh && hasEstablished) { Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) + } else if (hasAware) { + Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…") + aware?.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) @@ -83,14 +89,21 @@ 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) else aware?.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) { + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } + val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID) + val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } + if (viaMesh) { 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 if (viaAware) { + Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") + val me = try { aware?.myPeerID } catch (_: Exception) { null } + aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "") } else { Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") nostr.sendReadReceipt(receipt, toPeerID) 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 1196396d..22345923 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -441,8 +441,9 @@ class ChatViewModel( state.getNicknameValue() ) } else { - // Default: route via mesh + // Default: route via mesh + Wi‑Fi Aware meshService.sendMessage(messageContent, mentions, channel) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {} } }) return @@ -512,19 +513,23 @@ class ChatViewModel( state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> - // This would need proper mesh service integration + // Send encrypted payload announcement over both transports for reachability meshService.sendMessage(content, mentions, currentChannelValue) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} }, onFallback = { meshService.sendMessage(content, mentions, currentChannelValue) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } ) } else { meshService.sendMessage(content, mentions, currentChannelValue) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } } else { messageManager.addMessage(message) meshService.sendMessage(content, mentions, null) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {} } } } @@ -647,16 +652,23 @@ class ChatViewModel( val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) - val nicknames = meshService.getPeerNicknames() - state.setPeerNicknames(nicknames) + // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers + val bleNick = meshService.getPeerNicknames() + val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } + val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick + state.setPeerNicknames(mergedNick) val rssiValues = meshService.getPeerRSSI() - state.setPeerRSSI(rssiValues) + val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null } + val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues + state.setPeerRSSI(mergedRssi) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - meshService.getPeerInfo(pid)?.isDirectConnection == true + val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true + val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false } + ble || aware } state.setPeerDirect(directMap) } catch (_: Exception) { } 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 a3def523..fc134a1b 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -210,6 +210,7 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") meshService.sendFilePrivate(toPeerID, filePacket) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File send completed successfully") } @@ -264,6 +265,7 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") meshService.sendFileBroadcast(filePacket) + try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File broadcast completed successfully") } 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 a452616e..6e7d5d22 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -96,7 +96,10 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - state.setConnectedPeers(peers) + // Merge peers from multiple transports to avoid flapping + val current = state.getConnectedPeersValue().toMutableSet() + current.addAll(peers) + state.setConnectedPeers(current.toList()) state.setIsConnected(peers.isNotEmpty()) notificationManager.showActiveUserNotification(peers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 4ab4360a..c504b9f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -576,9 +576,26 @@ private fun PeerItem( tint = Color.Gray ) } else { + val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() + val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState() + val isWifiDirect = awareConnected.containsKey(peerID) + val isBleDirect = isDirect + val icon = when { + isWifiDirect -> Icons.Filled.Wifi + isBleDirect -> Icons.Outlined.SettingsInputAntenna + // Routed: show Route icon; optionally prefer Wi‑Fi Aware if discovered there + awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering + else -> Icons.Filled.Route + } + val cd = when { + isWifiDirect -> "Direct Wi‑Fi Aware" + isBleDirect -> "Direct Bluetooth" + awareDiscovered.contains(peerID) -> "Routed over Wi‑Fi" + else -> "Routed" + } Icon( - imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, - contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + imageVector = icon, + contentDescription = cd, modifier = Modifier.size(16.dp), tint = colorScheme.onSurface.copy(alpha = 0.8f) ) 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..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 @@ -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 = false): 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 77f6ce12..669331e6 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 @@ -36,6 +36,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(false) + 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() @@ -59,6 +70,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(false) + _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. } @@ -262,6 +277,27 @@ class DebugSettingsManager private constructor() { )) } + fun setBleEnabled(enabled: Boolean) { + DebugPreferenceManager.setBleEnabled(enabled) + _bleEnabled.value = enabled + addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) + } + + 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) @@ -319,6 +355,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 4b9f983a..94781b31 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 @@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.Cancel import androidx.compose.material.icons.filled.Devices @@ -50,7 +52,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() @@ -62,6 +64,12 @@ 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() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible @@ -86,6 +94,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) } } @@ -213,6 +230,46 @@ 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) + scope.launch { + if (it) { + if (gattServerEnabled) meshService.connectionManager.startServer() + if (gattClientEnabled) meshService.connectionManager.startClient() + } else { + meshService.connectionManager.stopServer() + meshService.connectionManager.stopClient() + } + } + }) + } + 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)) + Switch(checked = wifiAwareEnabled, 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)) { @@ -223,7 +280,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 @@ -472,6 +529,43 @@ 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)) { + 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 running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() + Text(if (running) "running" else "stopped", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) + AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, 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/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt new file mode 100644 index 00000000..d10c6741 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -0,0 +1,116 @@ +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 + +/** + * 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 var service: WifiAwareMeshService? = null + private var appContext: Context? = null + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _enabled = MutableStateFlow(false) + val enabled: StateFlow = _enabled.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 + 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() { + if (_running.value) return + val ctx = appContext ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Log.w(TAG, "Wi‑Fi Aware requires Android 10 (Q)+; disabled.") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} + 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") + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Grant Nearby Wi‑Fi Devices to start Wi‑Fi Aware")) } catch (_: Exception) {} + return + } + } + try { + service = WifiAwareMeshService(ctx).also { + it.startServices() + _running.value = true + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } 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) {} + } + } + + fun stop() { + try { service?.stopServices() } catch (_: Exception) { } + service = null + _running.value = false + try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} + } + + fun getService(): WifiAwareMeshService? = service + + // Optional bridge to BLE mesh for cross-transport relaying + @Volatile private var bleMesh: com.bitchat.android.mesh.BluetoothMeshService? = null + fun setBleMeshService(svc: com.bitchat.android.mesh.BluetoothMeshService) { bleMesh = svc } + fun getBleMeshService(): com.bitchat.android.mesh.BluetoothMeshService? = bleMesh +} 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..f895a7cb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -0,0 +1,1277 @@ +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.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.* +import com.bitchat.android.protocol.* +import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.util.toHexString +// Mesh-layer components are reused from the existing Bluetooth stack +import com.bitchat.android.mesh.PeerManager +import com.bitchat.android.mesh.PeerManagerDelegate +import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.mesh.FragmentManager +import com.bitchat.android.mesh.SecurityManager +import com.bitchat.android.mesh.SecurityManagerDelegate +import com.bitchat.android.mesh.StoreForwardManager +import com.bitchat.android.mesh.StoreForwardManagerDelegate +import com.bitchat.android.mesh.MessageHandler +import com.bitchat.android.mesh.MessageHandlerDelegate +import com.bitchat.android.mesh.PacketProcessor +import com.bitchat.android.mesh.PacketProcessorDelegate +import kotlinx.coroutines.* +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.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors + +/** + * 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) { + + 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" + } + + // Core crypto/services + private val encryptionService = EncryptionService(context) + + // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) + val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) + + // Core components + private val peerManager = PeerManager() + private 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) + + // Gossip sync + private val gossipSyncManager: GossipSyncManager + + // Wi-Fi Aware transport + private val awareManager = context.getSystemService(WifiAwareManager::class.java) + private var wifiAwareSession: WifiAwareSession? = null + private var publishSession: PublishDiscoverySession? = null + private var subscribeSession: SubscribeDiscoverySession? = null + private val listenerExec = Executors.newCachedThreadPool() + private var isActive = false + + // Delegate + var delegate: WifiAwareMeshDelegate? = null + + // Transport state + private val peerSockets = ConcurrentHashMap() + private val serverSockets = ConcurrentHashMap() + private val networkCallbacks = ConcurrentHashMap() + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val handleToPeerId = ConcurrentHashMap() // discovery mapping + private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time + + // Timestamp dedupe + private val lastTimestamps = ConcurrentHashMap() + + // Coroutines + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + setupDelegates() + messageHandler.packetProcessor = packetProcessor + + // Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor) + val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager + if (shared != null) { + gossipSyncManager = shared + } else { + gossipSyncManager = GossipSyncManager( + myPeerID = myPeerID, + scope = serviceScope, + configProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 500 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + } + ) + gossipSyncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) + } + override fun sendPacket(packet: BitchatPacket) { + broadcastPacket(RoutedPacket(packet)) + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } + } + } + } + + /** + * Helper method hexToBa. + */ + private fun hexStringToByteArray(hex: String): ByteArray { + val out = ByteArray(8) + var idx = 0 + var s = hex + while (s.length >= 2 && idx < 8) { + val b = s.substring(0, 2).toIntOrNull(16)?.toByte() ?: 0 + out[idx++] = b + s = s.drop(2) + } + return out + } + + /** + * Sign packet before broadcasting. + */ + private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { + val data = packet.toBinaryDataForSigning() ?: return packet + val sig = encryptionService.signData(data) ?: return packet + return packet.copy(signature = sig) + } + + /** + * Broadcasts raw bytes to currently connected peer. + */ + private fun broadcastRaw(bytes: ByteArray) { + var sent = 0 + peerSockets.forEach { (pid, sock) -> + try { + sock.getOutputStream().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})") + } + + /** + * 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})") + // Wi-Fi Aware uses full packets; no fragmentation + val data = routed.packet.toBinaryData() ?: return + serviceScope.launch { broadcastRaw(data) } + } + + // 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) { + // Wi-Fi Aware uses full packets; no fragmentation + val data = packet.toBinaryData() ?: return + serviceScope.launch { + val sock = peerSockets[peerID] + if (sock == null) { + Log.w(TAG, "TX: no socket for ${peerID.take(8)}") + return@launch + } + try { + sock.getOutputStream().write(data) + Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") + } catch (e: IOException) { + Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") + } + } + } + + /** + * Configures delegates for internal components so that events are routed back + * through this service and ultimately to the {@link WifiAwareMeshDelegate}. + */ + private fun setupDelegates() { + peerManager.delegate = object : PeerManagerDelegate { + override fun onPeerListUpdated(peerIDs: List) { + delegate?.didUpdatePeerList(peerIDs) + } + override fun onPeerRemoved(peerID: String) { + try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } + try { encryptionService.removePeer(peerID) } catch (_: Exception) { } + } + } + + securityManager.delegate = object : SecurityManagerDelegate { + override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { + serviceScope.launch { + delay(100) + sendAnnouncementToPeer(peerID) + delay(1000) + storeForwardManager.sendCachedMessages(peerID) + } + } + override fun sendHandshakeResponse(peerID: String, response: ByteArray) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = response, + ttl = MAX_TTL + ) + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + override fun getPeerInfo(peerID: String): PeerInfo? { + return peerManager.getPeerInfo(peerID) + } + } + + storeForwardManager.delegate = object : StoreForwardManagerDelegate { + override fun isFavorite(peerID: String) = delegate?.isFavorite(peerID) ?: false + override fun isPeerOnline(peerID: String) = peerManager.isPeerActive(peerID) + override fun sendPacket(packet: BitchatPacket) { + broadcastPacket(RoutedPacket(packet)) + } + } + + messageHandler.delegate = object : MessageHandlerDelegate { + override fun addOrUpdatePeer(peerID: String, nickname: String) = + 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) = + peerManager.getPeerNickname(peerID) + override fun getNetworkSize() = peerManager.getActivePeerCount() + override fun getMyNickname() = delegate?.getNickname() + override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + override fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + override fun sendPacket(packet: BitchatPacket) { + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + override fun relayPacket(routed: RoutedPacket) { broadcastPacket(routed) } + override fun getBroadcastRecipient() = SpecialRecipients.BROADCAST + + override fun verifySignature(packet: BitchatPacket, peerID: String) = + securityManager.verifySignature(packet, peerID) + override fun encryptForPeer(data: ByteArray, recipientPeerID: String) = + securityManager.encryptForPeer(data, recipientPeerID) + override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String) = + securityManager.decryptFromPeer(encryptedData, senderPeerID) + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = + encryptionService.verifyEd25519Signature(signature, data, publicKey) + + override fun hasNoiseSession(peerID: String) = + encryptionService.hasEstablishedSession(peerID) + override fun initiateNoiseHandshake(peerID: String) { + serviceScope.launch { + val hs = encryptionService.initiateHandshake(peerID) ?: return@launch + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_HANDSHAKE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = hs, + ttl = MAX_TTL + ) + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + } + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = + 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(TAG, "Updated peer binding to $newPeerID, fp=${fingerprint.take(16)}") + } + + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String) = + delegate?.decryptChannelMessage(encryptedContent, channel) + + override fun onMessageReceived(message: BitchatMessage) { + 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) + } + } + + packetProcessor.delegate = object : PacketProcessorDelegate { + override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = + securityManager.validatePacket(packet, peerID) + override fun updatePeerLastSeen(peerID: String) = peerManager.updatePeerLastSeen(peerID) + override fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) + override fun getNetworkSize(): Int = peerManager.getActivePeerCount() + override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST + + override fun handleNoiseHandshake(routed: RoutedPacket): Boolean = + runBlocking { securityManager.handleNoiseHandshake(routed) } + + override fun handleNoiseEncrypted(routed: RoutedPacket) { + serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + } + + override fun handleAnnounce(routed: RoutedPacket) { + serviceScope.launch { + val isFirst = messageHandler.handleAnnounce(routed) + routed.peerID?.let { pid -> + try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } + } + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + } + + override fun handleMessage(routed: RoutedPacket) { + serviceScope.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) { + serviceScope.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@WifiAwareMeshService.sendAnnouncementToPeer(peerID) + override fun sendCachedMessages(peerID: String) = storeForwardManager.sendCachedMessages(peerID) + override fun relayPacket(routed: RoutedPacket) = broadcastPacket(routed) + + override fun handleRequestSync(routed: RoutedPacket) { + val fromPeer = routed.peerID ?: return + val req = RequestSyncPacket.decode(routed.packet.payload) ?: return + gossipSyncManager.handleRequestSync(fromPeer, req) + } + } + } + + /** + * 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 + ]) + fun startServices() { + if (isActive) return + isActive = true + Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") + + awareManager?.attach(object : AttachCallback() { + @SuppressLint("MissingPermission") + @RequiresPermission(allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES + ]) + override fun onAttached(session: WifiAwareSession) { + 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) { + publishSession = pub + Log.d(TAG, "PUBLISH: onPublishStarted()") + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() + 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 (message.isEmpty()) return + val subscriberId = try { String(message) } catch (_: Exception) { "" } + if (subscriberId == myPeerID) return + + handleToPeerId[peerHandle] = subscriberId + if (subscriberId.isNotBlank()) discoveredTimestamps[subscriberId] = System.currentTimeMillis() + Log.d(TAG, "PUBLISH: got ping from $subscriberId; spinning up server") + handleSubscriberPing(publishSession!!, peerHandle) + } + }, + Handler(Looper.getMainLooper()) + ) + + // SUBSCRIBE (client role) + session.subscribe( + SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .build(), + object : DiscoverySessionCallback() { + override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { + subscribeSession = sub + Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") + } + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray, + matchFilter: List + ) { + val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } + handleToPeerId[peerHandle] = peerId + val msgId = (System.nanoTime() and 0x7fffffff).toInt() + subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) + if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() + Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)") + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun onMessageReceived( + peerHandle: PeerHandle, + message: ByteArray + ) { + if (message.isEmpty()) return + val peerId = handleToPeerId[peerHandle] ?: return + if (peerId == myPeerID) return + + Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B") + handleServerReady(peerHandle, message) + } + }, + Handler(Looper.getMainLooper()) + ) + } + override fun onAttachFailed() { + Log.e(TAG, "Wi-Fi Aware attach failed") + } + }, Handler(Looper.getMainLooper())) + + sendPeriodicBroadcastAnnounce() + gossipSyncManager.start() + } + + /** + * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. + */ + fun stopServices() { + if (!isActive) return + isActive = false + Log.i(TAG, "Stopping Wi-Fi Aware mesh") + + sendLeaveAnnouncement() + + serviceScope.launch { + delay(200) + + gossipSyncManager.stop() + + networkCallbacks.values.forEach { runCatching { cm.unregisterNetworkCallback(it) } } + networkCallbacks.clear() + publishSession?.close(); publishSession = null + subscribeSession?.close(); subscribeSession = null + wifiAwareSession?.close(); wifiAwareSession = null + + serverSockets.values.forEach { it.closeQuietly() } + peerSockets.values.forEach { it.closeQuietly() } + handleToPeerId.clear() + serverSockets.clear() + peerSockets.clear() + + peerManager.shutdown() + fragmentManager.shutdown() + securityManager.shutdown() + storeForwardManager.shutdown() + messageHandler.shutdown() + packetProcessor.shutdown() + + serviceScope.cancel() + } + } + + /** + * Periodically broadcasts an ANNOUNCE packet (every ~30s) while the service is active, + * so new/idle peers can discover us without user action. + */ + private fun sendPeriodicBroadcastAnnounce() { + serviceScope.launch { + while (isActive) { + try { delay(30_000); sendBroadcastAnnounce() } catch (_: Exception) { } + } + } + } + + /** + * 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 (serverSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already serving $peerId, skipping") + return + } + + val ss = ServerSocket(0) + serverSockets[peerId] = ss + val port = ss.localPort + + // Ensure port is set to reuse if connection was recently closed (TIME_WAIT) + try { + ss.reuseAddress = true + } catch (_: Exception) {} + + Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on port $port") + + val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) + .setPskPassphrase(PSK) + .setPort(port) + .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() { + override fun onAvailable(network: Network) { + try { + val client = ss.accept() + try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } + client.keepAlive = true + Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") + peerSockets[peerId] = client + try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(client, peerId) } + handleSubscriberKeepAlive(client, peerId, pubSession, peerHandle) + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + messageHandler.delegate?.initiateNoiseHandshake(peerId) + Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + } + // Ensure fast presence even before handshake settles + serviceScope.launch { delay(150); sendBroadcastAnnounce() } + } catch (ioe: IOException) { + Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) + } + } + override fun onLost(network: Network) { + networkCallbacks.remove(peerId) + Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}") + } + } + + networkCallbacks[peerId] = cb + Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}") + cm.requestNetwork(req, cb) + + val readyId = (System.nanoTime() and 0x7fffffff).toInt() + val portBytes = ByteBuffer.allocate(4) + .order(ByteOrder.BIG_ENDIAN) + .putInt(port) + .array() + Handler(Looper.getMainLooper()).post { + try { + val sent = pubSession.sendMessage(peerHandle, readyId, portBytes) + 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: Socket, + peerId: String, + pubSession: PublishDiscoverySession, + peerHandle: PeerHandle + ) { + // TCP keep-alive pings + serviceScope.launch { + try { + val os = client.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (peerSockets.containsKey(peerId)) { + try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } + delay(20_000) + } + } + } + + /** + * 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 = handleToPeerId[peerHandle] ?: return + if (amIServerFor(peerId)) return + if (peerSockets.containsKey(peerId)) { + Log.v(TAG, "↪ already client-connected to $peerId, skipping") + return + } + + val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int + Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port") + + val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle) + .setPskPassphrase(PSK) + .build() + val req = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(spec) + .build() + + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + // Do not bind process for Aware; use per-socket binding instead + } + override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { + if (peerSockets.containsKey(peerId)) return + val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return + val addr = info.peerIpv6Addr as? Inet6Address ?: return + + val lp = cm.getLinkProperties(network) + val iface = lp?.interfaceName + + try { + val sock = Socket() + try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") } + sock.tcpNoDelay = true + sock.keepAlive = true + + // 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 + } + + sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000) + Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)} addr=$scopedAddr:$port (iface=$iface)") + + peerSockets[peerId] = sock + try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} + try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} + listenerExec.execute { listenToPeer(sock, peerId) } + handleServerKeepAlive(sock, peerId, peerHandle) + // Kick off Noise handshake for this logical peer + if (myPeerID < peerId) { + messageHandler.delegate?.initiateNoiseHandshake(peerId) + Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)") + } + // 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) + } + } + override fun onLost(network: Network) { + networkCallbacks.remove(peerId) + Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}") + } + } + + networkCallbacks[peerId] = cb + Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}") + cm.requestNetwork(req, cb) + } + + /** + * Sends periodic TCP and discovery keep-alive messages for server connections. + */ + private fun handleServerKeepAlive( + sock: Socket, + peerId: String, + peerHandle: PeerHandle + ) { + // TCP keep-alive + serviceScope.launch { + try { + val os = sock.getOutputStream() + while (peerSockets.containsKey(peerId)) { + try { os.write(0) } catch (_: IOException) { break } + delay(2_000) + } + } catch (_: Exception) {} + } + // Discovery keep-alive + serviceScope.launch { + var msgId = 0 + while (peerSockets.containsKey(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) = 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: Socket, initialLogicalPeerId: String) { + val inStream = socket.getInputStream() + val buf = ByteArray(64 * 1024) + var routedPeerId: String? = null + + while (isActive) { + val len = try { inStream.read(buf) } catch (_: IOException) { break } + if (len <= 0) break + + val raw = buf.copyOf(len) + val pkt = BitchatPacket.fromBinaryData(raw) ?: continue + + val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue + if (senderPeerHex == myPeerID) continue + + val ts = pkt.timestamp + if (lastTimestamps.put(senderPeerHex, ts) == ts) { + continue + } + + if (routedPeerId == null) { + routedPeerId = senderPeerHex + peerSockets[routedPeerId] = socket + } + + Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})") + packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId)) + } + + // Breaking out of the loop means the socket is dead or service is stopping. + // We MUST notify the mesh layer so it removes the logical peer immediately to allow reconnection. + Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") + handlePeerDisconnection(initialLogicalPeerId, routedPeerId) + socket.closeQuietly() + } + + private fun handlePeerDisconnection(initialId: String, routedId: String?) { + serviceScope.launch { + Log.d(TAG, "Cleaning up peer: $initialId / $routedId") + + peerSockets.remove(initialId)?.closeQuietly() + serverSockets.remove(initialId)?.closeQuietly() + networkCallbacks.remove(initialId)?.let { runCatching { cm.unregisterNetworkCallback(it) } } + peerManager.removePeer(initialId) + + routedId?.let { id -> + if (id != initialId) { + peerSockets.remove(id)?.closeQuietly() + serverSockets.remove(id)?.closeQuietly() + networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } } + peerManager.removePeer(id) + } + } + } + } + + /** + * 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 + */ + fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { + if (content.isEmpty()) return + + serviceScope.launch { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = content.toByteArray(Charsets.UTF_8), + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(packet) + broadcastPacket(RoutedPacket(signed)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } + + /** + * 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) + */ + fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { + if (content.isEmpty() || recipientPeerID.isEmpty()) return + + serviceScope.launch { + val finalId = messageID ?: UUID.randomUUID().toString() + + if (encryptionService.hasEstablishedSession(recipientPeerID)) { + try { + val pm = PrivateMessagePacket(messageID = finalId, content = content) + val tlv = pm.encode() ?: return@launch + val payload = NoisePayload(type = NoisePayloadType.PRIVATE_MESSAGE, data = tlv).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + + val pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) + } catch (e: Exception) { + Log.e(TAG, "Failed to encrypt private message: ${e.message}") + } + } else { + messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) + } + } + } + + /** + * 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). + */ + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + serviceScope.launch { + try { + val payload = NoisePayload( + type = NoisePayloadType.READ_RECEIPT, + data = messageID.toByteArray(Charsets.UTF_8) + ).encode() + val enc = encryptionService.encrypt(payload, recipientPeerID) + val pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) + } catch (e: Exception) { + Log.e(TAG, "Failed to send read receipt: ${e.message}") + } + } + } + + /** + * 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. + */ + fun sendFileBroadcast(file: BitchatFilePacket) { + try { + val payload = file.encode() ?: run { Log.e(TAG, "file TLV encode failed"); return } + serviceScope.launch { + val pkt = BitchatPacket( + version = 2u, // FILE_TRANSFER big length + type = MessageType.FILE_TRANSFER.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(pkt) + val transferId = sha256Hex(payload) + broadcastPacket(RoutedPacket(signed, transferId = transferId)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } catch (e: Exception) { + Log.e(TAG, "sendFileBroadcast failed: ${e.message}", e) + } + } + + /** + * 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. + */ + fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { + try { + serviceScope.launch { + if (!encryptionService.hasEstablishedSession(recipientPeerID)) { + messageHandler.delegate?.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 pkt = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = enc, + signature = null, + ttl = MAX_TTL + ) + val signed = signPacketBeforeBroadcast(pkt) + val transferId = sha256Hex(tlv) + broadcastPacket(RoutedPacket(signed, transferId = transferId)) + } + } catch (e: Exception) { + Log.e(TAG, "sendFilePrivate failed: ${e.message}", e) + } + } + + /** + * 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. + */ + fun cancelFileTransfer(transferId: String): Boolean { + return false + } + + /** + * Computes the SHA-256 of the given bytes and returns a lowercase hex string. + * Falls back to the byte-length in hex if MessageDigest is unavailable. + */ + 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) } + + /** + * Broadcasts an ANNOUNCE packet to the entire mesh. + */ + fun sendBroadcastAnnounce() { + serviceScope.launch { + val nickname = delegate?.getNickname() ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: run { + Log.e(TAG, "No static public key available for announcement"); return@launch + } + val signingKey = encryptionService.getSigningPublicKey() ?: run { + Log.e(TAG, "No signing public key available for announcement"); return@launch + } + + val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return@launch + + val announcePacket = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + val signed = signPacketBeforeBroadcast(announcePacket) + + broadcastPacket(RoutedPacket(signed)) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + } + + /** + * Sends an ANNOUNCE packet to a specific peer. + */ + fun sendAnnouncementToPeer(peerID: String) { + if (peerManager.hasAnnouncedToPeer(peerID)) return + + val nickname = delegate?.getNickname() ?: myPeerID + val staticKey = encryptionService.getStaticPublicKey() ?: return + val signingKey = encryptionService.getSigningPublicKey() ?: return + + val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = tlvPayload + ) + val signed = signPacketBeforeBroadcast(packet) + + broadcastPacket(RoutedPacket(signed)) + peerManager.markPeerAsAnnouncedTo(peerID) + try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } + } + + /** + * Sends a LEAVE announcement to all peers before disconnecting. + */ + private fun sendLeaveAnnouncement() { + val nickname = delegate?.getNickname() ?: myPeerID + val packet = BitchatPacket( + type = MessageType.LEAVE.value, + ttl = MAX_TTL, + senderID = myPeerID, + payload = nickname.toByteArray() + ) + broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + + /** @return Mapping of peer IDs to nicknames. */ + fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() + + /** @return Mapping of peer IDs to RSSI values. */ + fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() + + /** + * @return true if a Noise session with the peer is fully established. + */ + fun hasEstablishedSession(peerID: String) = encryptionService.hasEstablishedSession(peerID) + + /** + * @return a human-readable Noise session state for the given peer (implementation-defined). + */ + fun getSessionState(peerID: String) = encryptionService.getSessionState(peerID) + + /** + * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. + */ + fun initiateNoiseHandshake(peerID: String) = messageHandler.delegate?.initiateNoiseHandshake(peerID) + + /** + * @return the stored public-key fingerprint (hex) for a peer, if known. + */ + fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) + + /** + * Retrieves the full profile for a peer, including keys and verification state, if available. + */ + fun getPeerInfo(peerID: String): PeerInfo? = peerManager.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. + */ + fun updatePeerInfo( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean + ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + + /** + * @return the local device’s long-term identity fingerprint (hex). + */ + fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() + + /** + * @return true if the UI should show an “encrypted” indicator for this peer. + */ + fun shouldShowEncryptionIcon(peerID: String) = encryptionService.hasEstablishedSession(peerID) + + /** + * @return a snapshot list of peers with established Noise sessions. + */ + fun getEncryptedPeers(): List = emptyList() + + /** + * @return the current IPv4/IPv6 address of a connected peer, if any. + * Prefers the scoped IPv6 address format. + */ + fun getDeviceAddressForPeer(peerID: String): String? = + peerSockets[peerID]?.let { resolveScopedAddress(it) } + + /** + * 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. + */ + fun getDeviceAddressToPeerMapping(): Map { + val map = mutableMapOf() + peerSockets.forEach { (pid, sock) -> + map[pid] = resolveScopedAddress(sock) ?: "unknown" + } + return map + } + + /** + * @return map of peer ID to nickname, bridged for UI warning fix. + */ + fun getPeerNicknamesMap(): Map = peerManager.getAllPeerNicknames() + + /** 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. + */ + fun printDeviceAddressesForPeers(): String = + getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } + + /** + * @return A detailed string containing the debug status of all mesh components. + */ + fun getDebugStatus(): String = buildString { + appendLine("=== Wi-Fi Aware Mesh Debug Status ===") + appendLine("My Peer ID: $myPeerID") + appendLine("Peers: ${peerSockets.keys}") + appendLine(peerManager.getDebugInfo(getDeviceAddressToPeerMapping())) + appendLine(fragmentManager.getDebugInfo()) + appendLine(securityManager.getDebugInfo()) + appendLine(storeForwardManager.getDebugInfo()) + appendLine(messageHandler.getDebugInfo()) + appendLine(packetProcessor.getDebugInfo()) + } + + /** Utility extension to safely close sockets. */ + private fun Socket.closeQuietly() = try { close() } catch (_: Exception) {} + + /** Utility extension to safely close server sockets. */ + private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} +} + + +/** + * Delegate interface for mesh service callbacks (maintains exact same interface) + */ +interface WifiAwareMeshDelegate { + 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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? + fun getNickname(): String? + fun isFavorite(peerID: String): Boolean + // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager +} From fa1978d587ff38c4cdd3ba9d30cfa33e6294fb79 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:37:49 +0700 Subject: [PATCH 07/24] Revert "fix: wifi aware socket binding (#533)" (#535) This reverts commit 0c7505b588b38b14f5b674ef6ec4d286cdb0f5c4. --- app/src/main/AndroidManifest.xml | 9 - .../com/bitchat/android/BitchatApplication.kt | 5 - .../java/com/bitchat/android/MainActivity.kt | 57 +- .../android/mesh/BluetoothMeshService.kt | 6 +- .../onboarding/OnboardingCoordinator.kt | 1 - .../onboarding/PermissionExplanationScreen.kt | 2 - .../android/onboarding/PermissionManager.kt | 20 - .../android/service/MeshServiceHolder.kt | 6 - .../bitchat/android/services/MessageRouter.kt | 17 +- .../com/bitchat/android/ui/ChatViewModel.kt | 24 +- .../bitchat/android/ui/MediaSendingManager.kt | 2 - .../bitchat/android/ui/MeshDelegateHandler.kt | 5 +- .../bitchat/android/ui/SidebarComponents.kt | 21 +- .../ui/debug/DebugPreferenceManager.kt | 27 +- .../android/ui/debug/DebugSettingsManager.kt | 46 - .../android/ui/debug/DebugSettingsSheet.kt | 98 +- .../android/wifi-aware/WifiAwareController.kt | 116 -- .../wifi-aware/WifiAwareMeshService.kt | 1277 ----------------- 18 files changed, 17 insertions(+), 1722 deletions(-) delete mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt delete mode 100644 app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e79fa8eb..f4ca2b89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ - @@ -20,12 +19,6 @@ - - - - - - @@ -54,8 +47,6 @@ - - - val svc = com.bitchat.android.wifiaware.WifiAwareController.getService() - if (running && svc != null) { - svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate { - override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) { - if (message.isPrivate) { - message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) } - } else if (message.channel != null) { - com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message) - } else { - com.bitchat.android.services.AppStateStore.addPublicMessage(message) - } - chatViewModel.didReceiveMessage(message) - } - override fun didUpdatePeerList(peers: List) { - chatViewModel.didUpdatePeerList(peers) - } - override fun didReceiveChannelLeave(channel: String, fromPeer: String) { - chatViewModel.didReceiveChannelLeave(channel, fromPeer) - } - override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) { - chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID) - } - override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { - chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID) - } - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { - return chatViewModel.decryptChannelMessage(encryptedContent, channel) - } - override fun getNickname(): String? { - return chatViewModel.getNickname() - } - override fun isFavorite(peerID: String): Boolean { - return try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true - } catch (_: Exception) { false } - } - } - } - } - } - } // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes @@ -395,12 +347,6 @@ 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 @@ -567,9 +513,8 @@ class MainActivity : OrientationAwareActivity() { else -> BatteryOptimizationStatus.ENABLED } - val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } when { - bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { + 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) 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 c48ce087..3de48407 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -94,9 +94,6 @@ class BluetoothMeshService(private val context: Context) { } catch (_: Exception) { 0.01 } } ) - - // Register as shared instance for Wi-Fi Aware transport - com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) // Wire sync manager delegate gossipSyncManager.delegate = object : GossipSyncManager.Delegate { @@ -271,8 +268,7 @@ class BluetoothMeshService(private val context: Context) { override fun sendPacket(packet: BitchatPacket) { // Sign the packet before broadcasting val signedPacket = signPacketBeforeBroadcast(packet) - val routed = RoutedPacket(signedPacket) - connectionManager.broadcastPacket(routed) + connectionManager.broadcastPacket(RoutedPacket(signedPacket)) } override fun relayPacket(routed: RoutedPacket) { 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 ba4da701..871cc892 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -209,7 +209,6 @@ class OnboardingCoordinator( return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" 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 c00f35f3..2b84aefa 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -11,7 +11,6 @@ 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.material.icons.filled.Warning import androidx.compose.material3.* @@ -243,7 +242,6 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { PermissionType.PRECISE_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 aedf85f8..ff0a160f 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -67,11 +67,6 @@ class PermissionManager(private val context: Context) { Manifest.permission.ACCESS_FINE_LOCATION )) - // Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES) - } - // Notification permission intentionally excluded to keep it optional return permissions @@ -182,20 +177,6 @@ class PermissionManager(private val context: Context) { ) ) - // Wi‑Fi Aware category (Android 13+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - 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" - ) - ) - } - // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -281,7 +262,6 @@ enum class PermissionType(val nameValue: String) { PRECISE_LOCATION("Precise 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/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index 71dddb66..d271ab29 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -9,12 +9,6 @@ import com.bitchat.android.mesh.BluetoothMeshService */ object MeshServiceHolder { private const val TAG = "MeshServiceHolder" - @Volatile - var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null - private set - - fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr } - @Volatile var meshService: BluetoothMeshService? = null private set 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 53db2fa6..8166487e 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -72,15 +72,9 @@ class MessageRouter private constructor( val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true val hasEstablished = mesh.hasEstablishedSession(toPeerID) - // Check Wi‑Fi Aware availability as a secondary transport - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } if (hasMesh && hasEstablished) { Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) - } else if (hasAware) { - Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…") - aware?.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) @@ -89,21 +83,14 @@ 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)}…") - if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID) + mesh.initiateNoiseHandshake(toPeerID) } } fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null } - val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID) - val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false } - if (viaMesh) { + if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(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 if (viaAware) { - Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") - val me = try { aware?.myPeerID } catch (_: Exception) { null } - aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "") } else { Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…") nostr.sendReadReceipt(receipt, toPeerID) 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 22345923..1196396d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -441,9 +441,8 @@ class ChatViewModel( state.getNicknameValue() ) } else { - // Default: route via mesh + Wi‑Fi Aware + // Default: route via mesh meshService.sendMessage(messageContent, mentions, channel) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {} } }) return @@ -513,23 +512,19 @@ class ChatViewModel( state.getNicknameValue(), meshService.myPeerID, onEncryptedPayload = { encryptedData -> - // Send encrypted payload announcement over both transports for reachability + // This would need proper mesh service integration meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} }, onFallback = { meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } ) } else { meshService.sendMessage(content, mentions, currentChannelValue) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {} } } else { messageManager.addMessage(message) meshService.sendMessage(content, mentions, null) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {} } } } @@ -652,23 +647,16 @@ class ChatViewModel( val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) - // Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers - val bleNick = meshService.getPeerNicknames() - val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null } - val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick - state.setPeerNicknames(mergedNick) + val nicknames = meshService.getPeerNicknames() + state.setPeerNicknames(nicknames) val rssiValues = meshService.getPeerRSSI() - val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null } - val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues - state.setPeerRSSI(mergedRssi) + state.setPeerRSSI(rssiValues) // Update directness per peer (driven by PeerManager state) try { val directMap = state.getConnectedPeersValue().associateWith { pid -> - val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true - val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false } - ble || aware + meshService.getPeerInfo(pid)?.isDirectConnection == true } state.setPeerDirect(directMap) } catch (_: Exception) { } 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 fc134a1b..a3def523 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -210,7 +210,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID") meshService.sendFilePrivate(toPeerID, filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File send completed successfully") } @@ -265,7 +264,6 @@ class MediaSendingManager( Log.d(TAG, "📤 Calling meshService.sendFileBroadcast") meshService.sendFileBroadcast(filePacket) - try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {} Log.d(TAG, "✅ File broadcast completed successfully") } 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 6e7d5d22..a452616e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -96,10 +96,7 @@ class MeshDelegateHandler( override fun didUpdatePeerList(peers: List) { coroutineScope.launch { - // Merge peers from multiple transports to avoid flapping - val current = state.getConnectedPeersValue().toMutableSet() - current.addAll(peers) - state.setConnectedPeers(current.toList()) + state.setConnectedPeers(peers) state.setIsConnected(peers.isNotEmpty()) notificationManager.showActiveUserNotification(peers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index c504b9f9..4ab4360a 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -576,26 +576,9 @@ private fun PeerItem( tint = Color.Gray ) } else { - val awareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState() - val awareDiscovered by com.bitchat.android.wifiaware.WifiAwareController.discoveredPeers.collectAsState() - val isWifiDirect = awareConnected.containsKey(peerID) - val isBleDirect = isDirect - val icon = when { - isWifiDirect -> Icons.Filled.Wifi - isBleDirect -> Icons.Outlined.SettingsInputAntenna - // Routed: show Route icon; optionally prefer Wi‑Fi Aware if discovered there - awareDiscovered.contains(peerID) -> Icons.Filled.WifiTethering - else -> Icons.Filled.Route - } - val cd = when { - isWifiDirect -> "Direct Wi‑Fi Aware" - isBleDirect -> "Direct Bluetooth" - awareDiscovered.contains(peerID) -> "Routed over Wi‑Fi" - else -> "Routed" - } Icon( - imageVector = icon, - contentDescription = cd, + imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, + contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", modifier = Modifier.size(16.dp), tint = colorScheme.onSurface.copy(alpha = 0.8f) ) 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 2d734c14..04ad48a2 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,10 +20,7 @@ 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" - // 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" + // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled private lateinit var prefs: SharedPreferences @@ -105,25 +102,5 @@ object DebugPreferenceManager { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } - // 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 = false): 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() - } + // No longer storing persistent notification in debug prefs. } 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 669331e6..77f6ce12 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 @@ -36,17 +36,6 @@ 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(false) - 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() @@ -70,10 +59,6 @@ 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(false) - _wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. } @@ -277,27 +262,6 @@ class DebugSettingsManager private constructor() { )) } - fun setBleEnabled(enabled: Boolean) { - DebugPreferenceManager.setBleEnabled(enabled) - _bleEnabled.value = enabled - addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled")) - } - - 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) @@ -355,16 +319,6 @@ 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 94781b31..4b9f983a 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 @@ -10,8 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.Cancel import androidx.compose.material.icons.filled.Devices @@ -52,7 +50,7 @@ fun DebugSettingsSheet( val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState() val gattClientEnabled by manager.gattClientEnabled.collectAsState() - val packetRelayed by manager.packetRelayEnabled.collectAsState() + val packetRelayEnabled by manager.packetRelayEnabled.collectAsState() val maxOverall by manager.maxConnectionsOverall.collectAsState() val maxServer by manager.maxServerConnections.collectAsState() val maxClient by manager.maxClientConnections.collectAsState() @@ -64,12 +62,6 @@ 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() // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible @@ -94,15 +86,6 @@ 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) } } @@ -230,46 +213,6 @@ 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) - scope.launch { - if (it) { - if (gattServerEnabled) meshService.connectionManager.startServer() - if (gattClientEnabled) meshService.connectionManager.startClient() - } else { - meshService.connectionManager.stopServer() - meshService.connectionManager.stopClient() - } - } - }) - } - 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)) - Switch(checked = wifiAwareEnabled, 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)) { @@ -280,7 +223,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 = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) + Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } // Removed aggregate labels; we will show per-direction compact labels below titles // Toggle: overall vs per-connection vs per-peer @@ -529,43 +472,6 @@ 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)) { - 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 running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() - Text(if (running) "running" else "stopped", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.startIfPossible() }, label = { Text("Start") }) - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.stop() }, label = { Text("Stop") }) - AssistChip(onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() }, 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/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt deleted file mode 100644 index d10c6741..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ /dev/null @@ -1,116 +0,0 @@ -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 - -/** - * 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 var service: WifiAwareMeshService? = null - private var appContext: Context? = null - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private val _enabled = MutableStateFlow(false) - val enabled: StateFlow = _enabled.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 - 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() { - if (_running.value) return - val ctx = appContext ?: return - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Log.w(TAG, "Wi‑Fi Aware requires Android 10 (Q)+; disabled.") - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware not supported on this device (requires Android 10+)")) } catch (_: Exception) {} - 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") - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Grant Nearby Wi‑Fi Devices to start Wi‑Fi Aware")) } catch (_: Exception) {} - return - } - } - try { - service = WifiAwareMeshService(ctx).also { - it.startServices() - _running.value = true - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } 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) {} - } - } - - fun stop() { - try { service?.stopServices() } catch (_: Exception) { } - service = null - _running.value = false - try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} - } - - fun getService(): WifiAwareMeshService? = service - - // Optional bridge to BLE mesh for cross-transport relaying - @Volatile private var bleMesh: com.bitchat.android.mesh.BluetoothMeshService? = null - fun setBleMeshService(svc: com.bitchat.android.mesh.BluetoothMeshService) { bleMesh = svc } - fun getBleMeshService(): com.bitchat.android.mesh.BluetoothMeshService? = bleMesh -} 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 deleted file mode 100644 index f895a7cb..00000000 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ /dev/null @@ -1,1277 +0,0 @@ -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.util.Log -import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission -import com.bitchat.android.crypto.EncryptionService -import com.bitchat.android.model.* -import com.bitchat.android.protocol.* -import com.bitchat.android.sync.GossipSyncManager -import com.bitchat.android.util.toHexString -// Mesh-layer components are reused from the existing Bluetooth stack -import com.bitchat.android.mesh.PeerManager -import com.bitchat.android.mesh.PeerManagerDelegate -import com.bitchat.android.mesh.PeerInfo -import com.bitchat.android.mesh.FragmentManager -import com.bitchat.android.mesh.SecurityManager -import com.bitchat.android.mesh.SecurityManagerDelegate -import com.bitchat.android.mesh.StoreForwardManager -import com.bitchat.android.mesh.StoreForwardManagerDelegate -import com.bitchat.android.mesh.MessageHandler -import com.bitchat.android.mesh.MessageHandlerDelegate -import com.bitchat.android.mesh.PacketProcessor -import com.bitchat.android.mesh.PacketProcessorDelegate -import kotlinx.coroutines.* -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.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors - -/** - * 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) { - - 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" - } - - // Core crypto/services - private val encryptionService = EncryptionService(context) - - // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) - val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) - - // Core components - private val peerManager = PeerManager() - private 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) - - // Gossip sync - private val gossipSyncManager: GossipSyncManager - - // Wi-Fi Aware transport - private val awareManager = context.getSystemService(WifiAwareManager::class.java) - private var wifiAwareSession: WifiAwareSession? = null - private var publishSession: PublishDiscoverySession? = null - private var subscribeSession: SubscribeDiscoverySession? = null - private val listenerExec = Executors.newCachedThreadPool() - private var isActive = false - - // Delegate - var delegate: WifiAwareMeshDelegate? = null - - // Transport state - private val peerSockets = ConcurrentHashMap() - private val serverSockets = ConcurrentHashMap() - private val networkCallbacks = ConcurrentHashMap() - private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - private val handleToPeerId = ConcurrentHashMap() // discovery mapping - private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time - - // Timestamp dedupe - private val lastTimestamps = ConcurrentHashMap() - - // Coroutines - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - init { - setupDelegates() - messageHandler.packetProcessor = packetProcessor - - // Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor) - val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager - if (shared != null) { - gossipSyncManager = shared - } else { - gossipSyncManager = GossipSyncManager( - myPeerID = myPeerID, - scope = serviceScope, - configProvider = object : GossipSyncManager.ConfigProvider { - override fun seenCapacity(): Int = 500 - override fun gcsMaxBytes(): Int = 400 - override fun gcsTargetFpr(): Double = 0.01 - } - ) - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) - } - override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(packet)) - } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) - } - } - } - } - - /** - * Helper method hexToBa. - */ - private fun hexStringToByteArray(hex: String): ByteArray { - val out = ByteArray(8) - var idx = 0 - var s = hex - while (s.length >= 2 && idx < 8) { - val b = s.substring(0, 2).toIntOrNull(16)?.toByte() ?: 0 - out[idx++] = b - s = s.drop(2) - } - return out - } - - /** - * Sign packet before broadcasting. - */ - private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket { - val data = packet.toBinaryDataForSigning() ?: return packet - val sig = encryptionService.signData(data) ?: return packet - return packet.copy(signature = sig) - } - - /** - * Broadcasts raw bytes to currently connected peer. - */ - private fun broadcastRaw(bytes: ByteArray) { - var sent = 0 - peerSockets.forEach { (pid, sock) -> - try { - sock.getOutputStream().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})") - } - - /** - * 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})") - // Wi-Fi Aware uses full packets; no fragmentation - val data = routed.packet.toBinaryData() ?: return - serviceScope.launch { broadcastRaw(data) } - } - - // 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) { - // Wi-Fi Aware uses full packets; no fragmentation - val data = packet.toBinaryData() ?: return - serviceScope.launch { - val sock = peerSockets[peerID] - if (sock == null) { - Log.w(TAG, "TX: no socket for ${peerID.take(8)}") - return@launch - } - try { - sock.getOutputStream().write(data) - Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})") - } catch (e: IOException) { - Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}") - } - } - } - - /** - * Configures delegates for internal components so that events are routed back - * through this service and ultimately to the {@link WifiAwareMeshDelegate}. - */ - private fun setupDelegates() { - peerManager.delegate = object : PeerManagerDelegate { - override fun onPeerListUpdated(peerIDs: List) { - delegate?.didUpdatePeerList(peerIDs) - } - override fun onPeerRemoved(peerID: String) { - try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { } - try { encryptionService.removePeer(peerID) } catch (_: Exception) { } - } - } - - securityManager.delegate = object : SecurityManagerDelegate { - override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { - serviceScope.launch { - delay(100) - sendAnnouncementToPeer(peerID) - delay(1000) - storeForwardManager.sendCachedMessages(peerID) - } - } - override fun sendHandshakeResponse(peerID: String, response: ByteArray) { - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = response, - ttl = MAX_TTL - ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - override fun getPeerInfo(peerID: String): PeerInfo? { - return peerManager.getPeerInfo(peerID) - } - } - - storeForwardManager.delegate = object : StoreForwardManagerDelegate { - override fun isFavorite(peerID: String) = delegate?.isFavorite(peerID) ?: false - override fun isPeerOnline(peerID: String) = peerManager.isPeerActive(peerID) - override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(packet)) - } - } - - messageHandler.delegate = object : MessageHandlerDelegate { - override fun addOrUpdatePeer(peerID: String, nickname: String) = - 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) = - peerManager.getPeerNickname(peerID) - override fun getNetworkSize() = peerManager.getActivePeerCount() - override fun getMyNickname() = delegate?.getNickname() - override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) - override fun updatePeerInfo( - peerID: String, - nickname: String, - noisePublicKey: ByteArray, - signingPublicKey: ByteArray, - isVerified: Boolean - ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) - - override fun sendPacket(packet: BitchatPacket) { - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - override fun relayPacket(routed: RoutedPacket) { broadcastPacket(routed) } - override fun getBroadcastRecipient() = SpecialRecipients.BROADCAST - - override fun verifySignature(packet: BitchatPacket, peerID: String) = - securityManager.verifySignature(packet, peerID) - override fun encryptForPeer(data: ByteArray, recipientPeerID: String) = - securityManager.encryptForPeer(data, recipientPeerID) - override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String) = - securityManager.decryptFromPeer(encryptedData, senderPeerID) - override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = - encryptionService.verifyEd25519Signature(signature, data, publicKey) - - override fun hasNoiseSession(peerID: String) = - encryptionService.hasEstablishedSession(peerID) - override fun initiateNoiseHandshake(peerID: String) { - serviceScope.launch { - val hs = encryptionService.initiateHandshake(peerID) ?: return@launch - val packet = BitchatPacket( - version = 1u, - type = MessageType.NOISE_HANDSHAKE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(peerID), - timestamp = System.currentTimeMillis().toULong(), - payload = hs, - ttl = MAX_TTL - ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - } - override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = - 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(TAG, "Updated peer binding to $newPeerID, fp=${fingerprint.take(16)}") - } - - override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String) = - delegate?.decryptChannelMessage(encryptedContent, channel) - - override fun onMessageReceived(message: BitchatMessage) { - 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) - } - } - - packetProcessor.delegate = object : PacketProcessorDelegate { - override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = - securityManager.validatePacket(packet, peerID) - override fun updatePeerLastSeen(peerID: String) = peerManager.updatePeerLastSeen(peerID) - override fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID) - override fun getNetworkSize(): Int = peerManager.getActivePeerCount() - override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST - - override fun handleNoiseHandshake(routed: RoutedPacket): Boolean = - runBlocking { securityManager.handleNoiseHandshake(routed) } - - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } - } - - override fun handleAnnounce(routed: RoutedPacket) { - serviceScope.launch { - val isFirst = messageHandler.handleAnnounce(routed) - routed.peerID?.let { pid -> - try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } - } - try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } - } - } - - override fun handleMessage(routed: RoutedPacket) { - serviceScope.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) { - serviceScope.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@WifiAwareMeshService.sendAnnouncementToPeer(peerID) - override fun sendCachedMessages(peerID: String) = storeForwardManager.sendCachedMessages(peerID) - override fun relayPacket(routed: RoutedPacket) = broadcastPacket(routed) - - override fun handleRequestSync(routed: RoutedPacket) { - val fromPeer = routed.peerID ?: return - val req = RequestSyncPacket.decode(routed.packet.payload) ?: return - gossipSyncManager.handleRequestSync(fromPeer, req) - } - } - } - - /** - * 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 - ]) - fun startServices() { - if (isActive) return - isActive = true - Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID") - - awareManager?.attach(object : AttachCallback() { - @SuppressLint("MissingPermission") - @RequiresPermission(allOf = [ - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.NEARBY_WIFI_DEVICES - ]) - override fun onAttached(session: WifiAwareSession) { - 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) { - publishSession = pub - Log.d(TAG, "PUBLISH: onPublishStarted()") - } - override fun onServiceDiscovered( - peerHandle: PeerHandle, - serviceSpecificInfo: ByteArray, - matchFilter: List - ) { - val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } - handleToPeerId[peerHandle] = peerId - if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() - 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 (message.isEmpty()) return - val subscriberId = try { String(message) } catch (_: Exception) { "" } - if (subscriberId == myPeerID) return - - handleToPeerId[peerHandle] = subscriberId - if (subscriberId.isNotBlank()) discoveredTimestamps[subscriberId] = System.currentTimeMillis() - Log.d(TAG, "PUBLISH: got ping from $subscriberId; spinning up server") - handleSubscriberPing(publishSession!!, peerHandle) - } - }, - Handler(Looper.getMainLooper()) - ) - - // SUBSCRIBE (client role) - session.subscribe( - SubscribeConfig.Builder() - .setServiceName(SERVICE_NAME) - .build(), - object : DiscoverySessionCallback() { - override fun onSubscribeStarted(sub: SubscribeDiscoverySession) { - subscribeSession = sub - Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()") - } - override fun onServiceDiscovered( - peerHandle: PeerHandle, - serviceSpecificInfo: ByteArray, - matchFilter: List - ) { - val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } - handleToPeerId[peerHandle] = peerId - val msgId = (System.nanoTime() and 0x7fffffff).toInt() - subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) - if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() - Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)") - } - - @RequiresApi(Build.VERSION_CODES.Q) - override fun onMessageReceived( - peerHandle: PeerHandle, - message: ByteArray - ) { - if (message.isEmpty()) return - val peerId = handleToPeerId[peerHandle] ?: return - if (peerId == myPeerID) return - - Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B") - handleServerReady(peerHandle, message) - } - }, - Handler(Looper.getMainLooper()) - ) - } - override fun onAttachFailed() { - Log.e(TAG, "Wi-Fi Aware attach failed") - } - }, Handler(Looper.getMainLooper())) - - sendPeriodicBroadcastAnnounce() - gossipSyncManager.start() - } - - /** - * Stops the Wi-Fi Aware mesh services and cleans up sockets and sessions. - */ - fun stopServices() { - if (!isActive) return - isActive = false - Log.i(TAG, "Stopping Wi-Fi Aware mesh") - - sendLeaveAnnouncement() - - serviceScope.launch { - delay(200) - - gossipSyncManager.stop() - - networkCallbacks.values.forEach { runCatching { cm.unregisterNetworkCallback(it) } } - networkCallbacks.clear() - publishSession?.close(); publishSession = null - subscribeSession?.close(); subscribeSession = null - wifiAwareSession?.close(); wifiAwareSession = null - - serverSockets.values.forEach { it.closeQuietly() } - peerSockets.values.forEach { it.closeQuietly() } - handleToPeerId.clear() - serverSockets.clear() - peerSockets.clear() - - peerManager.shutdown() - fragmentManager.shutdown() - securityManager.shutdown() - storeForwardManager.shutdown() - messageHandler.shutdown() - packetProcessor.shutdown() - - serviceScope.cancel() - } - } - - /** - * Periodically broadcasts an ANNOUNCE packet (every ~30s) while the service is active, - * so new/idle peers can discover us without user action. - */ - private fun sendPeriodicBroadcastAnnounce() { - serviceScope.launch { - while (isActive) { - try { delay(30_000); sendBroadcastAnnounce() } catch (_: Exception) { } - } - } - } - - /** - * 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 (serverSockets.containsKey(peerId)) { - Log.v(TAG, "↪ already serving $peerId, skipping") - return - } - - val ss = ServerSocket(0) - serverSockets[peerId] = ss - val port = ss.localPort - - // Ensure port is set to reuse if connection was recently closed (TIME_WAIT) - try { - ss.reuseAddress = true - } catch (_: Exception) {} - - Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on port $port") - - val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle) - .setPskPassphrase(PSK) - .setPort(port) - .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() { - override fun onAvailable(network: Network) { - try { - val client = ss.accept() - try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") } - client.keepAlive = true - Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}") - peerSockets[peerId] = client - try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} - try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} - listenerExec.execute { listenToPeer(client, peerId) } - handleSubscriberKeepAlive(client, peerId, pubSession, peerHandle) - // Kick off Noise handshake for this logical peer - if (myPeerID < peerId) { - messageHandler.delegate?.initiateNoiseHandshake(peerId) - Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)") - } - // Ensure fast presence even before handshake settles - serviceScope.launch { delay(150); sendBroadcastAnnounce() } - } catch (ioe: IOException) { - Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe) - } - } - override fun onLost(network: Network) { - networkCallbacks.remove(peerId) - Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}") - } - } - - networkCallbacks[peerId] = cb - Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}") - cm.requestNetwork(req, cb) - - val readyId = (System.nanoTime() and 0x7fffffff).toInt() - val portBytes = ByteBuffer.allocate(4) - .order(ByteOrder.BIG_ENDIAN) - .putInt(port) - .array() - Handler(Looper.getMainLooper()).post { - try { - val sent = pubSession.sendMessage(peerHandle, readyId, portBytes) - 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: Socket, - peerId: String, - pubSession: PublishDiscoverySession, - peerHandle: PeerHandle - ) { - // TCP keep-alive pings - serviceScope.launch { - try { - val os = client.getOutputStream() - while (peerSockets.containsKey(peerId)) { - try { os.write(0) } catch (_: IOException) { break } - delay(2_000) - } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (peerSockets.containsKey(peerId)) { - try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) - } - } - } - - /** - * 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 = handleToPeerId[peerHandle] ?: return - if (amIServerFor(peerId)) return - if (peerSockets.containsKey(peerId)) { - Log.v(TAG, "↪ already client-connected to $peerId, skipping") - return - } - - val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int - Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port") - - val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle) - .setPskPassphrase(PSK) - .build() - val req = NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) - .setNetworkSpecifier(spec) - .build() - - val cb = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - // Do not bind process for Aware; use per-socket binding instead - } - override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { - if (peerSockets.containsKey(peerId)) return - val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return - val addr = info.peerIpv6Addr as? Inet6Address ?: return - - val lp = cm.getLinkProperties(network) - val iface = lp?.interfaceName - - try { - val sock = Socket() - try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") } - sock.tcpNoDelay = true - sock.keepAlive = true - - // 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 - } - - sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000) - Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)} addr=$scopedAddr:$port (iface=$iface)") - - peerSockets[peerId] = sock - try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} - try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} - listenerExec.execute { listenToPeer(sock, peerId) } - handleServerKeepAlive(sock, peerId, peerHandle) - // Kick off Noise handshake for this logical peer - if (myPeerID < peerId) { - messageHandler.delegate?.initiateNoiseHandshake(peerId) - Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)") - } - // 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) - } - } - override fun onLost(network: Network) { - networkCallbacks.remove(peerId) - Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}") - } - } - - networkCallbacks[peerId] = cb - Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}") - cm.requestNetwork(req, cb) - } - - /** - * Sends periodic TCP and discovery keep-alive messages for server connections. - */ - private fun handleServerKeepAlive( - sock: Socket, - peerId: String, - peerHandle: PeerHandle - ) { - // TCP keep-alive - serviceScope.launch { - try { - val os = sock.getOutputStream() - while (peerSockets.containsKey(peerId)) { - try { os.write(0) } catch (_: IOException) { break } - delay(2_000) - } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (peerSockets.containsKey(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) = 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: Socket, initialLogicalPeerId: String) { - val inStream = socket.getInputStream() - val buf = ByteArray(64 * 1024) - var routedPeerId: String? = null - - while (isActive) { - val len = try { inStream.read(buf) } catch (_: IOException) { break } - if (len <= 0) break - - val raw = buf.copyOf(len) - val pkt = BitchatPacket.fromBinaryData(raw) ?: continue - - val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue - if (senderPeerHex == myPeerID) continue - - val ts = pkt.timestamp - if (lastTimestamps.put(senderPeerHex, ts) == ts) { - continue - } - - if (routedPeerId == null) { - routedPeerId = senderPeerHex - peerSockets[routedPeerId] = socket - } - - Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})") - packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId)) - } - - // Breaking out of the loop means the socket is dead or service is stopping. - // We MUST notify the mesh layer so it removes the logical peer immediately to allow reconnection. - Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.") - handlePeerDisconnection(initialLogicalPeerId, routedPeerId) - socket.closeQuietly() - } - - private fun handlePeerDisconnection(initialId: String, routedId: String?) { - serviceScope.launch { - Log.d(TAG, "Cleaning up peer: $initialId / $routedId") - - peerSockets.remove(initialId)?.closeQuietly() - serverSockets.remove(initialId)?.closeQuietly() - networkCallbacks.remove(initialId)?.let { runCatching { cm.unregisterNetworkCallback(it) } } - peerManager.removePeer(initialId) - - routedId?.let { id -> - if (id != initialId) { - peerSockets.remove(id)?.closeQuietly() - serverSockets.remove(id)?.closeQuietly() - networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } } - peerManager.removePeer(id) - } - } - } - } - - /** - * 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 - */ - fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { - if (content.isEmpty()) return - - serviceScope.launch { - val packet = BitchatPacket( - version = 1u, - type = MessageType.MESSAGE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = content.toByteArray(Charsets.UTF_8), - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(packet) - broadcastPacket(RoutedPacket(signed)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - } - - /** - * 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) - */ - fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { - if (content.isEmpty() || recipientPeerID.isEmpty()) return - - serviceScope.launch { - val finalId = messageID ?: UUID.randomUUID().toString() - - if (encryptionService.hasEstablishedSession(recipientPeerID)) { - try { - val pm = PrivateMessagePacket(messageID = finalId, content = content) - val tlv = pm.encode() ?: return@launch - val payload = NoisePayload(type = NoisePayloadType.PRIVATE_MESSAGE, data = tlv).encode() - val enc = encryptionService.encrypt(payload, recipientPeerID) - - val pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) - } catch (e: Exception) { - Log.e(TAG, "Failed to encrypt private message: ${e.message}") - } - } else { - messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID) - } - } - } - - /** - * 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). - */ - fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { - serviceScope.launch { - try { - val payload = NoisePayload( - type = NoisePayloadType.READ_RECEIPT, - data = messageID.toByteArray(Charsets.UTF_8) - ).encode() - val enc = encryptionService.encrypt(payload, recipientPeerID) - val pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(pkt))) - } catch (e: Exception) { - Log.e(TAG, "Failed to send read receipt: ${e.message}") - } - } - } - - /** - * 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. - */ - fun sendFileBroadcast(file: BitchatFilePacket) { - try { - val payload = file.encode() ?: run { Log.e(TAG, "file TLV encode failed"); return } - serviceScope.launch { - val pkt = BitchatPacket( - version = 2u, // FILE_TRANSFER big length - type = MessageType.FILE_TRANSFER.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = payload, - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(pkt) - val transferId = sha256Hex(payload) - broadcastPacket(RoutedPacket(signed, transferId = transferId)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - } catch (e: Exception) { - Log.e(TAG, "sendFileBroadcast failed: ${e.message}", e) - } - } - - /** - * 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. - */ - fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) { - try { - serviceScope.launch { - if (!encryptionService.hasEstablishedSession(recipientPeerID)) { - messageHandler.delegate?.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 pkt = BitchatPacket( - version = 1u, - type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = hexStringToByteArray(recipientPeerID), - timestamp = System.currentTimeMillis().toULong(), - payload = enc, - signature = null, - ttl = MAX_TTL - ) - val signed = signPacketBeforeBroadcast(pkt) - val transferId = sha256Hex(tlv) - broadcastPacket(RoutedPacket(signed, transferId = transferId)) - } - } catch (e: Exception) { - Log.e(TAG, "sendFilePrivate failed: ${e.message}", e) - } - } - - /** - * 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. - */ - fun cancelFileTransfer(transferId: String): Boolean { - return false - } - - /** - * Computes the SHA-256 of the given bytes and returns a lowercase hex string. - * Falls back to the byte-length in hex if MessageDigest is unavailable. - */ - 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) } - - /** - * Broadcasts an ANNOUNCE packet to the entire mesh. - */ - fun sendBroadcastAnnounce() { - serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID - val staticKey = encryptionService.getStaticPublicKey() ?: run { - Log.e(TAG, "No static public key available for announcement"); return@launch - } - val signingKey = encryptionService.getSigningPublicKey() ?: run { - Log.e(TAG, "No signing public key available for announcement"); return@launch - } - - val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return@launch - - val announcePacket = BitchatPacket( - type = MessageType.ANNOUNCE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = tlvPayload - ) - val signed = signPacketBeforeBroadcast(announcePacket) - - broadcastPacket(RoutedPacket(signed)) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - } - - /** - * Sends an ANNOUNCE packet to a specific peer. - */ - fun sendAnnouncementToPeer(peerID: String) { - if (peerManager.hasAnnouncedToPeer(peerID)) return - - val nickname = delegate?.getNickname() ?: myPeerID - val staticKey = encryptionService.getStaticPublicKey() ?: return - val signingKey = encryptionService.getSigningPublicKey() ?: return - - val tlvPayload = IdentityAnnouncement(nickname, staticKey, signingKey).encode() ?: return - - val packet = BitchatPacket( - type = MessageType.ANNOUNCE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = tlvPayload - ) - val signed = signPacketBeforeBroadcast(packet) - - broadcastPacket(RoutedPacket(signed)) - peerManager.markPeerAsAnnouncedTo(peerID) - try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } - } - - /** - * Sends a LEAVE announcement to all peers before disconnecting. - */ - private fun sendLeaveAnnouncement() { - val nickname = delegate?.getNickname() ?: myPeerID - val packet = BitchatPacket( - type = MessageType.LEAVE.value, - ttl = MAX_TTL, - senderID = myPeerID, - payload = nickname.toByteArray() - ) - broadcastPacket(RoutedPacket(signPacketBeforeBroadcast(packet))) - } - - /** @return Mapping of peer IDs to nicknames. */ - fun getPeerNicknames(): Map = peerManager.getAllPeerNicknames() - - /** @return Mapping of peer IDs to RSSI values. */ - fun getPeerRSSI(): Map = peerManager.getAllPeerRSSI() - - /** - * @return true if a Noise session with the peer is fully established. - */ - fun hasEstablishedSession(peerID: String) = encryptionService.hasEstablishedSession(peerID) - - /** - * @return a human-readable Noise session state for the given peer (implementation-defined). - */ - fun getSessionState(peerID: String) = encryptionService.getSessionState(peerID) - - /** - * Triggers a Noise handshake with the given peer. Safe to call repeatedly; no-op if already handshaking/established. - */ - fun initiateNoiseHandshake(peerID: String) = messageHandler.delegate?.initiateNoiseHandshake(peerID) - - /** - * @return the stored public-key fingerprint (hex) for a peer, if known. - */ - fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID) - - /** - * Retrieves the full profile for a peer, including keys and verification state, if available. - */ - fun getPeerInfo(peerID: String): PeerInfo? = peerManager.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. - */ - fun updatePeerInfo( - peerID: String, - nickname: String, - noisePublicKey: ByteArray, - signingPublicKey: ByteArray, - isVerified: Boolean - ): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) - - /** - * @return the local device’s long-term identity fingerprint (hex). - */ - fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint() - - /** - * @return true if the UI should show an “encrypted” indicator for this peer. - */ - fun shouldShowEncryptionIcon(peerID: String) = encryptionService.hasEstablishedSession(peerID) - - /** - * @return a snapshot list of peers with established Noise sessions. - */ - fun getEncryptedPeers(): List = emptyList() - - /** - * @return the current IPv4/IPv6 address of a connected peer, if any. - * Prefers the scoped IPv6 address format. - */ - fun getDeviceAddressForPeer(peerID: String): String? = - peerSockets[peerID]?.let { resolveScopedAddress(it) } - - /** - * 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. - */ - fun getDeviceAddressToPeerMapping(): Map { - val map = mutableMapOf() - peerSockets.forEach { (pid, sock) -> - map[pid] = resolveScopedAddress(sock) ?: "unknown" - } - return map - } - - /** - * @return map of peer ID to nickname, bridged for UI warning fix. - */ - fun getPeerNicknamesMap(): Map = peerManager.getAllPeerNicknames() - - /** 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. - */ - fun printDeviceAddressesForPeers(): String = - getDeviceAddressToPeerMapping().entries.joinToString("\n") { "${it.key} -> ${it.value}" } - - /** - * @return A detailed string containing the debug status of all mesh components. - */ - fun getDebugStatus(): String = buildString { - appendLine("=== Wi-Fi Aware Mesh Debug Status ===") - appendLine("My Peer ID: $myPeerID") - appendLine("Peers: ${peerSockets.keys}") - appendLine(peerManager.getDebugInfo(getDeviceAddressToPeerMapping())) - appendLine(fragmentManager.getDebugInfo()) - appendLine(securityManager.getDebugInfo()) - appendLine(storeForwardManager.getDebugInfo()) - appendLine(messageHandler.getDebugInfo()) - appendLine(packetProcessor.getDebugInfo()) - } - - /** Utility extension to safely close sockets. */ - private fun Socket.closeQuietly() = try { close() } catch (_: Exception) {} - - /** Utility extension to safely close server sockets. */ - private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} -} - - -/** - * Delegate interface for mesh service callbacks (maintains exact same interface) - */ -interface WifiAwareMeshDelegate { - 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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? - fun getNickname(): String? - fun isFavorite(peerID: String): Boolean - // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager -} From 1b9f76be2307e040f0278182062ac56177babf31 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:35:06 +0700 Subject: [PATCH 08/24] Fix notification user try 2 (#541) * count peers in notification * cleanup --- .../java/com/bitchat/android/service/MeshForegroundService.kt | 4 ++-- 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 | 1 + 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 | 1 + 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 | 1 + 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 | 1 + 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 | 1 + 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 | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 +- 35 files changed, 36 insertions(+), 3 deletions(-) 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 e92274f8..998c03b2 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -276,7 +276,7 @@ class MeshForegroundService : Service() { } else true } - private fun buildNotification(activeUsers: Int): Notification { + private fun buildNotification(activePeers: Int): Notification { val openIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, openIntent, @@ -291,7 +291,7 @@ class MeshForegroundService : Service() { ) val title = getString(R.string.app_name) - val content = getString(R.string.mesh_service_notification_content, activeUsers) + val content = getString(R.string.mesh_service_notification_content, activePeers) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 94414ce5..3f6d7ddf 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,4 +362,5 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + الشبكة تعمل — %1$d أقران diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a9f82658..9948b452 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,4 +349,5 @@ %d জন + মেশ চলছে — %1$d পিয়ার diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 786c9dcb..84eb0fd4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -363,4 +363,5 @@ sei der Erste, der hier eine Notiz hinterlässt. schließen füge eine Notiz zu diesem Ort hinzu + Mesh läuft — %1$d Peers diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index acf3cb92..b72bfdf3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -362,4 +362,5 @@ sé el primero en añadir una para este sitio. cerrar agrega una nota para este lugar + Mesh en ejecución — %1$d pares diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2896a97d..4007cf1a 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,4 +349,5 @@ %d نفر + مش در حال اجرا — %1$d همتا diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index bb150d93..b89824d6 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -362,4 +362,5 @@ %d tao + Tumatakbo ang Mesh — %1$d na peer diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b1936bce..e0e7c7eb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -376,4 +376,5 @@ soyez le premier à en ajouter pour cet endroit. fermer ajoutez une note pour cet endroit + Mesh actif — %1$d pairs diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a15f87af..059924d4 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,5 +14,6 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + רשת Mesh פועלת — %1$d עמיתים diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 20981383..780015b0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -362,4 +362,5 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + मेश चल रहा है — %1$d पीयर्स diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 00f8e7cf..744a499c 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -362,4 +362,5 @@ jadilah yang pertama menambahkan catatan untuk tempat ini. tutup tambahkan catatan untuk tempat ini + Mesh berjalan — %1$d peer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d086ba3e..185dfa3f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,4 +396,5 @@ sii il primo ad aggiungerne una per questo posto. chiudi aggiungi una nota per questo luogo + Mesh in esecuzione — %1$d peer diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index be41d3d4..7d8f530f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -362,4 +362,5 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + メッシュ実行中 — %1$d ピア diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index dbf16225..7c999d96 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -349,4 +349,5 @@ %d ადამიანი + Mesh გაშვებულია — %1$d პირები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3163fa0c..bf9b6d53 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -362,4 +362,5 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 메시 실행 중 — %1$d 피어 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 8d359165..4bed7bc0 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -376,4 +376,5 @@ Olona %d + Mandeha ny Mesh — %1$d peers diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 21d90e30..5027aade 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,5 +1,6 @@ + Mesh sedang berjalan — %1$d rakan diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 80deea92..bae76cf0 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,4 +362,5 @@ %d जना + मेश चलिरहेको छ — %1$d पियर्स diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8d4d3096..40fdf86b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -394,4 +394,5 @@ wees de eerste die een notitie voor deze plek toevoegt. sluiten voeg een notitie toe voor deze plek + Mesh actief — %1$d peers diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 2c3b7729..80ea2b84 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -349,4 +349,5 @@ %d بندے + میش چل رہا ہے — %1$d ساتھی diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 492b0af0..daa016d6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,5 +14,6 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Mesh działa — %1$d peerów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7d1998be..d736dd39 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -362,4 +362,5 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Mesh rodando — %1$d pares diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 207810b4..8e87c6e6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -362,4 +362,5 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Mesh em execução — %1$d pares diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85fd0ba4..c6c3bd37 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,4 +352,5 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Mesh запущен — %1$d пиров diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 62d8ef18..d461be4c 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -350,4 +350,5 @@ 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 + Mesh körs — %1$d peers diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 76893af8..c9e8943c 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,5 +1,6 @@ + மெஷ் இயங்குகிறது — %1$d பியர்ஸ் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c1cbedbf..9ae074c7 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -349,4 +349,5 @@ %d คน + Mesh กำลังทำงาน — %1$d เพื่อน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cb728f5c..291c9904 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -350,4 +350,5 @@ bu yer için ilk notu ekleyen siz olun. kapat bu yer için bir not ekleyin + Mesh çalışıyor — %1$d eş diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c644c198..16914c6a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,5 +1,6 @@ + Mesh працює — %1$d пірів diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index f6a5832c..b782115f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,4 +362,5 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + میش چل رہا ہے — %1$d ساتھی diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7a7df41f..ec726fc9 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -349,4 +349,5 @@ %d người + Mesh đang chạy — %1$d ngang hàng diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6ab11776..b979b121 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,5 +14,6 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + 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 b119edfb..7be4525f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,5 +14,6 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + Mesh 運行中 — %1$d 個節點 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1ae11941..17f53bbd 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,4 +375,5 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + Mesh 运行中 — %1$d 个节点 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a985c6c..d85513de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,7 +64,7 @@ Mesh Background Service Keeps the Bluetooth mesh running in the background - Mesh running — %1$d users connected + Mesh running — %1$d peers Add to favorites From 7c183b0c8d14de051cfacfac8bb4745d19895296 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 4 Jan 2026 06:08:02 +0000 Subject: [PATCH 09/24] Automated update of relay data - Sun Jan 4 06:08:02 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 569 +++++++++++++-------------- 1 file changed, 282 insertions(+), 287 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index ab075fb4..345b731b 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,295 +1,290 @@ Relay URL,Latitude,Longitude -strfry.shock.network,39.0438,-77.4874 -wot.nostr.party,36.1627,-86.7816 -nostr.blankfors.se,60.1699,24.9384 -nostr.now,36.55,139.733 -nostr.simplex.icu,51.5121,-0.0005238 -relay.threenine.services,51.5524,-0.29686 -relay.barine.co,43.6532,-79.3832 -theoutpost.life,64.1476,-21.9392 -purplerelay.com,50.1109,8.68213 -nostrue.com,40.8054,-74.0241 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -shu02.shugur.net,21.4902,39.2246 -relayone.geektank.ai,18.2148,-63.0574 -relay03.lnfi.network,39.0997,-94.5786 -soloco.nl,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.azzamo.net,52.2633,21.0283 -nostr.red5d.dev,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -nostr.tadryanom.me,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 -orly.ft.hn,50.4754,12.3683 -relay02.lnfi.network,39.0997,-94.5786 -relay.nosto.re,51.1792,5.89444 -strfry.openhoofd.nl,51.9229,4.40833 -relays.diggoo.com,43.6532,-79.3832 -relay.nostrhub.tech,49.0291,8.35696 -relay.artx.market,43.652,-79.3633 -relay.sigit.io,50.4754,12.3683 -nostr-relay.psfoundation.info,39.0438,-77.4874 -wot.brightbolt.net,47.6735,-116.781 -relay.bitcoinartclock.com,50.4754,12.3683 -khatru.nostrver.se,51.1792,5.89444 -relay.cosmicbolt.net,37.3986,-121.964 -adre.su,59.9311,30.3609 -nostrelay.memory-art.xyz,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -nproxy.kristapsk.lv,60.1699,24.9384 -relay.ngengine.org,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.commonshub.brussels,49.4543,11.0746 -wot.shaving.kiwi,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -relay.thibautduchene.fr,43.6532,-79.3832 -nostr-relay.corb.net,38.8353,-104.822 -wot.soundhsa.com,33.1384,-95.6011 -relay.jabato.space,52.52,13.405 -relay.credenso.cafe,43.3601,-80.3127 -relayone.soundhsa.com,33.1384,-95.6011 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.samt.st,40.8302,-74.1299 -relay.vrtmrz.net,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.npubhaus.com,43.6532,-79.3832 -strfry.elswa-dev.online,50.1109,8.68213 -relay.wavefunc.live,34.0362,-118.443 -wot.sovbit.host,64.1466,-21.9426 -relay.smies.me,33.7501,-84.3885 -nostr.bilthon.dev,25.8128,-80.2377 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -santo.iguanatech.net,40.8302,-74.1299 -prl.plus,42.6978,23.3246 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostream.breadslice.com,1.35208,103.82 -nostr.notribe.net,40.8302,-74.1299 -relay.fountain.fm,39.0997,-94.5786 -orangepiller.org,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -nostr.camalolo.com,24.1469,120.684 -okn.czas.top,51.267,6.81738 -relay.wolfcoil.com,35.6092,139.73 -nostr.quali.chat,60.1699,24.9384 -nostr-relay.online,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -ribo.eu.nostria.app,52.3676,4.90414 -wot.yesnostr.net,50.9871,2.12554 -nostr.overmind.lol,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -relay.hasenpfeffr.com,39.0438,-77.4874 -nostrelites.org,41.8781,-87.6298 -strfry.bonsai.com,37.8715,-122.273 -relay.agora.social,50.7383,15.0648 alien.macneilmediagroup.com,43.6532,-79.3832 -relay.getsafebox.app,43.6532,-79.3832 -relay.nuts.cash,34.0362,-118.443 -notemine.io,52.2026,20.9397 -relay.nostr.place,32.7767,-96.797 -relay.21e6.cz,50.7383,15.0648 -nostr.casa21.space,43.6532,-79.3832 -premium.primal.net,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.rblb.it,43.7094,10.6582 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.camelus.app,45.5201,-122.99 -relay.origin.land,35.6673,139.751 -relay.fr13nd5.com,52.5233,13.3426 -nos.lol,50.4754,12.3683 -wot.nostr.net,43.6532,-79.3832 -relay.javi.space,43.4633,11.8796 -relay2.ngengine.org,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -pyramid.treegaze.com,43.6532,-79.3832 -relay.letsfo.com,52.2633,21.0283 -relay.nostrzh.org,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -relay.0xchat.com,1.35208,103.82 -nostr.czas.top,50.1109,8.68213 -nostr.superfriends.online,43.6532,-79.3832 -hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -nostr.88mph.life,51.5072,-0.127586 -strfry.ymir.cloud,34.0965,-117.585 -nostr.lkjsxc.com,43.6532,-79.3832 -yabu.me,35.6092,139.73 -relay.chorus.community,50.1109,8.68213 -nostr-02.yakihonne.com,1.32123,103.695 -neuromancer.nettek.io,39.1429,-94.573 -relay04.lnfi.network,39.0997,-94.5786 -nostr-relay.amethyst.name,39.0438,-77.4874 -nostrcheck.tnsor.network,43.6532,-79.3832 -wot.nostr.place,32.7767,-96.797 -cyberspace.nostr1.com,40.7057,-74.0136 -relay.guggero.org,47.3769,8.54169 -nostr.calitabby.net,39.9268,-75.0246 -x.kojira.io,43.6532,-79.3832 -nostr.agentcampfire.com,52.3676,4.90414 -relay.bullishbounty.com,43.6532,-79.3832 -relay.nostrverse.net,43.6532,-79.3832 -nostr.robosats.org,64.1476,-21.9392 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr.huszonegy.world,47.4979,19.0402 -relay.cypherflow.ai,48.8566,2.35222 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.faultables.net,43.6532,-79.3832 -relay.libernet.app,43.6532,-79.3832 -relay.magiccity.live,25.8128,-80.2377 -relay.wellorder.net,45.5201,-122.99 -black.nostrcity.club,48.8575,2.35138 -relay.jeffg.fyi,43.6532,-79.3832 -freeben666.fr,43.7221,7.15296 -relay.nostr.wirednet.jp,34.706,135.493 -nostrelay.circum.space,52.3676,4.90414 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.nostriot.com,41.5695,-83.9786 -relayrs.notoshi.win,43.6532,-79.3832 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr.vulpem.com,49.4543,11.0746 -nostr.sathoarder.com,48.5734,7.75211 -wheat.happytavern.co,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -nostr-02.czas.top,51.2277,6.77346 -vault.iris.to,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -nostr.nodesmap.com,59.3327,18.0656 -nostr.n7ekb.net,47.4941,-122.294 -relayb.uid.ovh,43.6532,-79.3832 -shu05.shugur.net,48.8566,2.35222 -dev-relay.lnfi.network,39.0997,-94.5786 -relay.bitcoindistrict.org,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.zone667.com,60.1699,24.9384 -nostr-dev.wellorder.net,45.5201,-122.99 -nos.xmark.cc,50.6924,3.20113 -relay.etch.social,41.2619,-95.8608 -nostr.na.social,43.6532,-79.3832 -relay.orangepill.ovh,49.1689,-0.358841 -relay.olas.app,50.4754,12.3683 -relay.holzeis.me,43.6532,-79.3832 -relay2.angor.io,48.1046,11.6002 -relay.degmods.com,50.4754,12.3683 -vitor.nostr1.com,40.7128,-74.006 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -czas.xyz,48.8566,2.35222 -nostr.bitcoiner.social,39.1585,-94.5728 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -inbox.azzamo.net,52.2633,21.0283 -nostr.ovia.to,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3676,4.90414 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.bond,50.1109,8.68213 -relay01.lnfi.network,39.0997,-94.5786 -relay.seq1.net,43.6532,-79.3832 -nostr.rikmeijer.nl,50.4754,12.3683 -offchain.pub,47.6743,-117.112 -nostr.spaceshell.xyz,43.6532,-79.3832 -nos4smartnkind.tech,40.1872,44.5152 -kotukonostr.onrender.com,37.7775,-122.397 -nostr.ps1829.com,33.8851,130.883 -relay.lightning.pub,39.0438,-77.4874 -nostr-relay.gateway.in.th,15.2634,100.344 -relay.thebluepulse.com,49.4521,11.0767 -relay.malxte.de,52.52,13.405 -relay.usefusion.ai,38.7134,-78.1591 -nostr-relay.cbrx.io,43.6532,-79.3832 -shu01.shugur.net,21.4902,39.2246 -nostr.jerrynya.fun,31.2304,121.474 -dev-nostr.bityacht.io,25.0797,121.234 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.primal.net,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -nostr-relay.nextblockvending.com,47.2343,-119.853 v-relay.d02.vrtmrz.net,34.6937,135.502 -nostr.mikoshi.de,47.74,12.0917 -nostr.coincards.com,53.5501,-113.469 -relay.snort.social,53.3498,-6.26031 -bitcoiner.social,39.1585,-94.5728 -nostr.noones.com,50.1109,8.68213 -wot.dergigi.com,64.1476,-21.9392 +relay.thebluepulse.com,49.4521,11.0767 +relay.guggero.org,47.3769,8.54169 relay.lumina.rocks,49.0291,8.35695 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.snowbla.de,60.1699,24.9384 -strfry.felixzieger.de,50.1013,8.62643 -relay.satlantis.io,32.8769,-80.0114 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.davidebtc.me,51.5072,-0.127586 -relay.mattybs.lol,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -relay.tagayasu.xyz,43.6715,-79.38 -nostr-relay.zimage.com,34.0549,-118.243 -nostrcheck.me,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.siamdev.cc,13.9178,100.424 -r.bitcoinhold.net,43.6532,-79.3832 -skeme.vanderwarker.family,40.8218,-74.45 -articles.layer3.news,37.3387,-121.885 -no.str.cr,9.92857,-84.0528 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.comcomponent.com,34.7062,135.493 -relay.illuminodes.com,47.6061,-122.333 -nostr.openhoofd.nl,51.9229,4.40833 -nostr.plantroon.com,50.1013,8.62643 -slick.mjex.me,39.048,-77.4817 -relay.minibolt.info,43.6532,-79.3832 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.nostrdice.com,-33.8688,151.209 -wot.dtonon.com,43.6532,-79.3832 -shu04.shugur.net,25.2604,55.2989 -nostr.tavux.tech,48.8575,2.35138 -nostr-02.uid.ovh,43.6532,-79.3832 -relay.nostrcheck.me,43.6532,-79.3832 -relay.nsnip.io,60.1699,24.9384 -relay.toastr.net,40.8054,-74.0241 -relay.divine.video,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -wot.sebastix.social,51.1792,5.89444 -relay.openfarmtools.org,60.1699,24.9384 -relay.damus.io,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -wot.sudocarlos.com,51.5072,-0.127586 -relay.arx-ccn.com,50.4754,12.3683 -nostr.zoracle.org,45.6018,-121.185 -nostr.tac.lol,47.4748,-122.273 -relay.coinos.io,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.davidebtc.me,51.5072,-0.127586 -ribo.us.nostria.app,41.5868,-93.625 -relay.upleb.uk,51.9194,19.1451 -librerelay.aaroniumii.com,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -relay.electriclifestyle.com,26.2897,-80.1293 -relay.nostrhub.fr,48.1045,11.6004 -relay.agorist.space,52.3734,4.89406 -freelay.sovbit.host,64.1476,-21.9392 -nostr.hifish.org,47.4043,8.57398 -nostr-01.yakihonne.com,1.32123,103.695 -relay.routstr.com,43.6532,-79.3832 -nr.yay.so,46.2126,6.1154 -bcast.seutoba.com.br,43.6532,-79.3832 -fanfares.nostr1.com,40.7128,-74.006 -nostr.girino.org,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -relay.notoshi.win,13.311,101.112 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -satsage.xyz,37.3986,-121.964 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr-relay.online,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 relay.evanverma.com,40.8302,-74.1299 -nostr-2.21crypto.ch,47.5356,8.73209 +relay2.angor.io,48.1046,11.6002 +relay.arx-ccn.com,50.4754,12.3683 +relay.davidebtc.me,51.5072,-0.127586 +temp.iris.to,43.6532,-79.3832 +nostr.superfriends.online,43.6532,-79.3832 +vitor.nostr1.com,40.7057,-74.0136 +relay.moinsen.com,50.4754,12.3683 +ithurtswhenip.ee,51.223,6.78245 +nostr.tadryanom.me,43.6532,-79.3832 +relay.nostr.wirednet.jp,34.706,135.493 +relay.0xchat.com,1.35208,103.82 +nostrcheck.tnsor.network,43.6532,-79.3832 +relay.ngengine.org,43.6532,-79.3832 relay.mitchelltribe.com,39.0438,-77.4874 -relaynostr.breadslice.com,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -nostr.mom,50.4754,12.3683 +wotr.relatr.xyz,53.3498,-6.26031 +relay.chorus.community,50.1109,8.68213 +orly.ft.hn,50.4754,12.3683 +nostr.stakey.net,52.3676,4.90414 +relay.malxte.de,52.52,13.405 +nostrelay.circum.space,52.3676,4.90414 +nostrcheck.me,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +relay.routstr.com,43.6532,-79.3832 +relay.siamdev.cc,13.8434,100.363 +wot.sovbit.host,64.1466,-21.9426 +strfry.bonsai.com,37.8715,-122.273 +freeben666.fr,43.7221,7.15296 +okn.czas.top,51.267,6.81738 +relay.nostar.org,43.6532,-79.3832 +relay.jeffg.fyi,43.6532,-79.3832 +nostr-02.uid.ovh,43.6532,-79.3832 +relay.fr13nd5.com,52.5233,13.3426 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.sigit.io,50.4754,12.3683 +nostr.girino.org,43.6532,-79.3832 +bitsat.molonlabe.holdings,51.4012,-1.3147 +nostream.breadslice.com,1.35208,103.82 +nostr.rtvslawenia.com,49.4543,11.0746 +nostr.openhoofd.nl,51.9229,4.40833 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.etch.social,41.2619,-95.8608 +strfry.felixzieger.de,50.1013,8.62643 +relay.wavlake.com,41.2619,-95.8608 +relay.fundstr.me,42.3601,-71.0589 +slick.mjex.me,39.048,-77.4817 +relay.upleb.uk,51.9194,19.1451 +nos4smartnkind.tech,40.1872,44.5152 +discovery.eu.nostria.app,52.3676,4.90414 +nostr.tac.lol,47.4748,-122.273 +wot.nostr.net,43.6532,-79.3832 +nostr-dev.wellorder.net,45.5201,-122.99 +fanfares.nostr1.com,40.7128,-74.006 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.nsnip.io,60.1699,24.9384 +black.nostrcity.club,48.8575,2.35138 +nostr-02.czas.top,51.2277,6.77346 +schnorr.me,43.6532,-79.3832 +relay.fountain.fm,39.0997,-94.5786 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.divine.video,43.6532,-79.3832 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +relay.hasenpfeffr.com,39.0438,-77.4874 +relay.chakany.systems,43.6532,-79.3832 +nostr-01.yakihonne.com,1.32123,103.695 +nostr.azzamo.net,52.2633,21.0283 +nostr.bond,50.1109,8.68213 +nostr.coincards.com,53.5501,-113.469 +relay.nostrhub.tech,49.0291,8.35696 +relay.cosmicbolt.net,37.3986,-121.964 +nostr.21crypto.ch,47.5356,8.73209 +relay-dev.satlantis.io,40.8302,-74.1299 +offchain.pub,47.6743,-117.112 +relay.orangepill.ovh,49.1689,-0.358841 +nostr-relay.gateway.in.th,15.2634,100.344 +nostr.now,36.55,139.733 +relay.usefusion.ai,38.7134,-78.1591 +nostr.lkjsxc.com,43.6532,-79.3832 +nproxy.kristapsk.lv,60.1699,24.9384 +relay.nostrverse.net,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +relay.origin.land,35.6673,139.751 +relay.damus.io,43.6532,-79.3832 +relay.bnos.space,1.35208,103.82 +relay.endfiat.money,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +nostr.huszonegy.world,47.4979,19.0402 +theoutpost.life,64.1476,-21.9392 +nostr.noones.com,50.1109,8.68213 +relay.21e6.cz,50.7383,15.0648 +nostr-relay.xbytez.io,50.6924,3.20113 +wot.shaving.kiwi,43.6532,-79.3832 +relay.trustroots.org,43.6532,-79.3832 +wot.dtonon.com,43.6532,-79.3832 +nostr.camalolo.com,24.1469,120.684 +czas.xyz,48.8566,2.35222 +relayrs.notoshi.win,43.6532,-79.3832 +skeme.vanderwarker.family,40.8218,-74.45 +nostr.red5d.dev,43.6532,-79.3832 +relay.javi.space,43.4633,11.8796 +relay.nuts.cash,34.0362,-118.443 +nostr.commonshub.brussels,49.4543,11.0746 +relay.artx.market,43.652,-79.3633 +satsage.xyz,37.3986,-121.964 +relay.nostx.io,43.6532,-79.3832 +shu02.shugur.net,21.4902,39.2246 +strfry.ymir.cloud,34.0965,-117.585 relay.nostr-check.me,43.6532,-79.3832 +relay04.lnfi.network,39.0997,-94.5786 +kotukonostr.onrender.com,37.7775,-122.397 +nostr.davidebtc.me,51.5072,-0.127586 +relay2.ngengine.org,43.6532,-79.3832 +ribo.us.nostria.app,41.5868,-93.625 +nostr.czas.top,50.1109,8.68213 +relay.nostrzh.org,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +alienos.libretechsystems.xyz,55.4724,9.87335 +vault.iris.to,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostr.quali.chat,60.1699,24.9384 +wheat.happytavern.co,43.6532,-79.3832 +relay.olas.app,50.4754,12.3683 +nostr-relayrs.gateway.in.th,15.2634,100.344 +relay.snort.social,53.3498,-6.26031 +nostr-2.21crypto.ch,47.5356,8.73209 +pyramid.treegaze.com,43.6532,-79.3832 +relay.smies.me,33.7501,-84.3885 +wot.dergigi.com,64.1476,-21.9392 +nostr-relay.zimage.com,34.0549,-118.243 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.jerrynya.fun,31.2304,121.474 +purplerelay.com,50.1109,8.68213 +relay.comcomponent.com,34.7062,135.493 +relay.unitypay.cash,41.4513,-81.7021 +nostr.mikoshi.de,50.1109,8.68213 +prl.plus,42.6978,23.3246 +shu04.shugur.net,25.2604,55.2989 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +nostr.zoracle.org,45.6018,-121.185 +nostriches.club,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +nostr.vulpem.com,49.4543,11.0746 +bucket.coracle.social,37.7775,-122.397 +wot.yesnostr.net,50.9871,2.12554 +wot.nostr.place,32.7767,-96.797 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +relay.getsafebox.app,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +nostr.blankfors.se,60.1699,24.9384 +premium.primal.net,43.6532,-79.3832 +nostr.rikmeijer.nl,50.4754,12.3683 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +adre.su,59.9311,30.3609 +nostr.n7ekb.net,47.4941,-122.294 +nostr.hekster.org,37.3986,-121.964 +relay.jabato.space,52.52,13.405 +fenrir-s.notoshi.win,43.6532,-79.3832 +notemine.io,52.2026,20.9397 +r.bitcoinhold.net,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +articles.layer3.news,37.3387,-121.885 +relays.diggoo.com,43.6532,-79.3832 +relayb.uid.ovh,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +chat-relay.zap-work.com,43.6532,-79.3832 +nr.yay.so,46.2126,6.1154 +shu01.shugur.net,21.4902,39.2246 +khatru.nostrver.se,51.1792,5.89444 +relay.openfarmtools.org,60.1699,24.9384 +relay01.lnfi.network,39.0997,-94.5786 +nostrja-kari.heguro.com,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +relay.satlantis.io,32.8769,-80.0114 +relay.vrtmrz.net,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +orangepiller.org,60.1699,24.9384 +relay.coinos.io,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.toastr.net,40.8054,-74.0241 +ynostr.yael.at,60.1699,24.9384 +ribo.eu.nostria.app,52.3676,4.90414 +relay.nostrcheck.me,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.threenine.services,51.5524,-0.29686 +relay.nostr.net,43.6532,-79.3832 +nostr.middling.mydns.jp,35.8099,140.12 +soloco.nl,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +no.str.cr,9.92857,-84.0528 +espelho.girino.org,43.6532,-79.3832 +nostr.hifish.org,47.4043,8.57398 +wot.sebastix.social,51.1792,5.89444 +relay03.lnfi.network,39.0997,-94.5786 +relay.nosto.re,51.1792,5.89444 +relay.nostrdice.com,-33.8688,151.209 +nostr.agentcampfire.com,52.3676,4.90414 +nostr.night7.space,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +relayone.soundhsa.com,33.1384,-95.6011 +relay.illuminodes.com,47.6061,-122.333 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +relay.seq1.net,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +nostr.ovia.to,43.6532,-79.3832 +relay.holzeis.me,43.6532,-79.3832 +bcast.seutoba.com.br,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.nostriot.com,41.5695,-83.9786 +nostr.oxtr.dev,50.4754,12.3683 +nostr.mom,50.4754,12.3683 +nostr-03.dorafactory.org,1.35208,103.82 +relay.camelus.app,45.5201,-122.99 +strfry.openhoofd.nl,51.9229,4.40833 +nos.lol,50.4754,12.3683 +nostr.bitcoiner.social,39.1585,-94.5728 +relay.islandbitcoin.com,12.8498,77.6545 +relay.tagayasu.xyz,43.6715,-79.38 +dev-nostr.bityacht.io,25.0797,121.234 +relay.nostrhub.fr,48.1045,11.6004 +relay.satmaxt.xyz,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.samt.st,40.8302,-74.1299 +relay02.lnfi.network,39.0997,-94.5786 +nostr.88mph.life,51.5072,-0.127586 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +nostrelay.memory-art.xyz,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nostr-02.yakihonne.com,1.32123,103.695 +nostr.spicyz.io,43.6532,-79.3832 +relay.minibolt.info,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +relay.bitcoinartclock.com,50.4754,12.3683 +cyberspace.nostr1.com,40.7128,-74.006 +nostr.data.haus,50.4754,12.3683 +relay.agora.social,50.7383,15.0648 +relay.wavefunc.live,34.0362,-118.443 +nostr.casa21.space,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 relay.ditto.pub,43.6532,-79.3832 +nostr-relay.amethyst.name,39.0438,-77.4874 +relay.credenso.cafe,43.3601,-80.3127 +nostr.calitabby.net,39.9268,-75.0246 +relay.magiccity.live,25.8128,-80.2377 +nostr.chaima.info,51.223,6.78245 +wot.brightbolt.net,47.6735,-116.781 +nostr.sathoarder.com,48.5734,7.75211 +inbox.azzamo.net,52.2633,21.0283 +nostr.na.social,43.6532,-79.3832 +nostr.myshosholoza.co.za,52.3676,4.90414 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +nostr.0x7e.xyz,47.4988,8.72369 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.btcforplebs.com,43.6532,-79.3832 +bitcoiner.social,39.1585,-94.5728 +relaynostr.breadslice.com,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +santo.iguanatech.net,40.8302,-74.1299 +relay.lightning.pub,39.0438,-77.4874 +nostr.rblb.it,43.7094,10.6582 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.wolfcoil.com,35.6092,139.73 +nostr-relay.corb.net,38.8353,-104.822 +relay.nostr.place,32.7767,-96.797 +wot.soundhsa.com,33.1384,-95.6011 +relay.npubhaus.com,43.6532,-79.3832 +nostr-relay.psfoundation.info,39.0438,-77.4874 +x.kojira.io,43.6532,-79.3832 +relay.cypherflow.ai,48.8566,2.35222 From 54c96bd7370f572a9b3a5216355a50d6decac218 Mon Sep 17 00:00:00 2001 From: Rebroad Date: Sun, 4 Jan 2026 01:41:38 -0500 Subject: [PATCH 10/24] Fix: Add @ConscryptMode annotation to FileTransferTest to resolve UnsatisfiedLinkError (#543) Robolectric 4.9+ uses Conscrypt as the default security provider, which requires native libraries that aren't available in the test environment. Adding @ConscryptMode(Mode.OFF) disables Conscrypt and uses BouncyCastle instead, which resolves the test failures. This fixes the build failures in FileTransferTest where all 9 tests were failing with UnsatisfiedLinkError when Conscrypt tried to load native libraries. Fixes #542 --- app/src/test/kotlin/com/bitchat/FileTransferTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 1798f5fe..31131540 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -8,12 +8,14 @@ import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.ConscryptMode import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Date @RunWith(RobolectricTestRunner::class) +@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues class FileTransferTest { @Test From d73976537d6df37f794063c4b6e94981ec6af96e Mon Sep 17 00:00:00 2001 From: kargathara Aakash <97916053+Kargatharaakash@users.noreply.github.com> Date: Sun, 4 Jan 2026 12:27:55 +0530 Subject: [PATCH 11/24] Fix small-screen header clipping (#519) (#532) * Fix small-screen header clipping: reserve status-bar space and center-constrain titles (fixes #519) * Remove redundant Gradle configuration lines --- .../java/com/bitchat/android/ui/ChatHeader.kt | 18 +++- .../java/com/bitchat/android/ui/ChatScreen.kt | 87 ++++++++++--------- gradle.properties | 2 +- 3 files changed, 60 insertions(+), 47 deletions(-) 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 f741dafa..1c5c3a11 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -26,6 +26,8 @@ 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 androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign import com.bitchat.android.core.ui.utils.singleOrTripleClickable import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset @@ -406,7 +408,9 @@ private fun PrivateChatHeader( Text( text = titleText, style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500) // Orange + color = Color(0xFFFF9500), // Orange + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.width(4.dp)) @@ -493,8 +497,12 @@ private fun ChannelHeader( style = MaterialTheme.typography.titleMedium, color = Color(0xFFFF9500), // Orange to match input field modifier = Modifier - .align(Alignment.Center) - .clickable { onSidebarClick() } + .fillMaxWidth() + .padding(horizontal = 56.dp) + .clickable { onSidebarClick() }, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) // Leave button - positioned on the right @@ -552,7 +560,9 @@ private fun MainHeader( modifier = Modifier.singleOrTripleClickable( onSingleClick = onTitleClick, onTripleClick = onTripleTitleClick - ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.width(2.dp)) 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 d4d33ac0..3c2e262b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -23,6 +23,7 @@ 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.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage @@ -113,7 +114,10 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { val headerHeight = 42.dp - + // Reserve exact height: header + status bar inset so content below won't be overlapped + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val reservedHeaderHeight = headerHeight + statusBarTop + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -121,11 +125,10 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) + // Header spacer - reserve space equal to header + status bar inset Spacer( modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) + .height(reservedHeaderHeight) ) // Messages area - takes up available space, will compress when keyboard appears @@ -254,15 +257,7 @@ fun ChatScreen(viewModel: ChatViewModel) { onLocationNotesClick = { showLocationNotesSheet = true } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) - ) + val alpha by animateFloatAsState( targetValue = if (showSidebar) 0.5f else 0f, @@ -459,38 +454,46 @@ private fun ChatFloatingHeader( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area + .statusBarsPadding(), // Respect status bar insets color = colorScheme.background // Solid background color extending into status bar ) { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) + Column { + TopAppBar( + title = { + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) + } + }, + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick, - onLocationNotesClick = { - // Ensure location is loaded before showing sheet - locationManager.refreshChannels() - onLocationNotesClick() - } - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.height(headerHeight) // Ensure compact header height - ) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier.heightIn(min = headerHeight) // Allow header to expand for accessibility font scales + ) + + // Divider under header - always aligned with header bottom + Divider( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.outline.copy(alpha = 0.3f) + ) + } } } diff --git a/gradle.properties b/gradle.properties index 0838af96..fce5240f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,4 +26,4 @@ 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 From c663e8ede081595c03926ea2119eaebffb594768 Mon Sep 17 00:00:00 2001 From: yet300 <96379204+yet300@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:29:07 +0400 Subject: [PATCH 12/24] QR and Verification feature (#529) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * feat: Add ZXing dependency for QR code scanning * feat: Request camera permission for QR verification * Add QR verification payloads and mesh wiring * Wire verification state, system messages, and notifications * Add verification sheets and UI affordances * Show verified badges in sidebar and add strings * Persist fingerprint caches for offline verification * Handle bitchat://verify deep links * feat: Replace zxing-android-embedded with ML Kit and CameraX * Refactor(Verification): Replace zxing with MLKit for QR scanning * Replace `AndroidView` with `CameraXViewfinder` for camera preview * Refactor QR verification: Extract VerificationHandler and fix concurrency issues * Extract and translate strings for QR verification feature * Fix build errors: Escape ampersands in strings and restore missing methods in ChatViewModel * return to main * return to main 2 --------- Co-authored-by: GitHub Action Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- app/build.gradle.kts | 9 + app/src/main/AndroidManifest.xml | 9 + .../java/com/bitchat/android/MainActivity.kt | 14 + .../identity/SecureIdentityStateManager.kt | 117 ++++- .../android/mesh/BluetoothMeshService.kt | 62 +++ .../bitchat/android/mesh/MessageHandler.kt | 10 + .../bitchat/android/model/NoiseEncrypted.kt | 2 + .../nostr/NostrDirectMessageHandler.kt | 22 +- .../android/services/VerificationService.kt | 294 +++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 22 + .../java/com/bitchat/android/ui/ChatHeader.kt | 24 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 34 +- .../java/com/bitchat/android/ui/ChatState.kt | 14 + .../com/bitchat/android/ui/ChatViewModel.kt | 153 +++++- .../bitchat/android/ui/MeshDelegateHandler.kt | 8 + .../bitchat/android/ui/NotificationManager.kt | 31 ++ .../android/ui/SecurityVerificationSheet.kt | 443 ++++++++++++++++ .../bitchat/android/ui/SidebarComponents.kt | 47 +- .../bitchat/android/ui/VerificationHandler.kt | 368 +++++++++++++ .../bitchat/android/ui/VerificationSheet.kt | 496 ++++++++++++++++++ .../com/bitchat/android/util/AppConstants.kt | 4 + app/src/main/res/values-ar/strings.xml | 36 ++ app/src/main/res/values-bn/strings.xml | 36 ++ app/src/main/res/values-de/strings.xml | 40 +- app/src/main/res/values-es/strings.xml | 36 ++ app/src/main/res/values-fa/strings.xml | 36 ++ app/src/main/res/values-fil/strings.xml | 36 ++ app/src/main/res/values-fr/strings.xml | 40 +- app/src/main/res/values-he/strings.xml | 36 ++ app/src/main/res/values-hi/strings.xml | 38 +- app/src/main/res/values-id/strings.xml | 40 +- app/src/main/res/values-it/strings.xml | 36 ++ app/src/main/res/values-ja/strings.xml | 40 +- app/src/main/res/values-ka/strings.xml | 40 +- app/src/main/res/values-ko/strings.xml | 40 +- app/src/main/res/values-mg/strings.xml | 40 +- app/src/main/res/values-ms/strings.xml | 36 ++ app/src/main/res/values-ne/strings.xml | 36 ++ app/src/main/res/values-nl/strings.xml | 42 +- app/src/main/res/values-pa-rPK/strings.xml | 40 +- app/src/main/res/values-pl/strings.xml | 36 ++ app/src/main/res/values-pt-rBR/strings.xml | 40 +- app/src/main/res/values-pt/strings.xml | 36 ++ app/src/main/res/values-ru/strings.xml | 36 ++ app/src/main/res/values-sv/strings.xml | 36 ++ app/src/main/res/values-ta/strings.xml | 36 ++ app/src/main/res/values-th/strings.xml | 40 +- app/src/main/res/values-tr/strings.xml | 36 ++ app/src/main/res/values-uk/strings.xml | 36 ++ app/src/main/res/values-ur/strings.xml | 36 ++ app/src/main/res/values-vi/strings.xml | 40 +- app/src/main/res/values-zh-rCN/strings.xml | 36 ++ app/src/main/res/values-zh-rTW/strings.xml | 36 ++ app/src/main/res/values-zh/strings.xml | 36 ++ app/src/main/res/values/strings.xml | 40 +- gradle/libs.versions.toml | 16 + 56 files changed, 3421 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/services/VerificationService.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f6de2444..7222b63d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,15 @@ dependencies { // Permissions implementation(libs.accompanist.permissions) + + // QR + implementation(libs.zxing.core) + implementation(libs.mlkit.barcode.scanning) + + // CameraX + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.compose) // Cryptography implementation(libs.bundles.cryptography) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4ca2b89..14a01098 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,6 +31,8 @@ + + @@ -47,6 +49,7 @@ + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 717a24ed..a87c4a25 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -40,6 +40,7 @@ import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.OrientationAwareActivity import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.services.VerificationService import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -655,6 +656,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) + handleVerificationIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -683,6 +685,7 @@ class MainActivity : OrientationAwareActivity() { // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) + handleVerificationIntent(intent) } } @@ -788,6 +791,17 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleVerificationIntent(intent: Intent) { + val uri = intent.data ?: return + if (uri.scheme != "bitchat" || uri.host != "verify") return + + chatViewModel.showVerificationSheet() + val qr = VerificationService.verifyScannedQR(uri.toString()) + if (qr != null) { + chatViewModel.beginQRVerification(qr) + } + } + override fun onDestroy() { super.onDestroy() 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 2b0b2bdd..012ea3c4 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -5,7 +5,10 @@ import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import java.security.MessageDigest +import android.util.Base64 import android.util.Log +import com.bitchat.android.util.hexEncodedString +import androidx.core.content.edit /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation @@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) { private const val KEY_STATIC_PUBLIC_KEY = "static_public_key" private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key" private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key" + private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints" + private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints" + 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 val prefs: SharedPreferences + private val lock = Any() init { // Create master key for encryption @@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) { fun generateFingerprint(publicKeyData: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(publicKeyData) - return hash.joinToString("") { "%02x".format(it) } + return hash.hexEncodedString() } /** @@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) { // SHA-256 fingerprint should be 64 hex characters return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$")) } + + // MARK: - Verified Fingerprints + + fun getVerifiedFingerprints(): Set { + return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet() + } + + fun isVerifiedFingerprint(fingerprint: String): Boolean { + return getVerifiedFingerprints().contains(fingerprint) + } + + fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { + if (!isValidFingerprint(fingerprint)) return + synchronized(lock) { + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + if (verified) { + current.add(fingerprint) + } else { + current.remove(fingerprint) + } + prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } + } + } + + fun getCachedPeerFingerprint(peerID: String): String? { + val pid = peerID.lowercase() + // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility + // if we are paranoid, but SharedPreferences is generally thread-safe for reads. + // However, to ensure we don't read a partial update (unlikely with SP), we can leave it. + // The critical part is the write. + val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null + return entry.substringAfter(':').takeIf { isValidFingerprint(it) } + } + + fun cachePeerFingerprint(peerID: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid:") } + current.add("$pid:$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) } + } + } + + fun getCachedNoiseKey(peerID: String): String? { + val pid = peerID.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null + return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) } + } + + fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) { + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid=") } + current.add("$pid=${noiseKeyHex.lowercase()}") + prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) } + } + } + + fun getCachedNoiseFingerprint(noiseKeyHex: String): String? { + val key = noiseKeyHex.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + return entry.substringAfter('=').takeIf { isValidFingerprint(it) } + } + + fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val key = noiseKeyHex.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) } + } + } + + fun getCachedFingerprintNickname(fingerprint: String): String? { + if (!isValidFingerprint(fingerprint)) return null + val key = fingerprint.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + val encoded = entry.substringAfter('=') + return runCatching { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + String(bytes, Charsets.UTF_8) + }.getOrNull() + } + + fun cacheFingerprintNickname(fingerprint: String, nickname: String) { + if (!isValidFingerprint(fingerprint)) return + val key = fingerprint.lowercase() + val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$encoded") + prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } + } + } // MARK: - Peer ID Rotation Management (removed) // Android now derives peer ID from the persisted Noise identity fingerprint. 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 3de48407..91f88919 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.IdentityAnnouncement +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.protocol.SpecialRecipients 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 kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -72,6 +75,7 @@ class BluetoothMeshService(private val context: Context) { init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") + VerificationService.configure(encryptionService) setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() @@ -414,6 +418,14 @@ class BluetoothMeshService(private val context: Context) { 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 delegates @@ -939,6 +951,50 @@ class BluetoothMeshService(private val context: Context) { } } } + + // MARK: QR Verification over Noise + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify challenge") + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify response") + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) { + serviceScope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 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 + ) + + val signedPacket = signPacketBeforeBroadcast(packet) + connectionManager.broadcastPacket(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}") + } + } + } /** * Send broadcast announce with TLV-encoded identity announcement - exactly like iOS @@ -1127,6 +1183,10 @@ class BluetoothMeshService(private val context: Context) { fun getIdentityFingerprint(): String { return encryptionService.getIdentityFingerprint() } + + fun getStaticNoisePublicKey(): ByteArray? { + return encryptionService.getStaticPublicKey() + } /** * Check if encryption icon should be shown for a peer @@ -1283,6 +1343,8 @@ interface BluetoothMeshDelegate { 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/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d016dd37..ce8b0d9e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Simplified: Call delegate with messageID and peerID directly delegate?.onReadReceiptReceived(messageID, peerID) } + com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> { + Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } + com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { + Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } } } catch (e: Exception) { @@ -611,4 +619,6 @@ interface MessageHandlerDelegate { fun onChannelLeave(channel: String, fromPeer: String) fun onDeliveryAckReceived(messageID: String, peerID: String) fun onReadReceiptReceived(messageID: String, peerID: String) + fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) } 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 7f691a9c..c46a114f 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) { PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding READ_RECEIPT(0x02u), // Message was read DELIVERED(0x03u), // Message was delivered + VERIFY_CHALLENGE(0x10u), // Verification challenge + VERIFY_RESPONSE(0x11u), // Verification response FILE_TRANSFER(0x20u); 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 3dcb60d6..18c12e4d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -2,8 +2,12 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log +import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +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.SeenMessageStore import com.bitchat.android.ui.ChatState @@ -71,7 +75,7 @@ class NostrDirectMessageHandler( if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch + val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch val messageTimestamp = Date(giftWrap.createdAt * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) @@ -104,7 +108,7 @@ class NostrDirectMessageHandler( } private suspend fun processNoisePayload( - payload: com.bitchat.android.model.NoisePayload, + payload: NoisePayload, convKey: String, senderNickname: String, timestamp: Date, @@ -112,8 +116,8 @@ class NostrDirectMessageHandler( recipientIdentity: NostrIdentity ) { when (payload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return + NoisePayloadType.PRIVATE_MESSAGE -> { + val pm = PrivateMessagePacket.decode(payload.data) ?: return val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() if (existingMessages.any { it.id == pm.messageID }) return @@ -148,21 +152,21 @@ class NostrDirectMessageHandler( seenStore.markRead(pm.messageID) } } - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { + NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) } } - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { + NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) } } - com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> { + NoisePayloadType.FILE_TRANSFER -> { // Properly handle encrypted file transfer - val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data) + val file = BitchatFilePacket.decode(payload.data) if (file != null) { val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file) @@ -185,6 +189,8 @@ class NostrDirectMessageHandler( Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey") } } + NoisePayloadType.VERIFY_CHALLENGE, + NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages } } diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt new file mode 100644 index 00000000..06dcd6f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -0,0 +1,294 @@ +package com.bitchat.android.services + +import android.net.Uri +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import androidx.core.net.toUri +import java.lang.ref.WeakReference + +/** + * QR verification helpers: schema, signing, and basic challenge/response helpers. + */ +object VerificationService { + private const val CONTEXT = "bitchat-verify-v1" + private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1" + + private var encryptionServiceRef: WeakReference? = null + + fun configure(encryptionService: EncryptionService) { + this.encryptionServiceRef = WeakReference(encryptionService) + } + + data class VerificationQR( + val v: Int, + val noiseKeyHex: String, + val signKeyHex: String, + val npub: String?, + val nickname: String, + val ts: Long, + val nonceB64: String, + val sigHex: String + ) { + fun canonicalBytes(): ByteArray { + val out = ByteArrayOutputStream() + + fun appendField(value: String) { + val data = value.toByteArray(Charsets.UTF_8) + val len = minOf(data.size, 255) + out.write(len) + out.write(data, 0, len) + } + + appendField(CONTEXT) + appendField(v.toString()) + appendField(noiseKeyHex.lowercase()) + appendField(signKeyHex.lowercase()) + appendField(npub ?: "") + appendField(nickname) + appendField(ts.toString()) + appendField(nonceB64) + return out.toByteArray() + } + + fun toUrlString(): String { + val builder = Uri.Builder() + .scheme("bitchat") + .authority("verify") + .appendQueryParameter("v", v.toString()) + .appendQueryParameter("noise", noiseKeyHex) + .appendQueryParameter("sign", signKeyHex) + .appendQueryParameter("nick", nickname) + .appendQueryParameter("ts", ts.toString()) + .appendQueryParameter("nonce", nonceB64) + .appendQueryParameter("sig", sigHex) + if (npub != null) { + builder.appendQueryParameter("npub", npub) + } + return builder.build().toString() + } + + companion object { + fun fromUrlString(urlString: String): VerificationQR? { + val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null + if (uri.scheme != "bitchat" || uri.host != "verify") return null + + val vStr = uri.getQueryParameter("v") ?: return null + val v = vStr.toIntOrNull() ?: return null + val noise = uri.getQueryParameter("noise") ?: return null + val sign = uri.getQueryParameter("sign") ?: return null + val nick = uri.getQueryParameter("nick") ?: return null + val tsStr = uri.getQueryParameter("ts") ?: return null + val ts = tsStr.toLongOrNull() ?: return null + val nonce = uri.getQueryParameter("nonce") ?: return null + val sig = uri.getQueryParameter("sig") ?: return null + val npub = uri.getQueryParameter("npub") + + return VerificationQR( + v = v, + noiseKeyHex = noise, + signKeyHex = sign, + npub = npub, + nickname = nick, + ts = ts, + nonceB64 = nonce, + sigHex = sig + ) + } + } + } + + fun buildMyQRString(nickname: String, npub: String?): String? { + val service = encryptionServiceRef?.get() ?: return null + val cache = Cache.last + if (cache != null && cache.nickname == nickname && cache.npub == npub) { + if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) { + return cache.value + } + } + + val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null + val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null + val ts = System.currentTimeMillis() / 1000L + val nonce = ByteArray(16) + SecureRandom().nextBytes(nonce) + val nonceB64 = Base64.encodeToString( + nonce, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + + val payload = VerificationQR( + v = 1, + noiseKeyHex = noiseKey, + signKeyHex = signKey, + npub = npub, + nickname = nickname, + ts = ts, + nonceB64 = nonceB64, + sigHex = "" + ) + + val signature = service.signData(payload.canonicalBytes()) ?: return null + val signed = payload.copy(sigHex = signature.hexEncodedString()) + val out = signed.toUrlString() + Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out) + return out + } + + fun verifyScannedQR( + urlString: String, + maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS + ): VerificationQR? { + val service = encryptionServiceRef?.get() ?: return null + val qr = VerificationQR.fromUrlString(urlString) ?: return null + val now = System.currentTimeMillis() / 1000L + if (now - qr.ts > maxAgeSeconds) return null + + val sig = qr.sigHex.dataFromHexString() ?: return null + val signKey = qr.signKeyHex.dataFromHexString() ?: return null + val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey) + return if (ok) qr else null + } + + fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray { + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + return out.toByteArray() + } + + fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? { + val service = encryptionServiceRef?.get() ?: return null + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val sig = service.signData(msg.toByteArray()) ?: return null + + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + out.write(0x03) + out.write(minOf(sig.size, 255)) + out.write(sig, 0, minOf(sig.size, 255)) + return out.toByteArray() + } + + fun parseVerifyChallenge(data: ByteArray): Pair? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + return noise to nonce + } + + data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VerifyResponse + + if (noiseKeyHex != other.noiseKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + if (!signature.contentEquals(other.signature)) return false + + return true + } + + override fun hashCode(): Int { + var result = noiseKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + } + + fun parseVerifyResponse(data: ByteArray): VerifyResponse? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + val t3 = take(1) ?: return null + if (t3[0].toInt() != 0x03) return null + val l3 = take(1)?.get(0)?.toInt() ?: return null + val sig = take(l3) ?: return null + + return VerifyResponse(noise, nonce, sig) + } + + fun verifyResponseSignature( + noiseKeyHex: String, + nonceA: ByteArray, + signature: ByteArray, + signerPublicKeyHex: String + ): Boolean { + val service = encryptionServiceRef?.get() ?: return false + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false + return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey) + } + + private data class CacheEntry( + val nickname: String, + val npub: String?, + val builtAtMs: Long, + val value: String + ) + + private object Cache { + var last: CacheEntry? = null + } +} 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 7c5fa7a0..d931c027 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -9,6 +9,7 @@ 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.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Warning @@ -681,6 +682,27 @@ fun AboutSheet( } } +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(18.dp) + ) + } +} /** * Password prompt dialog for password-protected channels * Kept as dialog since it requires user input 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 1c5c3a11..658d6088 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -320,6 +320,10 @@ private fun PrivateChatHeader( ) { val colorScheme = MaterialTheme.colorScheme val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val isVerified = remember(peerID, verifiedFingerprints) { + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } // Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup) val isMutualFavorite = remember(peerID, peerNicknames) { try { @@ -417,17 +421,33 @@ private fun PrivateChatHeader( // Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite) + val securityModifier = if (!isNostrDM) { + Modifier.clickable { viewModel.showSecurityVerificationSheet() } + } else { + Modifier + } + if (showGlobe) { Icon( imageVector = Icons.Outlined.Public, contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), + modifier = Modifier.size(14.dp).then(securityModifier), tint = Color(0xFF9B59B6) // Purple like iOS ) } else { NoiseSessionIcon( sessionState = sessionState, - modifier = Modifier.size(14.dp) + modifier = Modifier.size(14.dp).then(securityModifier) + ) + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(14.dp).then(securityModifier), + tint = Color(0xFF32D74B) ) } 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 3c2e262b..c196ece3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -58,6 +58,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle() val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle() val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle() + val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() + val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -322,6 +324,10 @@ fun ChatScreen(viewModel: ChatViewModel) { SidebarOverlay( viewModel = viewModel, onDismiss = { viewModel.hideSidebar() }, + onShowVerification = { + viewModel.showVerificationSheet(fromSidebar = true) + viewModel.hideSidebar() + }, modifier = Modifier.fillMaxSize() ) } @@ -368,7 +374,11 @@ fun ChatScreen(viewModel: ChatViewModel) { }, selectedUserForSheet = selectedUserForSheet, selectedMessageForSheet = selectedMessageForSheet, - viewModel = viewModel + viewModel = viewModel, + showVerificationSheet = showVerificationSheet, + onVerificationSheetDismiss = viewModel::hideVerificationSheet, + showSecurityVerificationSheet = showSecurityVerificationSheet, + onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet ) } @@ -516,7 +526,11 @@ private fun ChatDialogs( onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, selectedMessageForSheet: BitchatMessage?, - viewModel: ChatViewModel + viewModel: ChatViewModel, + showVerificationSheet: Boolean, + onVerificationSheetDismiss: () -> Unit, + showSecurityVerificationSheet: Boolean, + onSecurityVerificationSheetDismiss: () -> Unit ) { // Password dialog PasswordPromptDialog( @@ -570,4 +584,20 @@ private fun ChatDialogs( viewModel = viewModel ) } + + if (showVerificationSheet) { + VerificationSheet( + isPresented = showVerificationSheet, + onDismiss = onVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (showSecurityVerificationSheet) { + SecurityVerificationSheet( + isPresented = showSecurityVerificationSheet, + onDismiss = onSecurityVerificationSheetDismiss, + viewModel = viewModel + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 6d0f2364..c4ae3db0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -122,6 +122,12 @@ class ChatState( // Navigation state private val _showAppInfo = MutableStateFlow(false) val showAppInfo: StateFlow = _showAppInfo.asStateFlow() + + private val _showVerificationSheet = MutableStateFlow(false) + val showVerificationSheet: StateFlow = _showVerificationSheet.asStateFlow() + + private val _showSecurityVerificationSheet = MutableStateFlow(false) + val showSecurityVerificationSheet: StateFlow = _showSecurityVerificationSheet.asStateFlow() // Location channels state (for Nostr geohash features) private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) @@ -302,6 +308,14 @@ class ChatState( fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } + + fun setShowVerificationSheet(show: Boolean) { + _showVerificationSheet.value = show + } + + fun setShowSecurityVerificationSheet(show: Boolean) { + _showSecurityVerificationSheet.value = show + } fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { _selectedLocationChannel.value = channel 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 1196396d..58d5c77f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,11 +5,15 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow +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.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +23,13 @@ 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.util.hexEncodedString +import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -46,6 +57,20 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + fun getCurrentNpub(): String? { + return try { + NostrIdentityBridge + .getCurrentNostrIdentity(getApplication()) + ?.npub + } catch (_: Exception) { + null + } + } + + fun buildMyQRString(nickname: String, npub: String?): String { + return VerificationService.buildMyQRString(nickname, npub) ?: "" + } + // MARK: - State management private val state = ChatState( scope = viewModelScope, @@ -57,6 +82,7 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) + private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -75,6 +101,17 @@ class ChatViewModel( NotificationIntervalManager() ) + private val verificationHandler = VerificationHandler( + context = application.applicationContext, + scope = viewModelScope, + meshService = meshService, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints + // Media file sending manager private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) @@ -134,6 +171,8 @@ class ChatViewModel( val peerRSSI: StateFlow> = state.peerRSSI val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo + val showVerificationSheet: StateFlow = state.showVerificationSheet + val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val selectedLocationChannel: StateFlow = state.selectedLocationChannel val isTeleported: StateFlow = state.isTeleported val geohashPeople: StateFlow> = state.geohashPeople @@ -247,6 +286,9 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Load verified fingerprints from secure storage + verificationHandler.loadVerifiedFingerprints() + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { @@ -646,6 +688,18 @@ class ChatViewModel( // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) + fingerprints.forEach { (peerID, fingerprint) -> + identityManager.cachePeerFingerprint(peerID, fingerprint) + val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null } + val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) + } + info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname(fingerprint, nickname) + } + } val nicknames = meshService.getPeerNicknames() state.setPeerNicknames(nicknames) @@ -660,6 +714,34 @@ class ChatViewModel( } 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) { + verificationHandler.sendPendingVerificationIfNeeded(peerID) + } + } + } + + // MARK: - QR Verification + + fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) + return fingerprint != null && verifiedFingerprints.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { + val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + return verifiedFingerprints.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + verificationHandler.unverifyFingerprint(peerID) + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + return verificationHandler.beginQRVerification(qr) } // MARK: - Debug and Troubleshooting @@ -668,41 +750,75 @@ class ChatViewModel( return meshService.getDebugStatus() } - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic notificationManager.setAppBackgroundState(inBackground) } fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - /** - * Clear mesh mention notifications when user opens mesh chat - */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } + private var reopenSidebarAfterVerification = false + + fun showVerificationSheet(fromSidebar: Boolean = false) { + if (fromSidebar) { + reopenSidebarAfterVerification = true + } + state.setShowVerificationSheet(true) + } + + fun hideVerificationSheet() { + state.setShowVerificationSheet(false) + if (reopenSidebarAfterVerification) { + reopenSidebarAfterVerification = false + state.setShowSidebar(true) + } + } + + fun showSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(true) + } + + fun hideSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(false) + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + return verificationHandler.getPeerFingerprintForDisplay(peerID) + } + + fun getMyFingerprint(): String { + return verificationHandler.getMyFingerprint() + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) + } + + fun verifyFingerprintValue(fingerprint: String) { + verificationHandler.verifyFingerprintValue(fingerprint) + } + + fun unverifyFingerprintValue(fingerprint: String) { + verificationHandler.unverifyFingerprintValue(fingerprint) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { @@ -744,6 +860,14 @@ class ChatViewModel( override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyChallenge(peerID, payload) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyResponse(peerID, payload) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -827,7 +951,7 @@ class ChatViewModel( // Clear secure identity state (if used) try { - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) try { @@ -840,7 +964,7 @@ class ChatViewModel( // Clear FavoritesPersistenceService persistent relationships try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } @@ -969,5 +1093,6 @@ class ChatViewModel( */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) - } +} + } 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 a452616e..d27719c7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -216,6 +216,14 @@ class MeshDelegateHandler( messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) } } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return channelManager.decryptChannelMessage(encryptedContent, channel) 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 622800ca..9eb72b48 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -280,6 +280,37 @@ class NotificationManager( Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") } + fun showVerificationNotification(title: String, body: String, peerID: String? = null) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (peerID != null) { + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, peerID) + putExtra(EXTRA_SENDER_NICKNAME, body) + } + } + + val pendingIntent = PendingIntent.getActivity( + context, + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + + notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build()) + } + private fun showNotificationForActivePeers(peersSize: Int) { // Create intent to open the app val intent = Intent(context, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt new file mode 100644 index 00000000..59fe6085 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -0,0 +1,443 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.LocalClipboardManager +import androidx.compose.ui.res.stringResource +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.R + +private data class SecurityStatusInfo( + val text: String, + val icon: ImageVector, + val tint: Color +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecurityVerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } + + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SecurityVerificationHeader( + accent = accent, + onClose = onDismiss + ) + + if (peerID == null) { + Text( + text = stringResource(R.string.fingerprint_no_peer), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } else { + val selectedPeerID = peerID!! + val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) + val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) + val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) + val sessionState = peerSessionStates[selectedPeerID] + val statusInfo = buildStatusInfo( + isVerified = isVerified, + sessionState = sessionState, + accent = accent + ) + + SecurityStatusCard( + displayName = displayName, + accent = accent, + boxColor = boxColor, + statusInfo = statusInfo + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_their), + fingerprint = fingerprint, + boxColor = boxColor, + accent = accent + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_yours), + fingerprint = viewModel.getMyFingerprint(), + boxColor = boxColor, + accent = accent + ) + + SecurityVerificationActions( + isVerified = isVerified, + fingerprint = fingerprint, + displayName = displayName, + accent = accent, + canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex), + onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) }, + onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, + onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } + ) + } + } + } +} + +@Composable +private fun SecurityVerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security_verification_title), + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Spacer(modifier = Modifier.weight(1f)) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun buildStatusInfo( + isVerified: Boolean, + sessionState: String?, + accent: Color +): SecurityStatusInfo { + val text = when { + isVerified -> stringResource(R.string.fingerprint_status_verified) + sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) + sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) + sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) + else -> stringResource(R.string.fingerprint_status_uninitialized) + } + val icon = when { + isVerified -> Icons.Filled.Verified + sessionState == "handshaking" -> Icons.Outlined.Sync + sessionState == "failed" -> Icons.Outlined.OutlinedWarning + sessionState == "established" -> Icons.Filled.Lock + else -> Icons.Outlined.NoEncryption + } + val tint = when { + isVerified -> Color(0xFF32D74B) + sessionState == "failed" -> Color(0xFFFF3B30) + sessionState == "handshaking" -> Color(0xFFFF9500) + sessionState == "established" -> Color(0xFF32D74B) + else -> accent.copy(alpha = 0.6f) + } + return SecurityStatusInfo(text, icon, tint) +} + +@Composable +private fun SecurityStatusCard( + displayName: String, + accent: Color, + boxColor: Color, + statusInfo: SecurityStatusInfo +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, shape = MaterialTheme.shapes.medium) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = statusInfo.icon, + contentDescription = null, + tint = statusInfo.tint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Text( + text = statusInfo.text, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun SecurityVerificationActions( + isVerified: Boolean, + fingerprint: String?, + displayName: String, + accent: Color, + canStartHandshake: Boolean, + onStartHandshake: () -> Unit, + onVerify: (String) -> Unit, + onUnverify: (String) -> Unit +) { + if (canStartHandshake) { + Button( + onClick = onStartHandshake, + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_start_handshake), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + + if (isVerified) { + VerificationStatusRow( + icon = Icons.Filled.Verified, + iconTint = Color(0xFF32D74B), + text = stringResource(R.string.fingerprint_verified_label), + textTint = Color(0xFF32D74B) + ) + Text( + text = stringResource(R.string.fingerprint_verified_message), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Button( + onClick = { fingerprint?.let(onUnverify) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3B30), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } else { + VerificationStatusRow( + icon = Icons.Filled.Warning, + iconTint = Color(0xFFFF9500), + text = stringResource(R.string.fingerprint_not_verified_label), + textTint = Color(0xFFFF9500) + ) + Text( + text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (fingerprint != null) { + Button( + onClick = { onVerify(fingerprint) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_mark_verified), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun VerificationStatusRow( + icon: ImageVector, + iconTint: Color, + text: String, + textTint: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = textTint + ) + } +} + +@Composable +private fun FingerprintBlock( + title: String, + fingerprint: String?, + boxColor: Color, + accent: Color +) { + val clipboardManager = LocalClipboardManager.current + var showMenu by remember(fingerprint) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent.copy(alpha = 0.8f) + ) + if (fingerprint != null) { + Column { + Text( + text = formatFingerprint(fingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + color = accent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = {}, + onLongClick = { showMenu = true } + ) + .background(boxColor, shape = MaterialTheme.shapes.small) + .padding(16.dp), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.fingerprint_copy)) }, + onClick = { + clipboardManager.setText(AnnotatedString(fingerprint)) + showMenu = false + } + ) + } + } + } else { + Text( + text = stringResource(R.string.fingerprint_pending), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFF9500), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatFingerprint(fingerprint: String): String { + val upper = fingerprint.uppercase() + val sb = StringBuilder() + upper.forEachIndexed { index, c -> + if (index > 0 && index % 4 == 0) { + if (index % 16 == 0) sb.append('\n') else sb.append(' ') + } + sb.append(c) + } + return sb.toString() +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 4ab4360a..9b8328d6 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.util.hexEncodedString /** @@ -34,6 +35,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE fun SidebarOverlay( viewModel: ChatViewModel, onDismiss: () -> Unit, + onShowVerification: () -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme @@ -131,6 +133,7 @@ fun SidebarOverlay( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, viewModel = viewModel, + onShowVerification = onShowVerification, onPrivateChatStart = { peerID -> viewModel.startPrivateChat(peerID) onDismiss() @@ -257,8 +260,11 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, viewModel: ChatViewModel, + onShowVerification: () -> Unit, onPrivateChatStart: (String) -> Unit ) { + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + Column(modifier = modifier) { Row( modifier = Modifier @@ -279,6 +285,17 @@ fun PeopleSection( color = colorScheme.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.weight(1f)) + if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) { + IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = stringResource(R.string.verify_title), + tint = colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(16.dp) + ) + } + } } if (connectedPeers.isEmpty()) { @@ -295,6 +312,7 @@ fun PeopleSection( val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() // Reactive favorite computation for all peers val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { @@ -308,10 +326,16 @@ 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.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString() } catch (_: Exception) { null } }.filterValues { it != null }.mapValues { it.value!! } + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + } + 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 @@ -342,7 +366,7 @@ fun PeopleSection( // Offline favorites (exclude ones mapped to connected) val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.hexEncodedString() val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (!isMappedToConnected) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname @@ -368,6 +392,7 @@ fun PeopleSection( sortedPeers.forEach { 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] @@ -392,6 +417,7 @@ fun PeopleSection( isDirect = isDirectLive, isSelected = peerID == selectedPrivatePeer, isFavorite = isFavorite, + isVerified = isVerified, hasUnreadDM = combinedHasUnread, colorScheme = colorScheme, viewModel = viewModel, @@ -408,7 +434,7 @@ fun PeopleSection( // Append offline favorites we actively favorite (and not currently connected) offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.hexEncodedString() // 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 @@ -419,7 +445,7 @@ fun PeopleSection( 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 + if (hrp == "npub") data.hexEncodedString() else null } else { npubOrHex.lowercase() } @@ -435,6 +461,7 @@ fun PeopleSection( val dn = peerNicknames[favPeerID] ?: fav.peerNickname val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 + val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) // Compute unreadCount from either noise conversation or Nostr conversation val unreadCount = ( @@ -449,6 +476,7 @@ fun PeopleSection( isDirect = false, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, + isVerified = isVerified, hasUnreadDM = hasUnread, colorScheme = colorScheme, viewModel = viewModel, @@ -516,6 +544,7 @@ private fun PeerItem( isDirect: Boolean, isSelected: Boolean, isFavorite: Boolean, + isVerified: Boolean, hasUnreadDM: Boolean, colorScheme: ColorScheme, viewModel: ChatViewModel, @@ -616,6 +645,16 @@ private fun PeerItem( color = baseColor.copy(alpha = 0.6f) ) } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) + ) + } } // Favorite star with proper filled/outlined states diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt new file mode 100644 index 00000000..32620281 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -0,0 +1,368 @@ +package com.bitchat.android.ui + +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.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val context: Context, + private val scope: CoroutineScope, + private val meshService: BluetoothMeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + + private val _verifiedFingerprints = MutableStateFlow>(emptySet()) + val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + + private val pendingQRVerifications = ConcurrentHashMap() + private val lastVerifyNonceByPeer = ConcurrentHashMap() + private val lastInboundVerifyChallengeAt = ConcurrentHashMap() + private val lastMutualToastAt = ConcurrentHashMap() + + fun loadVerifiedFingerprints() { + _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + } + + fun isPeerVerified(peerID: String): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = getPeerFingerprintForDisplay(peerID) + return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean { + val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + return _verifiedFingerprints.value.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + val fingerprint = meshService.getPeerFingerprint(peerID) ?: return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + val targetNoise = qr.noiseKeyHex.lowercase() + val peerID = state.getConnectedPeersValue().firstOrNull { pid -> + val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() + noiseKeyHex == targetNoise + } ?: return false + + if (pendingQRVerifications.containsKey(peerID)) return true + val nonce = ByteArray(16) + java.security.SecureRandom().nextBytes(nonce) + val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) + pendingQRVerifications[peerID] = pending + + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } else { + meshService.initiateNoiseHandshake(peerID) + } + fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> + identityManager.cacheFingerprintNickname(fp, qr.nickname) + identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) + identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) + } + return true + } + + fun sendPendingVerificationIfNeeded(peerID: String) { + val pending = pendingQRVerifications[peerID] ?: return + if (pending.sent) return + meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } + + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) { + scope.launch { + val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch + val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch + if (parsed.first.lowercase() != myNoiseHex) return@launch + + val lastNonce = lastVerifyNonceByPeer[peerID] + if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch + lastVerifyNonceByPeer[peerID] = parsed.second + + val fp = meshService.getPeerFingerprint(peerID) + if (fp != null) { + lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() + if (_verifiedFingerprints.value.contains(fp)) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val name = resolvePeerDisplayName(peerID) + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + + meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) + } + } + + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) { + scope.launch { + val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch + val pending = pendingQRVerifications[peerID] ?: return@launch + if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch + if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch + + val ok = VerificationService.verifyResponseSignature( + noiseKeyHex = resp.noiseKeyHex, + nonceA = resp.nonceA, + signature = resp.signature, + signerPublicKeyHex = pending.signKeyHex + ) + if (!ok) return@launch + + pendingQRVerifications.remove(peerID) + val fp = meshService.getPeerFingerprint(peerID) ?: return@launch + identityManager.setVerifiedFingerprint(fp, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fp) + _verifiedFingerprints.value = current + + val name = resolvePeerDisplayName(peerID) + identityManager.cacheFingerprintNickname(fp, name) + val noiseKeyHex = try { + meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + } catch (_: Exception) { + null + } + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) + } + addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID) + + val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L + if (System.currentTimeMillis() - lastChallenge < 600_000L) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + val fromMap = state.getPeerFingerprintsValue()[peerID] + if (fromMap != null) return fromMap + val hexRegex = Regex("^[0-9a-fA-F]+$") + return try { + when { + peerID.length == 64 && peerID.matches(hexRegex) -> { + identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } + fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } + } + peerID.length == 16 && peerID.matches(hexRegex) -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + peerID.startsWith("nostr_") -> { + val pubHex = GeohashAliasRegistry.get(peerID) + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + peerID.startsWith("nostr:") -> { + val prefix = peerID.removePrefix("nostr:").lowercase() + val pubHex = GeohashAliasRegistry + .snapshot() + .values + .firstOrNull { it.lowercase().startsWith(prefix) } + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + else -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + } + } catch (_: Exception) { + null + } + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + val nicknameMap = state.peerNicknames.value + nicknameMap[peerID]?.let { return it } + try { + meshService.getPeerInfo(peerID)?.nickname?.let { return it } + } catch (_: Exception) { } + + val fingerprint = getPeerFingerprintForDisplay(peerID) + fingerprint?.let { fp -> + identityManager.getCachedFingerprintNickname(fp)?.let { cached -> + if (cached.isNotBlank()) return cached + } + } + + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (peerID.length == 64 && peerID.matches(hexRegex)) { + val noiseKeyBytes = try { + peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (_: Exception) { null } + val favorite = noiseKeyBytes?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + if (peerID.length == 16 && peerID.matches(hexRegex)) { + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + return peerID.take(8) + } + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fingerprint) + _verifiedFingerprints.value = current + } + + fun unverifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + private fun addVerificationSystemMessage(peerID: String, text: String) { + val msg = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + messageManager.addPrivateMessageNoUnread(peerID, msg) + } + + private fun resolvePeerDisplayName(peerID: String): String { + val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } + return nick ?: peerID.take(8) + } + + private fun sendVerificationNotification(title: String, body: String, peerID: String) { + notificationManager.showVerificationNotification(title, body, peerID) + } + + private fun fingerprintFromNoiseHex(noiseHex: String): String? { + val bytes = noiseHex.dataFromHexString() ?: return null + return fingerprintFromNoiseBytes(bytes) + } + + fun fingerprintFromNoiseBytes(bytes: ByteArray): String { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes) + return hash.hexEncodedString() + } + + private data class PendingVerification( + val noiseKeyHex: String, + val signKeyHex: String, + val nonceA: ByteArray, + val startedAtMs: Long, + val sent: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingVerification + + if (startedAtMs != other.startedAtMs) return false + if (sent != other.sent) return false + if (noiseKeyHex != other.noiseKeyHex) return false + if (signKeyHex != other.signKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + + return true + } + + override fun hashCode(): Int { + var result = startedAtMs.hashCode() + result = 31 * result + sent.hashCode() + result = 31 * result + noiseKeyHex.hashCode() + result = 31 * result + signKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + return result + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt new file mode 100644 index 00000000..896a66c1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -0,0 +1,496 @@ +package com.bitchat.android.ui + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCode +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.services.VerificationService +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.flow.MutableStateFlow +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + + var showingScanner by remember { mutableStateOf(false) } + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val npub = remember { + viewModel.getCurrentNpub() + } + + + val qrString = remember(nickname, npub) { + viewModel.buildMyQRString(nickname, npub) + } + + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + VerificationHeader( + accent = accent, + onClose = onDismiss + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (showingScanner) { + QRScannerPanel( + accent = accent, + boxColor = boxColor, + onScan = { code -> + val qr = VerificationService.verifyScannedQR(code) + if (qr != null && viewModel.beginQRVerification(qr)) { + showingScanner = false + } + } + ) + } else { + MyQrPanel( + qrString = qrString, + accent = accent, + boxColor = boxColor, + ) + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ToggleVerificationModeButton( + showingScanner = showingScanner, + onToggle = { showingScanner = !showingScanner } + ) + + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + if (peerID != null) { + val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!) + if (fingerprint != null && fingerprints.contains(fingerprint)) { + Button( + onClick = { viewModel.unverifyFingerprint(peerID!!) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + } + } + } +} + +@Composable +private fun VerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.verify_title).uppercase(), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = accent + ) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun MyQrPanel( + qrString: String, + accent: Color, + boxColor: Color, +) { + Text( + text = stringResource(R.string.verify_my_qr_title), + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + color = accent, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + QRCodeCard(qrString = qrString, boxColor = boxColor) +} + +@Composable +private fun ToggleVerificationModeButton( + showingScanner: Boolean, + onToggle: () -> Unit +) { + Button( + onClick = onToggle, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) { + if (showingScanner) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.verify_show_my_qr), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ) + } else { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.verify_scan_someone), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ) + } + } +} + +@Composable +private fun QRCodeCard(qrString: String, boxColor: Color) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (qrString.isNotBlank()) { + QRCodeImage(data = qrString, size = 220.dp) + } else { + Box( + modifier = Modifier + .size(220.dp) + .background(Color.Transparent, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.verify_qr_unavailable), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + SelectionContainer { + Text( + text = qrString, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun QRCodeImage(data: String, size: Dp) { + val sizePx = with(LocalDensity.current) { size.toPx().toInt() } + val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size) + ) + } +} + +private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) return null + return try { + val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx) + bitmapFromMatrix(matrix) + } catch (_: Exception) { + null + } +} + +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 +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun QRScannerPanel( + onScan: (String) -> Unit, + accent: Color, + boxColor: Color, + modifier: Modifier = Modifier +) { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lastValid by remember { mutableStateOf(null) } + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val surfaceRequests = remember { MutableStateFlow(null) } + val surfaceRequest by surfaceRequests.collectAsState(initial = null) + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + val onCodeState = rememberUpdatedState(onScan) + val analyzer = remember { + QRCodeAnalyzer { text -> + mainHandler.post { + if (text == lastValid) return@post + lastValid = text + onCodeState.value(text) + } + } + } + + DisposableEffect(permissionState.status.isGranted) { + var cameraProvider: ProcessCameraProvider? = null + if (permissionState.status.isGranted) { + val executor = ContextCompat.getMainExecutor(context) + cameraProviderFuture.addListener( + { + val provider = cameraProviderFuture.get() + cameraProvider = provider + val preview = Preview.Builder() + .build() + .also { it.setSurfaceProvider { request -> surfaceRequests.value = request } } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + + runCatching { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + }.onFailure { + Log.w("VerificationSheet", "Failed to bind camera: ${it.message}") + } + }, + executor + ) + } + + onDispose { + surfaceRequests.value = null + runCatching { cameraProvider?.unbindAll() } + } + } + + DisposableEffect(Unit) { + onDispose { cameraExecutor.shutdown() } + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(boxColor, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + color = accent, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + if (permissionState.status.isGranted) { + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + implementationMode = ImplementationMode.EMBEDDED, + modifier = Modifier + .size(220.dp) + .clipToBounds() + ) + } + } else { + Text( + text = stringResource(R.string.verify_camera_permission), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +private class QRCodeAnalyzer( + private val onCode: (String) -> Unit +) : ImageAnalysis.Analyzer { + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + @ExperimentalGetImage + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image ?: run { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue + if (!text.isNullOrBlank()) onCode(text) + } + .addOnCompleteListener { imageProxy.close() } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index ca031df9..0e6a84f0 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -58,6 +58,10 @@ object AppConstants { const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L } + object Verification { + const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes + } + object Protocol { const val COMPRESSION_THRESHOLD_BYTES: Int = 100 } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3f6d7ddf..7ddae251 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -363,4 +363,40 @@ إغلاق أضف ملاحظة لهذا المكان الشبكة تعمل — %1$d أقران + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 9948b452..24f94630 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -350,4 +350,40 @@ মেশ চলছে — %1$d পিয়ার + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 84eb0fd4..c02bbb37 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -70,7 +70,7 @@ Aus Favoriten entfernen Lesezeichen hinzufügen - + zurück Kanal: %1$s verlassen @@ -170,7 +170,7 @@ Online‑Geohash‑Kanäle Ende‑zu‑Ende‑Verschlüsselung - + Bild %1$d von %2$d Bild nicht verfügbar Bild in "Downloads" gespeichert @@ -364,4 +364,40 @@ schließen füge eine Notiz zu diesem Ort hinzu Mesh läuft — %1$d Peers + + verifizieren + scannen zur verifizierung + anderen qr scannen + anderen qr scannen + mein qr zeigen + verifizierung entfernen + qr nicht verfügbar + kamera-berechtigung nötig + kamera aktivieren + verifizierungs-url einfügen + validieren + verifizierung angefordert + sicherheitsüberprüfung + ihr fingerabdruck + dein fingerabdruck + handshake ausstehend + privatchat öffnen + verschlüsselt & verifiziert + verschlüsselt + handshake + handshake fehlgeschlagen + unverschlüsselt + verifiziert + du hast diese person verifiziert. + nicht verifiziert + vergleiche fingerabdrücke mit %1$s. + als verifiziert markieren + handshake starten + kopieren + Gegenseitige Verifizierung + Du und %1$s habt euch verifiziert + gegenseitige verifizierung mit %1$s + Verifiziert + Du hast %1$s verifiziert + verifiziert %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b72bfdf3..eae48a0e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -363,4 +363,40 @@ cerrar agrega una nota para este lugar Mesh en ejecución — %1$d pares + + verificar + escanear para verificarme + escanear qr de otro + escanear qr de otro + mostrar mi qr + eliminar verificación + qr no disponible + se necesita permiso de cámara + activar cámara + pegar url de verificación + validar + verificación solicitada + verificación de seguridad + su huella digital + tu huella digital + intercambio pendiente + abrir chat privado para ver + cifrado y verificado + cifrado + negociando + falló negociación + no cifrado + verificado + has verificado esta identidad. + no verificado + compara huellas con %1$s por canal seguro. + marcar verificado + iniciar handshake + copiar + Verificación mutua + Tú y %1$s se verificaron + verificación mutua con %1$s + Verificado + Verificaste a %1$s + verificado %1$s diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 4007cf1a..1473c5fd 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -350,4 +350,40 @@ مش در حال اجرا — %1$d همتا + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 b89824d6..0a7dc2e3 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -363,4 +363,40 @@ Tumatakbo ang Mesh — %1$d na peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e0e7c7eb..01f480d2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -70,7 +70,7 @@ Retirer des favoris Ajouter un signet - + retour canal : %1$s quitter @@ -165,7 +165,7 @@ Canaux Geohash en ligne Chiffrement de bout en bout - + Image %1$d sur %2$d Image indisponible Image enregistrée dans "Téléchargements" @@ -377,4 +377,40 @@ fermer ajoutez une note pour cet endroit Mesh actif — %1$d pairs + + vérifier + scanner pour me vérifier + scanner un autre qr + scanner un autre qr + afficher mon qr + supprimer la vérification + qr indisponible + permission caméra requise + activer caméra + coller url de vérification + valider + vérification demandée + vérification de sécurité + leur empreinte + votre empreinte + négociation en cours + ouvrir un chat privé + chiffré & vérifié + chiffré + négociation + échec négociation + non chiffré + vérifié + vous avez vérifié cette personne. + non vérifié + comparez avec %1$s via canal sûr. + marquer vérifié + lancer handshake + copier + Vérification mutuelle + Vous et %1$s êtes vérifiés + vérification mutuelle avec %1$s + Vérifié + Vous avez vérifié %1$s + vérifié %1$s diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 059924d4..deb22711 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -15,5 +15,41 @@ סגור הוסף הערה למקום זה רשת Mesh פועלת — %1$d עמיתים + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 780015b0..acaa54b6 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -70,7 +70,7 @@ पसंदीदा से हटाएँ बुकमार्क जोड़ें - + वापस चैनल: %1$s छोड़ें @@ -363,4 +363,40 @@ बंद करें इस स्थान के लिए एक नोट जोड़ें मेश चल रहा है — %1$d पीयर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 744a499c..18c85a5b 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -70,7 +70,7 @@ Hapus dari favorit Tambah bookmark - + kembali Channel: %1$s keluar @@ -170,7 +170,7 @@ Channel Geohash Online Enkripsi end-to-end - + Gambar %1$d dari %2$d Gambar tidak tersedia Gambar disimpan ke \"Downloads\" @@ -363,4 +363,40 @@ tutup tambahkan catatan untuk tempat ini Mesh berjalan — %1$d peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 185dfa3f..07fb5a76 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -397,4 +397,40 @@ chiudi aggiungi una nota per questo luogo Mesh in esecuzione — %1$d peer + + verifica + scansiona per verificarmi + scansiona qr altrui + scansiona qr altrui + mostra mio qr + rimuovi verifica + qr non disponibile + permesso fotocamera necessario + abilita fotocamera + incolla url verifica + convalida + verifica richiesta + verifica sicurezza + loro impronta + tua impronta + handshake in corso + apri chat privata + criptato & verificato + criptato + handshake + handshake fallito + non criptato + verificato + hai verificato questa persona. + non verificato + confronta con %1$s via canale sicuro. + segna verificato + avvia handshake + copia + Verifica reciproca + Tu e %1$s vi siete verificati + verifica reciproca con %1$s + Verificato + Hai verificato %1$s + verificato %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7d8f530f..f9c4fd64 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -70,7 +70,7 @@ お気に入りから削除 ブックマークに追加 - + 戻る チャンネル: %1$s 退出 @@ -170,7 +170,7 @@ オンライン Geohash チャンネル エンドツーエンド暗号化 - + 画像 %1$d / %2$d 画像は利用できません 画像を「ダウンロード」に保存しました @@ -363,4 +363,40 @@ 閉じる この場所へのメモを追加 メッシュ実行中 — %1$d ピア + + 検証 + 私を検証するためにスキャン + 他人のQRをスキャン + 他人のQRをスキャン + QRコードを表示 + 検証を削除 + QR利用不可 + QRスキャンにカメラ権限が必要です + カメラを有効化 + 検証URLを貼り付け + 検証する + 検証をリクエストしました + セキュリティ検証 + 相手の指紋 + あなたの指紋 + ハンドシェイク保留中 + 指紋を見るにはプライベートチャットを開いてください + 暗号化&検証済み + 暗号化済み + ハンドシェイク中 + ハンドシェイク失敗 + 未暗号化 + 検証済み + この人物の身元を検証しました。 + 未検証 + 安全な経路で %1$s と指紋を比較してください。 + 検証済みにする + ハンドシェイク開始 + コピー + 相互検証 + あなたと %1$s は相互に検証しました + %1$s と相互検証しました + 検証済み + %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 7c999d96..db24c8a9 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -70,7 +70,7 @@ რჩეულებიდან წაშლა სანიშნეს დამატება - + უკან არხი: %1$s გასვლა @@ -170,7 +170,7 @@ ონლაინ geohash არხები ბოლო-მდე დაშიფვრა - + სურათი %1$d %2$d-დან სურათი მიუწვდომელია სურათი შენახულია "ჩამოტვირთვებში" @@ -350,4 +350,40 @@ Mesh გაშვებულია — %1$d პირები + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 bf9b6d53..0510fb7d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,7 +70,7 @@ 즐겨찾기에서 제거 북마크 추가 - + 뒤로 채널: %1$s 나가기 @@ -170,7 +170,7 @@ 온라인 geohash 채널 종단간 암호화 - + 이미지 %1$d / %2$d 이미지를 사용할 수 없음 이미지가 \"Downloads\"에 저장됨 @@ -363,4 +363,40 @@ 닫기 이 장소에 노트 추가 메시 실행 중 — %1$d 피어 + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 4bed7bc0..5fc53577 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -70,7 +70,7 @@ Hanesorana amin\'ny ankafizina Hanampy bookmark - + miverina fantsona: %1$s hiala @@ -170,7 +170,7 @@ Fantsona Geohash an-tserasera Fandokoana Farany amin\'ny Farany - + Sary %1$d amin\'ny %2$d Tsy misy sary Sary voatahiry ao amin\'ny Downloads @@ -377,4 +377,40 @@ Mandeha ny Mesh — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 5027aade..0e6dd945 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2,5 +2,41 @@ Mesh sedang berjalan — %1$d rakan + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index bae76cf0..6632f0dc 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -363,4 +363,40 @@ मेश चलिरहेको छ — %1$d पियर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 40fdf86b..7d19b52a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -70,7 +70,7 @@ Verwijderen uit favorieten Bladwijzer toevoegen - + terug kanaal: %1$s verlaten @@ -170,7 +170,7 @@ Online geohash-kanalen End-to-end-versleuteling - + Afbeelding %1$d van %2$d Afbeelding niet beschikbaar Afbeelding opgeslagen in Downloads @@ -338,7 +338,7 @@ Deelnemen Annuleren - + @ bitchat/ · ⧉ @@ -395,4 +395,40 @@ sluiten voeg een notitie toe voor deze plek Mesh actief — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 80ea2b84..3f08aa32 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -70,7 +70,7 @@ پسندیدہ توں ہٹاؤ بُک مارک پاؤ - + واپس چینل: %1$s باہر آؤ @@ -170,7 +170,7 @@ آن لائن geohash چینل اینڈ ٹو اینڈ انکرپشن - + تصویر %1$d وچوں %2$d تصویر دستیاب نئیں تصویر \"Downloads\" وچ سیو ہو گئی @@ -350,4 +350,40 @@ میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 daa016d6..56cd087a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -15,5 +15,41 @@ zamknij dodaj notatkę dla tego miejsca Mesh działa — %1$d peerów + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d736dd39..e9686f99 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -70,7 +70,7 @@ Remover dos favoritos Adicionar favorito - + voltar Canal: %1$s sair @@ -170,7 +170,7 @@ Canais geohash online Criptografia de ponta a ponta - + Imagem %1$d de %2$d Imagem indisponível Imagem salva em "Downloads" @@ -363,4 +363,40 @@ fechar adicione uma nota para este local Mesh rodando — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8e87c6e6..8f9baf72 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -363,4 +363,40 @@ fechar adicione uma nota para este local Mesh em execução — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c6c3bd37..cad2a0d1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -353,4 +353,40 @@ закрыть добавьте заметку для этого места Mesh запущен — %1$d пиров + + проверить + сканируй для проверки + сканировать чужой QR + сканировать чужой QR + показать мой QR + удалить проверку + QR недоступен + нужен доступ к камере + включить камеру + вставить URL проверки + проверить + запрос проверки + проверка безопасности + их отпечаток + ваш отпечаток + ожидание рукопожатия + откройте личный чат + шифровано и проверено + шифровано + рукопожатие + сбой рукопожатия + не шифровано + проверено + вы проверили этого человека. + не проверено + сравните с %1$s. + отметить проверенным + начать рукопожатие + копировать + Взаимная проверка + Вы и %1$s проверили друг друга + взаимная проверка с %1$s + Проверено + Вы проверили %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 d461be4c..ef3307e3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -351,4 +351,40 @@ stäng lägg till en anteckning för den här platsen Mesh körs — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index c9e8943c..98ceef9c 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -2,5 +2,41 @@ மெஷ் இயங்குகிறது — %1$d பியர்ஸ் + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 9ae074c7..343f6885 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -70,7 +70,7 @@ เอาออกจากรายการโปรด เพิ่มที่คั่นหน้า - + กลับ ช่อง: %1$s ออก @@ -170,7 +170,7 @@ ช่อง geohash ออนไลน์ การเข้ารหัสแบบปลายทางถึงปลายทาง - + รูปภาพ %1$d จาก %2$d ไม่สามารถใช้รูปภาพได้ บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว @@ -350,4 +350,40 @@ Mesh กำลังทำงาน — %1$d เพื่อน + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 291c9904..1209074c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -351,4 +351,40 @@ kapat bu yer için bir not ekleyin Mesh çalışıyor — %1$d eş + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 16914c6a..5545b546 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2,5 +2,41 @@ Mesh працює — %1$d пірів + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 b782115f..1626a041 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -363,4 +363,40 @@ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + 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 ec726fc9..afc227b6 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -70,7 +70,7 @@ Xóa khỏi yêu thích Thêm bookmark - + quay lại Kênh: %1$s rời khỏi @@ -170,7 +170,7 @@ Kênh Geohash trực tuyến Mã hóa đầu cuối - + Hình ảnh %1$d trong %2$d Hình ảnh không có sẵn Hình ảnh đã lưu vào \"Downloads\" @@ -350,4 +350,40 @@ Mesh đang chạy — %1$d ngang hàng + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b979b121..9f4b17d3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -15,5 +15,41 @@ 关闭 为此地点添加一条笔记 Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7be4525f..a347d5b0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -15,5 +15,41 @@ 關閉 為此地點新增一則筆記 Mesh 運行中 — %1$d 個節點 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 17f53bbd..3a4a628f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -376,4 +376,40 @@ 关闭 为此地点添加一条笔记 Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85513de..6fe23b49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,7 +71,7 @@ Remove from favorites Add bookmark - + back channel: %1$s leave @@ -265,6 +265,44 @@ none disconnect recent scan results + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + connect debug console clear diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7bfdd8a..2264de75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,13 @@ gms-location = "21.3.0" # Security security-crypto = "1.1.0-beta01" +# QR +zxing-core = "3.5.4" + +# CameraX / ML Kit +camerax = "1.5.2" +mlkit-barcode = "17.3.0" + # Testing junit = "4.13.2" androidx-test-ext = "1.2.1" @@ -104,6 +111,15 @@ gms-location = { module = "com.google.android.gms:play-services-location", versi # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } +# QR +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } + +# 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" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } From 55b2d68defead1896961f87fef7c865f642ac79b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:47:24 +0700 Subject: [PATCH 13/24] Enforce mandatory packet signature verification (#546) * security: enforce mandatory packet signature verification * tests: add comprehensive unit tests for packet signature verification --- .../android/crypto/EncryptionService.kt | 17 +- .../bitchat/android/mesh/SecurityManager.kt | 62 +++-- .../android/mesh/SecurityManagerTest.kt | 255 ++++++++++++++++++ 3 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt 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 449d705f..c615d62b 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap * This is the main interface for all encryption/decryption operations in bitchat. * It now uses the Noise protocol for secure transport encryption with proper session management. */ -class EncryptionService(private val context: Context) { +open class EncryptionService(private val context: Context) { companion object { private const val TAG = "EncryptionService" @@ -27,14 +27,14 @@ class EncryptionService(private val context: Context) { } // Core Noise encryption service - private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context) + private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) } // Session tracking for established connections private val establishedSessions = ConcurrentHashMap() // peerID -> fingerprint // Ed25519 signing keys (separate from Noise static keys) - private val ed25519PrivateKey: Ed25519PrivateKeyParameters - private val ed25519PublicKey: Ed25519PublicKeyParameters + private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters + private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters // Callbacks for UI state updates var onSessionEstablished: ((String) -> Unit)? = null // peerID @@ -42,6 +42,13 @@ class EncryptionService(private val context: Context) { var onHandshakeRequired: ((String) -> Unit)? = null // peerID init { + initialize() + } + + /** + * Initialization logic moved to method to allow overriding in tests + */ + protected open fun initialize() { // Initialize or load Ed25519 signing keys val keyPair = loadOrCreateEd25519KeyPair() ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters @@ -356,7 +363,7 @@ class EncryptionService(private val context: Context) { /** * Verify Ed25519 signature against data using a public key */ - fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { return try { val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0) val verifier = Ed25519Signer() 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 bce69c26..8ad8e42c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -69,8 +69,11 @@ class SecurityManager(private val encryptionService: EncryptionService, private // They are signed/idempotent and we need to ensure first-announce per-connection can bind. } - // NEW: Signature verification logging (not rejecting yet) - verifyPacketSignatureWithLogging(packet, peerID) + // Enforce mandatory signature verification + if (!verifyPacketSignature(packet, peerID)) { + Log.w(TAG, "Dropping packet from $peerID due to signature verification failure") + return false + } Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID") return true @@ -223,34 +226,50 @@ class SecurityManager(private val encryptionService: EncryptionService, private } /** - * Verify packet signature using peer's signing public key and log the result + * Verify packet signature using peer's signing public key + * Returns true only if signature is present and valid */ - private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) { + private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { try { - // Check if packet has a signature + // 1. Mandatory Signature Check if (packet.signature == null) { - Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") - return + Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") + return false } - // Try to get peer's signing public key from peer info - val peerInfo = delegate?.getPeerInfo(peerID) - val signingPublicKey = peerInfo?.signingPublicKey + // 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 + } if (signingPublicKey == null) { - Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})") - return + // If we don't have a key (and it's not an announce), we can't verify. + // For security, we must reject packets from unknown peers unless it's an announce. + Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})") + return false } - // Get the canonical packet data for signature verification (without signature) + // 3. Get Canonical Data val packetDataForSigning = packet.toBinaryDataForSigning() if (packetDataForSigning == null) { - Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") - return + Log.w(TAG, "❌ Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") + return false } - // Verify the signature using the peer's signing public key - val signature = packet.signature!! // We already checked for null above + // 4. Verify Signature + val signature = packet.signature!! val isSignatureValid = encryptionService.verifyEd25519Signature( signature, packetDataForSigning, @@ -258,13 +277,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private ) if (isSignatureValid) { - Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})") + // Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})") + return true } else { - Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})") + Log.w(TAG, "❌ Signature INVALID for $peerID (type ${packet.type})") + return false } } catch (e: Exception) { - Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})") + Log.e(TAG, "❌ Signature verification error for $peerID: ${e.message}") + return false } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt new file mode 100644 index 00000000..b3e18956 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -0,0 +1,255 @@ +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.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +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.* +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 SecurityManagerTest { + + private lateinit var securityManager: SecurityManager + private lateinit var fakeEncryptionService: FakeEncryptionService + private lateinit var mockDelegate: SecurityManagerDelegate + + private val myPeerID = "1111222233334444" + private val otherPeerID = "aaaabbbbccccdddd" + private val unknownPeerID = "9999888877776666" + + 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 lastVerifyKey: ByteArray? = null + + override fun initialize() { + // Do nothing to avoid KeyStore access in tests + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + lastVerifySignature = signature + lastVerifyKey = publicKeyBytes + + // Simple logic: if configured to verify, check if signature matches validSignature + // We use the signature bytes passed in setup() + if (shouldVerify) { + return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) + } + return false + } + } + + @Before + fun setup() { + fakeEncryptionService = FakeEncryptionService() + mockDelegate = mock() + + securityManager = SecurityManager(fakeEncryptionService, myPeerID) + securityManager.delegate = mockDelegate + } + + @After + fun tearDown() { + if (::securityManager.isInitialized) { + securityManager.shutdown() + } + } + + @Test + fun `validatePacket - rejects packet with missing signature`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet without signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet with invalid signature`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet from unknown peer (no key)`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result) + } + + @Test + fun `validatePacket - accepts packet with valid signature from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertTrue("Valid signed packet from known peer should be accepted", result) + } + + @Test + fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result) + // Verify we used the correct key + assertTrue("Should have used extracted key for verification", + fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() { + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = byteArrayOf(0x00, 0x01, 0x02) + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) + } + + @Test + fun `validatePacket - ignores own packets`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = myPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, myPeerID) + + assertFalse("Own packets should return false (skipped)", result) + } + + @Test + fun `validatePacket - detects duplicates`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result1 = securityManager.validatePacket(packet, otherPeerID) + assertTrue("First packet should be accepted", result1) + + val result2 = securityManager.validatePacket(packet, otherPeerID) + assertFalse("Duplicate packet should be rejected", result2) + } + + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { + val info = PeerInfo( + id = peerID, + nickname = "Test User", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32), + signingPublicKey = signingKey, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) + } +} From 123a24ce34141c990840b6301de589626db59a64 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:08:52 +0700 Subject: [PATCH 14/24] Revert "Fix small-screen header clipping (#519) (#532)" (#548) This reverts commit d73976537d6df37f794063c4b6e94981ec6af96e. --- .../java/com/bitchat/android/ui/ChatHeader.kt | 18 +--- .../java/com/bitchat/android/ui/ChatScreen.kt | 87 +++++++++---------- gradle.properties | 2 +- 3 files changed, 47 insertions(+), 60 deletions(-) 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 658d6088..6d3a9f3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -26,8 +26,6 @@ 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 androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.style.TextAlign import com.bitchat.android.core.ui.utils.singleOrTripleClickable import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset @@ -412,9 +410,7 @@ private fun PrivateChatHeader( Text( text = titleText, style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange - maxLines = 1, - overflow = TextOverflow.Ellipsis + color = Color(0xFFFF9500) // Orange ) Spacer(modifier = Modifier.width(4.dp)) @@ -517,12 +513,8 @@ private fun ChannelHeader( style = MaterialTheme.typography.titleMedium, color = Color(0xFFFF9500), // Orange to match input field modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp) - .clickable { onSidebarClick() }, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis + .align(Alignment.Center) + .clickable { onSidebarClick() } ) // Leave button - positioned on the right @@ -580,9 +572,7 @@ private fun MainHeader( modifier = Modifier.singleOrTripleClickable( onSingleClick = onTitleClick, onTripleClick = onTripleTitleClick - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis + ) ) Spacer(modifier = Modifier.width(2.dp)) 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 c196ece3..bf6a73b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -23,7 +23,6 @@ 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.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage @@ -116,10 +115,7 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { val headerHeight = 42.dp - // Reserve exact height: header + status bar inset so content below won't be overlapped - val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val reservedHeaderHeight = headerHeight + statusBarTop - + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -127,10 +123,11 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - reserve space equal to header + status bar inset + // Header spacer - creates exact space for the floating header (status bar + compact header) Spacer( modifier = Modifier - .height(reservedHeaderHeight) + .windowInsetsPadding(WindowInsets.statusBars) + .height(headerHeight) ) // Messages area - takes up available space, will compress when keyboard appears @@ -259,7 +256,15 @@ fun ChatScreen(viewModel: ChatViewModel) { onLocationNotesClick = { showLocationNotesSheet = true } ) - + // Divider under header - positioned after status bar + header height + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .offset(y = headerHeight) + .zIndex(1f), + color = colorScheme.outline.copy(alpha = 0.3f) + ) val alpha by animateFloatAsState( targetValue = if (showSidebar) 0.5f else 0f, @@ -464,46 +469,38 @@ private fun ChatFloatingHeader( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .statusBarsPadding(), // Respect status bar insets + .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area color = colorScheme.background // Solid background color extending into status bar ) { - Column { - TopAppBar( - title = { - ChatHeaderContent( - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - viewModel = viewModel, - onBackClick = { - when { - selectedPrivatePeer != null -> viewModel.endPrivateChat() - currentChannel != null -> viewModel.switchToChannel(null) - } - }, - onSidebarClick = onSidebarToggle, - onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo, - onLocationChannelsClick = onLocationChannelsClick, - onLocationNotesClick = { - // Ensure location is loaded before showing sheet - locationManager.refreshChannels() - onLocationNotesClick() + TopAppBar( + title = { + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) } - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ), - modifier = Modifier.heightIn(min = headerHeight) // Allow header to expand for accessibility font scales - ) - - // Divider under header - always aligned with header bottom - Divider( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.outline.copy(alpha = 0.3f) - ) - } + }, + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo, + onLocationChannelsClick = onLocationChannelsClick, + onLocationNotesClick = { + // Ensure location is loaded before showing sheet + locationManager.refreshChannels() + onLocationNotesClick() + } + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + modifier = Modifier.height(headerHeight) // Ensure compact header height + ) } } diff --git a/gradle.properties b/gradle.properties index fce5240f..0838af96 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,4 +26,4 @@ android.nonTransitiveRClass=false kotlin.code.style=official # JVM heap size configuration to prevent OutOfMemoryError -org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError \ No newline at end of file From fe5417bf979f8d4f7c76cc93f4808971dbec7cea Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:09:14 +0700 Subject: [PATCH 15/24] fix: location channel manager (#516) * location channel manager fix * fix: use interval arg in beginLiveRefresh & remove unused code --- .../android/geohash/LocationChannelManager.kt | 108 +++++++++++++----- 1 file changed, 81 insertions(+), 27 deletions(-) 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 9c18a859..3e74a8ee 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -124,36 +124,55 @@ class LocationChannelManager private constructor(private val context: Context) { } /** - * Begin periodic one-shot location refreshes while a selector UI is visible + * Begin real-time location updates while a selector UI is visible + * Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately */ fun beginLiveRefresh(interval: Long = 5000L) { - Log.d(TAG, "Beginning live refresh with interval ${interval}ms") - + Log.d(TAG, "Beginning live refresh (continuous updates)") + if (_permissionState.value != PermissionState.AUTHORIZED) { Log.w(TAG, "Cannot start live refresh - permission not authorized") return } - + if (!isLocationServicesEnabled()) { Log.w(TAG, "Cannot start live refresh - location services disabled by user") return } - // Cancel existing timer + // Cancel any existing timer-based refreshers refreshTimer?.cancel() - - // Start new timer with coroutines - refreshTimer = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - if (isLocationServicesEnabled()) { - requestOneShotLocation() + refreshTimer = null + + // Register for continuous updates from available providers + try { + if (hasLocationPermission()) { + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER + ) + + providers.forEach { provider -> + if (locationManager.isProviderEnabled(provider)) { + // 2s min time, 5m min distance for responsive yet battery-aware updates + locationManager.requestLocationUpdates( + provider, + interval, + 5f, + continuousLocationListener + ) + Log.d(TAG, "Registered continuous updates for $provider") + } } - delay(interval) + + // Prime state immediately with last known / current location + requestOneShotLocation() } + } catch (e: SecurityException) { + Log.e(TAG, "Missing location permission for continuous updates: ${e.message}") + } catch (e: Exception) { + Log.e(TAG, "Failed to register continuous updates: ${e.message}") } - - // Kick off immediately - requestOneShotLocation() } /** @@ -163,6 +182,12 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Ending live refresh") refreshTimer?.cancel() refreshTimer = null + // Unregister continuous updates listener + try { + locationManager.removeUpdates(continuousLocationListener) + } catch (_: SecurityException) { + } catch (_: Exception) { + } } /** @@ -297,14 +322,38 @@ class LocationChannelManager private constructor(private val context: Context) { } } + // Continuous location listener for real-time updates + private val continuousLocationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + Log.d(TAG, "Real-time location: ${location.latitude}, ${location.longitude} acc=${location.accuracy}m") + lastLocation = location + _isLoadingLocation.value = false + computeChannels(location) + reverseGeocodeIfNeeded(location) + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated but can still be called on older devices + Log.v(TAG, "Provider status changed: $provider -> $status") + } + + override fun onProviderEnabled(provider: String) { + Log.d(TAG, "Provider enabled: $provider") + } + + override fun onProviderDisabled(provider: String) { + Log.d(TAG, "Provider disabled: $provider") + } + } + // One-time location listener to get a fresh location update private val oneShotLocationListener = object : LocationListener { override fun onLocationChanged(location: Location) { - Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}") + Log.d(TAG, "One-shot location: ${location.latitude}, ${location.longitude}") lastLocation = location computeChannels(location) reverseGeocodeIfNeeded(location) - + // Update loading state to indicate we have a location now _isLoadingLocation.value = false @@ -315,6 +364,18 @@ class LocationChannelManager private constructor(private val context: Context) { Log.e(TAG, "Error removing location listener: ${e.message}") } } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Required for compatibility with older platform versions + } + + override fun onProviderEnabled(provider: String) { + // Required for compatibility with older platform versions + } + + override fun onProviderDisabled(provider: String) { + // Required for compatibility with older platform versions + } } // Request a fresh location update using getCurrentLocation instead of continuous updates @@ -663,16 +724,9 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Cleaning up LocationChannelManager") endLiveRefresh() - // For older Android versions, remove any remaining location listener to prevent memory leaks - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { - try { - locationManager.removeUpdates(oneShotLocationListener) - } catch (e: SecurityException) { - Log.e(TAG, "Error removing location listener during cleanup: ${e.message}") - } catch (e: Exception) { - Log.e(TAG, "Error during cleanup: ${e.message}") - } - } + // Remove listeners to prevent memory leaks + try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {} + try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {} // For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation } } From 7ee61a04404c5f8f8e0d28e0a7490f2fcd438eae Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:12:51 +0700 Subject: [PATCH 16/24] fix: sync notification peer count with sidebar (#547) --- app/src/main/java/com/bitchat/android/mesh/PeerManager.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 4c279d4a..d4c84c03 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -299,8 +299,7 @@ class PeerManager { */ fun isPeerActive(peerID: String): Boolean { val info = peers[peerID] ?: return false - val now = System.currentTimeMillis() - return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected + return info.isConnected } /** @@ -328,8 +327,7 @@ class PeerManager { * Get list of active peer IDs */ fun getActivePeerIDs(): List { - val now = System.currentTimeMillis() - return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected } + return peers.filterValues { it.isConnected } .keys .toList() .sorted() From eeaabf487f7f5f00655b27b3aecbecf0007acde8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:42:35 +0700 Subject: [PATCH 17/24] bump versioncode to 28 (#551) --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7222b63d..0d6558e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 27 + versionCode = 28 versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 95385daaf91256b3e7873ba4429c99927081b3fa Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:22:06 +0700 Subject: [PATCH 18/24] verift signature only for ANNOUNCE, MESSAGE, and FILE_TRANSFER like ios (#552) --- .../main/java/com/bitchat/android/mesh/SecurityManager.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 8ad8e42c..7f70a4bb 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -231,6 +231,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private */ private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { try { + // only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER + if (MessageType.fromValue(packet.type) !in setOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.FILE_TRANSFER + )) { + return true + } // 1. Mandatory Signature Check if (packet.signature == null) { Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") From 97338066102191a98e2406b82db7f7030f710a31 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 11:39:13 +0700 Subject: [PATCH 19/24] bump to 29 (#553) --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d6558e7..cffe5b52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 28 + versionCode = 29 versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 423feb8b77c908e28805941b1a11a697deb43d7a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:09:04 +0700 Subject: [PATCH 20/24] Fix/foreground permissions scanning (#556) * try catch the foreground location service if not available * ask for background permissions * background permissions in onboarding * small improvements --- app/src/main/AndroidManifest.xml | 5 +- .../java/com/bitchat/android/MainActivity.kt | 34 ++- .../mesh/BluetoothPermissionManager.kt | 4 +- .../BackgroundLocationPermissionScreen.kt | 251 ++++++++++++++++++ .../BackgroundLocationPreferenceManager.kt | 21 ++ .../onboarding/OnboardingCoordinator.kt | 90 ++++++- .../android/onboarding/OnboardingState.kt | 3 +- .../onboarding/PermissionExplanationScreen.kt | 26 +- .../android/onboarding/PermissionManager.kt | 54 +++- .../android/service/MeshForegroundService.kt | 34 ++- app/src/main/res/values/strings.xml | 12 + 11 files changed, 481 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt create mode 100644 app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14a01098..8c957240 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + @@ -27,6 +28,8 @@ + + @@ -104,7 +107,7 @@ diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index a87c4a25..3b2d1857 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationStatus +import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -135,6 +136,9 @@ class MainActivity : OrientationAwareActivity() { activity = this, permissionManager = permissionManager, onOnboardingComplete = ::handleOnboardingComplete, + onBackgroundLocationRequired = { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + }, onOnboardingFailed = ::handleOnboardingFailed ) @@ -267,6 +271,21 @@ class MainActivity : OrientationAwareActivity() { ) } + OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> { + BackgroundLocationPermissionScreen( + modifier = modifier, + onContinue = { + onboardingCoordinator.requestBackgroundLocation() + }, + onRetry = { + onboardingCoordinator.checkBackgroundLocationAndProceed() + }, + onSkip = { + onboardingCoordinator.skipBackgroundLocation() + } + ) + } + OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { // Set up back navigation handling for the chat screen val backCallback = object : OnBackPressedCallback(true) { @@ -380,10 +399,17 @@ class MainActivity : OrientationAwareActivity() { if (permissionManager.isFirstTimeLaunch()) { Log.d("MainActivity", "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) - } else if (permissionManager.areAllPermissionsGranted()) { - Log.d("MainActivity", "Existing user with permissions, initializing app") - mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) - initializeApp() + } else if (permissionManager.areRequiredPermissionsGranted()) { + Log.d("MainActivity", "Existing user with required permissions") + if (permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity) + ) { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + } else { + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() + } } else { Log.d("MainActivity", "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt index 917de66e..45d48ac6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION )) - + return permissions.all { ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt new file mode 100644 index 00000000..1346cb23 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -0,0 +1,251 @@ +package com.bitchat.android.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +/** + * Explanation screen shown before requesting background location permission. + */ +@Composable +fun BackgroundLocationPermissionScreen( + modifier: Modifier, + onContinue: () -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .padding(bottom = 88.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + + HeaderSection(colorScheme) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = stringResource(R.string.cd_location_services), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_required_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_explanation), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_settings_tip), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_needs_for), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_needs_bullets), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_privacy_note), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium + ), + color = colorScheme.onBackground + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = colorScheme.surface, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.grant_background_location), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.check_again), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ) + ) + } + + TextButton( + onClick = onSkip, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.battery_optimization_skip), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } + } +} + +@Composable +private fun HeaderSection(colorScheme: ColorScheme) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground + ) + + Text( + text = stringResource(R.string.background_location_required_subtitle), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt new file mode 100644 index 00000000..0a73e346 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.onboarding + +import android.content.Context + +/** + * Preference manager for background location skip choice. + */ +object BackgroundLocationPreferenceManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped" + + fun setSkipped(context: Context, skipped: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply() + } + + fun isSkipped(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) + } +} 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 871cc892..701e9d19 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -19,6 +19,7 @@ class OnboardingCoordinator( private val activity: ComponentActivity, private val permissionManager: PermissionManager, private val onOnboardingComplete: () -> Unit, + private val onBackgroundLocationRequired: () -> Unit, private val onOnboardingFailed: (String) -> Unit ) { @@ -27,9 +28,11 @@ class OnboardingCoordinator( } private var permissionLauncher: ActivityResultLauncher>? = null + private var backgroundLocationLauncher: ActivityResultLauncher? = null init { setupPermissionLauncher() + setupBackgroundLocationLauncher() } /** @@ -43,6 +46,14 @@ class OnboardingCoordinator( } } + private fun setupBackgroundLocationLauncher() { + backgroundLocationLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + handleBackgroundLocationResult(granted) + } + } + /** * Start the onboarding process */ @@ -50,9 +61,14 @@ class OnboardingCoordinator( Log.d(TAG, "Starting onboarding process") permissionManager.logPermissionStatus() - if (permissionManager.areAllPermissionsGranted()) { - Log.d(TAG, "All permissions already granted, completing onboarding") - completeOnboarding() + if (permissionManager.areRequiredPermissionsGranted()) { + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; background location recommended") + onBackgroundLocationRequired() + } else { + Log.d(TAG, "Required permissions already granted, completing onboarding") + completeOnboarding() + } } else { Log.d(TAG, "Missing permissions, need to start explanation flow") // The explanation screen will be shown by the calling activity @@ -76,7 +92,11 @@ class OnboardingCoordinator( val missingPermissions = (missingRequired + optionalToRequest).distinct() if (missingPermissions.isEmpty()) { - completeOnboarding() + if (shouldRequestBackgroundLocation()) { + onBackgroundLocationRequired() + } else { + completeOnboarding() + } return } @@ -98,13 +118,19 @@ class OnboardingCoordinator( val criticalGranted = criticalPermissions.all { permissions[it] == true } when { - allGranted -> { - Log.d(TAG, "All permissions granted successfully") - completeOnboarding() - } criticalGranted -> { - Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") - showPartialPermissionWarning(permissions) + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; requesting background location next") + onBackgroundLocationRequired() + return + } + if (allGranted) { + Log.d(TAG, "All permissions granted successfully") + completeOnboarding() + } else { + Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") + showPartialPermissionWarning(permissions) + } } else -> { Log.d(TAG, "Critical permissions denied") @@ -113,15 +139,50 @@ class OnboardingCoordinator( } } + fun requestBackgroundLocation() { + val permission = permissionManager.getBackgroundLocationPermission() + if (permission == null) { + completeOnboarding() + return + } + Log.d(TAG, "Requesting background location permission") + backgroundLocationLauncher?.launch(permission) + } + + private fun handleBackgroundLocationResult(granted: Boolean) { + if (granted) { + Log.d(TAG, "Background location permission granted") + } else { + Log.w(TAG, "Background location permission denied; continuing without it") + } + completeOnboarding() + } + + fun skipBackgroundLocation() { + Log.d(TAG, "User skipped background location permission") + BackgroundLocationPreferenceManager.setSkipped(activity, true) + completeOnboarding() + } + + fun checkBackgroundLocationAndProceed() { + if (!shouldRequestBackgroundLocation()) { + completeOnboarding() + } + } + + private fun shouldRequestBackgroundLocation(): Boolean { + return permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !BackgroundLocationPreferenceManager.isSkipped(activity) + } + /** * Get the list of critical permissions that are absolutely required */ private fun getCriticalPermissions(): List { // For bitchat, Bluetooth and location permissions are critical - // Notifications are nice-to-have but not critical - return permissionManager.getRequiredPermissions().filter { permission -> - !permission.contains("POST_NOTIFICATIONS") - } + // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions() + return permissionManager.getRequiredPermissions() } /** @@ -208,6 +269,7 @@ class OnboardingCoordinator( private fun getPermissionDisplayName(permission: String): String { return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" + permission.contains("BACKGROUND") -> "Background Location" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt index f06ddd08..7fa07817 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt @@ -6,8 +6,9 @@ enum class OnboardingState { LOCATION_CHECK, BATTERY_OPTIMIZATION_CHECK, PERMISSION_EXPLANATION, + BACKGROUND_LOCATION_EXPLANATION, PERMISSION_REQUESTING, INITIALIZING, COMPLETE, ERROR -} \ No newline at end of file +} 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 2b84aefa..e982d2e1 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -12,12 +12,10 @@ 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.Settings -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* 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.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -209,29 +207,6 @@ private fun PermissionCategoryCard( color = colorScheme.onBackground.copy(alpha = 0.8f) ) - if (category.type == PermissionType.PRECISE_LOCATION) { - // Extra emphasis for location permission - Spacer(modifier = Modifier.height(4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.cd_warning), - tint = Color(0xFFFF9800), - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.location_tracking_warning), - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - ) - } - } } } } @@ -240,6 +215,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { return when (permissionType) { PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn + PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power 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 ff0a160f..c32f855b 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat +import com.bitchat.android.R /** * Centralized permission management for bitchat app @@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) { } /** - * Get all permissions required by the app + * Get required permissions that can be requested together. + * Background location is handled separately to ensure correct request order. * Note: Notification permission is optional and not included here, * so the app works without notification access. */ @@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) { return permissions } + /** + * Background location permission is required on Android 10+ for background BLE scanning. + * Must be requested after foreground location permissions are granted. + */ + fun needsBackgroundLocationPermission(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + fun getBackgroundLocationPermission(): String? { + return if (needsBackgroundLocationPermission()) { + Manifest.permission.ACCESS_BACKGROUND_LOCATION + } else { + null + } + } + + fun isBackgroundLocationGranted(): Boolean { + val permission = getBackgroundLocationPermission() ?: return true + return isPermissionGranted(permission) + } + /** * Get optional permissions that improve the experience but aren't required. * Currently includes POST_NOTIFICATIONS on Android 13+. @@ -93,9 +116,13 @@ class PermissionManager(private val context: Context) { } /** - * Check if all required permissions are granted + * Check if all required permissions are granted (background location is optional). */ fun areAllPermissionsGranted(): Boolean { + return areRequiredPermissionsGranted() + } + + fun areRequiredPermissionsGranted(): Boolean { return getRequiredPermissions().all { isPermissionGranted(it) } } @@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) { return getRequiredPermissions().filter { !isPermissionGranted(it) } } + fun getMissingBackgroundLocationPermission(): List { + val permission = getBackgroundLocationPermission() ?: return emptyList() + return if (isPermissionGranted(permission)) emptyList() else listOf(permission) + } + /** * Get categorized permission information for display */ @@ -177,6 +209,19 @@ class PermissionManager(private val context: Context) { ) ) + if (needsBackgroundLocationPermission()) { + val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + categories.add( + PermissionCategory( + type = PermissionType.BACKGROUND_LOCATION, + description = context.getString(R.string.perm_background_location_desc), + permissions = backgroundPermission, + isGranted = backgroundPermission.all { isPermissionGranted(it) }, + systemDescription = context.getString(R.string.perm_background_location_system) + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) { appendLine("Permission Diagnostics:") appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("First time launch: ${isFirstTimeLaunch()}") - appendLine("All permissions granted: ${areAllPermissionsGranted()}") + appendLine("Required permissions granted: ${areAllPermissionsGranted()}") appendLine() getCategorizedPermissions().forEach { category -> @@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) { appendLine() } - val missing = getMissingPermissions() + val missing = getMissingPermissions() + getMissingBackgroundLocationPermission() if (missing.isNotEmpty()) { appendLine("Missing permissions:") missing.forEach { permission -> @@ -260,6 +305,7 @@ data class PermissionCategory( enum class PermissionType(val nameValue: String) { NEARBY_DEVICES("Nearby Devices"), PRECISE_LOCATION("Precise Location"), + BACKGROUND_LOCATION("Background Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), BATTERY_OPTIMIZATION("Battery Optimization"), 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 998c03b2..bf1f236f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat @@ -176,7 +177,7 @@ class MeshForegroundService : Service() { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val n = buildNotification(meshService?.getActivePeerCount() ?: 0) - startForeground(NOTIFICATION_ID, n) + startForegroundCompat(n) isInForeground = true } else { updateNotification(force = true) @@ -191,7 +192,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) - startForeground(NOTIFICATION_ID, notification) + startForegroundCompat(notification) isInForeground = true } @@ -326,6 +327,35 @@ class MeshForegroundService : Service() { } } + private fun hasLocationPermission(): Boolean { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + return fine || coarse + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= 34) { + val type = if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + try { + startForeground(NOTIFICATION_ID, notification, type) + } catch (e: SecurityException) { + // Fallback for cases where "While In Use" permission exists but background start is restricted + if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) { + android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}") + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + throw e + } + } + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + override fun onDestroy() { updateJob?.cancel() updateJob = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fe23b49..9845fd85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,15 @@ bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature. bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection + Background Location Recommended + optional, improves mesh reliability + Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. + When settings opens, choose "Allow all the time". + bitchat uses background location for: + - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + We NEVER collect or store your location. Your privacy is safe. + Allow Background Location + Continue without background location Open Location Settings Check Again Location Services Unavailable @@ -405,6 +414,8 @@ Allow bitchat to connect to nearby devices Required by Android to discover nearby bitchat users via Bluetooth bitchat needs this to scan for nearby devices + Recommended to scan for nearby devices while the app is in the background + Allow background location access Receive notifications when you receive private messages Allow bitchat to send you notifications Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections @@ -413,6 +424,7 @@ Nearby Devices Precise Location + Background Location Microphone Notifications Battery Optimization From 171483a1da20fd108c896eb1757c7f601132469c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:41:17 +0700 Subject: [PATCH 21/24] drop duplicate announces based on TTL (#558) --- .../bitchat/android/mesh/SecurityManager.kt | 22 ++++++++----- .../android/mesh/SecurityManagerTest.kt | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) 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 7f70a4bb..5beb3384 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -56,18 +56,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Duplicate detection val messageID = generateMessageID(packet, peerID) - if (messageType != MessageType.ANNOUNCE) { - if (processedMessages.contains(messageID)) { + + if (processedMessages.contains(messageID)) { + // Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL) + // This ensures we catch the "first announce" on a new connection for binding, + // while still dropping looped/relayed duplicates. + val isFreshAnnounce = messageType == MessageType.ANNOUNCE && + packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + + if (!isFreshAnnounce) { Log.d(TAG, "Dropping duplicate packet: $messageID") return false } - // Add to processed messages - processedMessages.add(messageID) - messageTimestamps[messageID] = currentTime - } else { - // Do not deduplicate ANNOUNCE at the security layer. - // They are signed/idempotent and we need to ensure first-announce per-connection can bind. + 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)) { 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 b3e18956..aa629ca7 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -239,6 +239,37 @@ class SecurityManagerTest { assertFalse("Duplicate packet should be rejected", result2) } + @Test + fun `validatePacket - handles ANNOUNCE duplicates correctly`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + // 1. Initial Announce (Fresh) + val packet1 = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u + senderID = unknownPeerID, + payload = payload + ) + packet1.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID)) + + // 2. Relayed Duplicate (Lower TTL) + val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte()) + assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID)) + + // 3. Direct Duplicate (Max TTL) + val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS) + assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, From eef831fe604d6442d3284e7aa6ac48b60bdce71f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:41:41 +0700 Subject: [PATCH 22/24] scan duration fix (#557) --- app/build.gradle.kts | 1 + .../java/com/bitchat/android/MainActivity.kt | 6 --- .../mesh/BluetoothConnectionManager.kt | 7 --- .../com/bitchat/android/mesh/PowerManager.kt | 49 ++++++++++++++++--- .../com/bitchat/android/ui/ChatViewModel.kt | 4 -- .../com/bitchat/android/util/AppConstants.kt | 4 +- gradle/libs.versions.toml | 3 ++ 7 files changed, 47 insertions(+), 27 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cffe5b52..6605a757 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { // Lifecycle implementation(libs.bundles.lifecycle) + implementation(libs.androidx.lifecycle.process) // Navigation implementation(libs.androidx.navigation.compose) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 3b2d1857..f287784d 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -721,9 +721,6 @@ class MainActivity : OrientationAwareActivity() { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Reattach mesh delegate to new ChatViewModel instance after Activity recreation try { meshService.delegate = chatViewModel } catch (_: Exception) { } - // Set app foreground state - meshService.connectionManager.setAppBackgroundState(false) - chatViewModel.setAppBackgroundState(false) // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() @@ -750,9 +747,6 @@ class MainActivity : OrientationAwareActivity() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app background state - meshService.connectionManager.setAppBackgroundState(true) - chatViewModel.setAppBackgroundState(true) // Detach UI delegate so the foreground service can own DM notifications while UI is closed try { meshService.delegate = null } catch (_: Exception) { } } 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 8e0fa15b..6defd010 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -247,13 +247,6 @@ class BluetoothConnectionManager( return active } - /** - * Set app background state for power optimization - */ - fun setAppBackgroundState(inBackground: Boolean) { - powerManager.setAppBackgroundState(inBackground) - } - /** * Broadcast packet to connected devices with connection limit enforcement * Automatically fragments large packets to fit within BLE MTU limits diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 46bc394f..3096828a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -7,7 +7,13 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.* import kotlin.math.max @@ -15,7 +21,7 @@ import kotlin.math.max * Power-aware Bluetooth management for bitchat * Adjusts scanning, advertising, and connection behavior based on battery state */ -class PowerManager(private val context: Context) { +class PowerManager(private val context: Context) : LifecycleEventObserver { companion object { private const val TAG = "PowerManager" @@ -49,7 +55,7 @@ class PowerManager(private val context: Context) { private var currentMode = PowerMode.BALANCED private var isCharging = false private var batteryLevel = 100 - private var isAppInBackground = false + private var isAppInBackground = true private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var dutyCycleJob: Job? = null @@ -87,6 +93,16 @@ class PowerManager(private val context: Context) { init { registerBatteryReceiver() + + // Register for process lifecycle events on the main thread + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Failed to register lifecycle observer: ${e.message}") + } + } + updatePowerMode() } @@ -99,13 +115,30 @@ class PowerManager(private val context: Context) { Log.i(TAG, "Stopping power management") powerScope.cancel() unregisterBatteryReceiver() + + // Unregister lifecycle observer + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}") + } + } } - fun setAppBackgroundState(inBackground: Boolean) { - if (isAppInBackground != inBackground) { - isAppInBackground = inBackground - Log.d(TAG, "App background state changed: $inBackground") - updatePowerMode() + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_START -> { + Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)") + isAppInBackground = false + updatePowerMode() + } + Lifecycle.Event.ON_STOP -> { + Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)") + isAppInBackground = true + updatePowerMode() + } + else -> {} } } @@ -133,7 +166,7 @@ class PowerManager(private val context: Context) { .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) PowerMode.ULTRA_LOW_POWER -> builder - .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) } 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 58d5c77f..75101897 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -750,10 +750,6 @@ class ChatViewModel( return meshService.getDebugStatus() } - fun setAppBackgroundState(inBackground: Boolean) { - notificationManager.setAppBackgroundState(inBackground) - } - fun setCurrentPrivateChatPeer(peerID: String?) { notificationManager.setCurrentPrivateChatPeer(peerID) } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 0e6a84f0..904bc067 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -80,9 +80,9 @@ object AppConstants { const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L - const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L + const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L - const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L + const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2264de75..b6a5411a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,9 +61,12 @@ mockito-inline = "4.1.0" roboelectric = "4.15" kotlinx-coroutines-test = "1.6" +lifecycle-process = "2.8.7" + [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-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" } From 68407e3b47879965a8c79f89d9dc6cddbc1989df Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:28:53 +0200 Subject: [PATCH 23/24] feat: Build and release per-architecture APKs (#550) * feat: Build and release per-architecture APKs This commit modifies the build process to generate separate APKs for different CPU architectures (arm64-v8a, x86_64) and a universal APK. This allows for smaller, optimized downloads for users and provides specific builds for platforms like Chromebooks. Key changes: - In `app/build.gradle.kts`, enables ABI splits to create `arm64-v8a`, `x86_64`, and `universal` APKs during the `assembleRelease` task. - Updates the `release.yml` GitHub Actions workflow to rename and upload each of these APKs as distinct release assets. - Removes the previous `arm64-v8a` only filter to allow for multi-architecture builds. * feat: Allow manual triggering of Android CI workflow This adds the `workflow_dispatch` event to the `android-build.yml` GitHub Actions workflow. This change enables the Android CI pipeline to be run manually from the GitHub UI, in addition to the existing triggers for pushes and pull requests. --- .github/workflows/android-build.yml | 1 + .github/workflows/release.yml | 37 +++++++++++++++-------------- app/build.gradle.kts | 17 +++++++++---- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 2f8a7906..9911c273 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,6 +1,7 @@ name: Android CI on: + workflow_dispatch: push: branches: [ "main", "develop" ] pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79495ee9..f1e1841a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,17 +38,20 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x ./gradlew - - name: Build Release APK (with Tor) + - name: Build Release APKs (with architecture splits) run: ./gradlew assembleRelease --no-daemon --stacktrace - name: List APK files run: | echo "APK files built:" - find app/build/outputs/apk -name "*.apk" -type f + find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \; - - name: Rename APK + - name: Rename APKs for GitHub Release run: | - mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk + cd app/build/outputs/apk/release + [ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk + [ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk + [ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk - name: DEBUG run: | @@ -59,8 +62,8 @@ jobs: ls -all tree || ls -R - # Optional: Sign APK (requires secrets) - # - name: Sign APK + # Optional: Sign APKs (uncomment and configure secrets when ready) + # - name: Sign APKs # uses: r0adkll/sign-android-release@v1 # with: # releaseDirectory: app/build/outputs/apk/release @@ -69,10 +72,10 @@ jobs: # keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} # keyPassword: ${{ secrets.KEY_PASSWORD }} - - name: Upload APK as artifact + - name: Upload APKs as artifacts uses: actions/upload-artifact@v4 with: - name: bitchat-android-apk-${{ github.ref_name }} + name: bitchat-android-release-${{ github.ref_name }} path: app/build/outputs/apk/release/*.apk retention-days: 30 if-no-files-found: error @@ -82,25 +85,23 @@ jobs: runs-on: ubuntu-latest steps: - - name: Download APK artifact + - name: Download release artifacts uses: actions/download-artifact@v4 with: - name: bitchat-android-apk-${{ github.ref_name }} + name: bitchat-android-release-${{ github.ref_name }} path: release - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | - release/bitchat-android.apk + release/bitchat-android-arm64.apk + release/bitchat-android-x86_64.apk + release/bitchat-android-universal.apk name: Release ${{ github.ref_name }} body: | - ## bitchat Android Release - - **bitchat-android.apk** (~15MB) - - Secure P2P messaging over Bluetooth mesh and Nostr - - Built-in Tor support (custom Arti build with 16KB page size) - - Compatible with Google Play requirements (Nov 2025+) - - Cross-platform compatible with iOS version + **bitchat-android-arm64.apk** - ARM64 (most phones) + **bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets) + **bitchat-android-universal.apk** - All architectures (fallback) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6605a757..a9511725 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,13 +43,20 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - ndk { - // ARM64-only to minimize APK size (~5.8MB savings) - // Excludes x86_64 as emulator not needed for production builds - abiFilters += listOf("arm64-v8a") - } } } + + // APK splits for GitHub releases - creates arm64, x86_64, and universal APKs + // AAB for Play Store handles architecture distribution automatically + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "x86_64") + isUniversalApk = true // For F-Droid and fallback + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 From 5dc6369b8f63ed60632a4e4e76e97fdaca1b295f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:30:28 +0700 Subject: [PATCH 24/24] bump vc to 30 (#559) --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9511725..a789136e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 29 + versionCode = 30 versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"