Merge branch 'main' into wifi-aware

This commit is contained in:
callebtc
2025-12-13 16:15:38 +07:00
53 changed files with 2750 additions and 956 deletions
+266 -267
View File
@@ -1,271 +1,270 @@
Relay URL,Latitude,Longitude
slick.mjex.me,39.048,-77.4817
relay.guggero.org,47.3769,8.54169
nostr-relay.online,40.7357,-74.1724
relay.bullishbounty.com,40.7357,-74.1724
nostr.kalf.org,52.3676,4.90414
relay.hook.cafe,40.7357,-74.1724
nostrcheck.me,43.6532,-79.3832
relay.vrtmrz.net,40.7357,-74.1724
nostr.notribe.net,40.8302,-74.1299
nos.lol,50.4754,12.3683
relay.notoshi.win,13.4166,101.335
nostrelites.org,41.8781,-87.6298
nostr-02.czas.top,53.471,9.88208
relay.2nix.de,60.1699,24.9384
nostr.faultables.net,43.6532,-79.3832
nostr.noones.com,50.1109,8.68213
relay.nostromo.social,49.4543,11.0746
keys.nostr1.com,40.7057,-74.0136
relay.toastr.net,40.8054,-74.0241
nostr.spicyz.io,40.7357,-74.1724
shu04.shugur.net,25.2604,55.2989
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.freeplace.nl,52.3676,4.90414
relay.wolfcoil.com,35.6092,139.73
relay2.ngengine.org,43.6532,-79.3832
nostr.davidebtc.me,50.1109,8.68213
inbox.azzamo.net,52.2633,21.0283
relay.arx-ccn.com,50.4754,12.3683
nostr2.girino.org,43.6532,-79.3832
nostr.camalolo.com,24.1469,120.684
nostr.mom,50.4754,12.3683
relayone.soundhsa.com,33.1384,-95.6011
relay.zone667.com,60.1699,24.9384
nr.yay.so,46.2126,6.1154
r.lostr.net,52.3676,4.90414
zap.watch,45.5029,-73.5723
relay.evanverma.com,40.8302,-74.1299
nostr.sagaciousd.com,49.2827,-123.121
dev-nostr.bityacht.io,25.0797,121.234
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostr.night7.space,50.4754,12.3683
wot.basspistol.org,49.4521,11.0767
nostr.hekster.org,37.3986,-121.964
relay.lumina.rocks,49.0291,8.35695
offchain.pub,36.1809,-115.241
satsage.xyz,37.3986,-121.964
nostr.openhoofd.nl,51.9229,4.40833
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
yabu.me,35.6092,139.73
relay.magiccity.live,25.8128,-80.2377
wot.soundhsa.com,33.1384,-95.6011
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.upleb.uk,52.2297,21.0122
soloco.nl,40.7357,-74.1724
nostr.plantroon.com,50.1013,8.62643
relay.angor.io,48.1046,11.6002
relay.sigit.io,50.4754,12.3683
khatru.nostrver.se,51.8933,4.42083
nostrelay.circum.space,51.2217,6.77616
nproxy.kristapsk.lv,60.1699,24.9384
nostr.chaima.info,51.223,6.78245
nostr.bilthon.dev,25.8128,-80.2377
nostr.azzamo.net,52.2633,21.0283
relay.fr13nd5.com,52.5233,13.3426
nostr.agentcampfire.com,50.8933,6.05805
relay.uid.ovh,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
wot.sovbit.host,64.1466,-21.9426
nostr-relay.cbrx.io,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
relay.nostr.wirednet.jp,34.706,135.493
wot.nostr.place,30.2672,-97.7431
relay.libernet.app,43.6532,-79.3832
purpura.cloud,43.6532,-79.3832
adre.su,59.9311,30.3609
strfry.felixzieger.de,50.1013,8.62643
nostr-verified.wellorder.net,45.5201,-122.99
relay.islandbitcoin.com,12.8498,77.6545
gnostr.com,42.6978,23.3246
relay.davidebtc.me,50.1109,8.68213
freelay.sovbit.host,64.1476,-21.9392
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
relay.basspistol.org,46.2044,6.14316
relay.ditto.pub,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.stream.labs.h3.se,59.4016,17.9455
a.nos.lol,50.4754,12.3683
relay.nostraddress.com,40.7357,-74.1724
dev-relay.lnfi.network,39.0997,-94.5786
relay.cosmicbolt.net,37.3986,-121.964
relay03.lnfi.network,39.0997,-94.5786
relay04.lnfi.network,39.0997,-94.5786
nostr.snowbla.de,60.1699,24.9384
santo.iguanatech.net,40.8302,-74.1299
nostr.myshosholoza.co.za,52.3676,4.90414
nostr-01.yakihonne.com,1.32123,103.695
relay.usefusion.ai,38.7134,-78.1591
relay.nostr.place,32.7767,-96.797
relay.bitcoinartclock.com,50.4754,12.3683
nostr.coincards.com,53.5501,-113.469
relay.coinos.io,40.7357,-74.1724
relay01.lnfi.network,39.0997,-94.5786
no.str.cr,9.92857,-84.0528
relay.holzeis.me,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
theoutpost.life,64.1476,-21.9392
articles.layer3.news,37.3387,-121.885
relay.primal.net,40.7357,-74.1724
nostr-2.21crypto.ch,47.4988,8.72369
relay.puresignal.news,40.7357,-74.1724
pyramid.fiatjaf.com,51.5072,-0.127586
relay.wavlake.com,41.2619,-95.8608
nostr.0x7e.xyz,47.4988,8.72369
relay.21e6.cz,50.1682,14.0546
nostr-relay.zimage.com,34.282,-118.439
relay.fundstr.me,42.3601,-71.0589
temp.iris.to,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.agorist.space,52.3734,4.89406
relay.mwaters.net,50.9871,2.12554
wot.utxo.one,40.7128,-74.006
relay.utxo.farm,35.6916,139.768
relayrs.notoshi.win,43.6532,-79.3832
r.bitcoinhold.net,43.6532,-79.3832
nostr.huszonegy.world,47.4979,19.0402
relay.nosto.re,51.8933,4.42083
relay.0xchat.com,1.35208,103.82
relay.nostrhub.tech,49.0291,8.35696
purplerelay.com,50.1109,8.68213
nostr.namek.link,40.7357,-74.1724
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.psfoundation.info,39.0438,-77.4874
wot.dergigi.com,64.1476,-21.9392
nostr.carroarmato0.be,50.9928,3.26317
nostr.21crypto.ch,47.4988,8.72369
relay.nostr.net,50.4754,12.3683
cyberspace.nostr1.com,40.7128,-74.006
srtrelay.c-stellar.net,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
wot.sebastix.social,51.8933,4.42083
nostr.satstralia.com,64.1476,-21.9392
relay.illuminodes.com,47.6061,-122.333
relay.letsfo.com,51.098,17.0321
relay-dev.satlantis.io,40.8302,-74.1299
mhp258zrpiiwn.clorecloud.net,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
nostr.simplex.icu,50.8198,-1.08798
wot.nostr.party,36.1627,-86.7816
shu02.shugur.net,21.4902,39.2246
nostr.casa21.space,40.7357,-74.1724
schnorr.me,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
nostr.lostr.space,43.6532,-79.3832
relay.barine.co,40.7357,-74.1724
nostr-relay.xbytez.io,50.6924,3.20113
relay.javi.space,43.4633,11.8796
nostr.smartflowsocial.com,40.7357,-74.1724
bcast.seutoba.com.br,40.7357,-74.1724
nostr.oxtr.dev,50.4754,12.3683
nostr.n7ekb.net,47.4941,-122.294
fanfares.nostr1.com,40.7057,-74.0136
bcast.girino.org,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
shu05.shugur.net,48.8566,2.35222
nostr-03.dorafactory.org,1.35208,103.82
relay.artx.market,43.652,-79.3633
relay.chorus.community,50.1109,8.68213
nostream.breadslice.com,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.olas.app,50.4754,12.3683
strfry.elswa-dev.online,48.8566,2.35222
nostr-relay.amethyst.name,39.0438,-77.4874
relay.nostr.vet,52.6467,4.7395
relay02.lnfi.network,39.0997,-94.5786
nos.xmark.cc,50.6924,3.20113
nostr-01.uid.ovh,40.7357,-74.1724
nostr.liberty.fans,36.9104,-89.5875
nostr.4rs.nl,49.0291,8.35696
wheat.happytavern.co,40.7357,-74.1724
fenrir-s.notoshi.win,40.7357,-74.1724
relay.btcforplebs.com,43.6532,-79.3832
nostr.now,36.55,139.733
orangepiller.org,60.1699,24.9384
relay.satlantis.io,32.8769,-80.0114
relay.cypherflow.ai,48.8566,2.35222
relay.orangepill.ovh,49.1689,-0.358841
wot.brightbolt.net,47.6735,-116.781
wot.tealeaf.dev,33.7488,-84.3877
relay.wellorder.net,45.5201,-122.99
ynostr.yael.at,60.1699,24.9384
ribo.af.nostria.app,-26.2041,28.0473
nostr.tadryanom.me,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
nostr.data.haus,50.4754,12.3683
relay.varke.eu,52.6921,6.19372
relay.artiostr.ch,40.7357,-74.1724
relay.moinsen.com,50.4754,12.3683
nostr.makibisskey.work,40.7357,-74.1724
relay.snort.social,40.7357,-74.1724
premium.primal.net,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.lifpay.me,1.35208,103.82
strfry.openhoofd.nl,51.9229,4.40833
nostrcheck.tnsor.network,43.6532,-79.3832
noxir.kpherox.dev,34.8587,135.509
relay5.bitransfer.org,43.6532,-79.3832
ribo.us.nostria.app,41.5868,-93.625
nostr-pub.wellorder.net,45.5201,-122.99
relay.mostro.network,40.8302,-74.1299
wot.nostr.net,40.7357,-74.1724
relay.nostriot.com,41.5695,-83.9786
nostr.mikoshi.de,50.1109,8.68213
relay.mattybs.lol,40.7357,-74.1724
relay.nostr.band,60.1699,24.9384
strfry.shock.network,41.8959,-88.2169
x.kojira.io,43.6532,-79.3832
relay.etch.social,41.2619,-95.8608
relay.lightning.pub,41.8959,-88.2169
relay.damus.io,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.nostrcheck.me,43.6532,-79.3832
relay.credenso.cafe,43.3601,-80.3127
shu01.shugur.net,21.4902,39.2246
relay.ru.ac.th,13.7584,100.622
relay.goodmorningbitcoin.com,40.7357,-74.1724
relay.bitcoindistrict.org,43.6532,-79.3832
black.nostrcity.club,41.8781,-87.6298
nostr.jerrynya.fun,31.2304,121.474
orangesync.tech,50.1109,8.68213
nostr.blankfors.se,60.1699,24.9384
relay.chatbett.de,28.0199,-82.5248
relay.siamdev.cc,13.8434,100.363
relay.seq1.net,40.7128,-74.006
dizzyspells.nostr1.com,40.7057,-74.0136
strfry.bonsai.com,37.8715,-122.273
nostr.stakey.net,52.3676,4.90414
ithurtswhenip.ee,51.223,6.78245
relay.mccormick.cx,52.3563,4.95714
alien.macneilmediagroup.com,43.6532,-79.3832
relay-rpi.edufeed.org,49.4543,11.0746
relay.aloftus.io,34.0881,-118.379
nostr.girino.org,40.7357,-74.1724
nostr.einundzwanzig.space,50.1109,8.68213
nostr.rikmeijer.nl,50.4754,12.3683
relay2.angor.io,48.1046,11.6002
nostr.zenon.network,43.5009,-70.4428
relay.internationalright-wing.org,-22.5022,-48.7114
relayb.uid.ovh,40.7357,-74.1724
prl.plus,38.9072,-77.0369
relay.ngengine.org,40.7357,-74.1724
nostr.tac.lol,47.4748,-122.273
nostr.88mph.life,43.6532,-79.3832
relay.13room.space,40.7357,-74.1724
relay.hasenpfeffr.com,39.0438,-77.4874
nostr.zoracle.org,45.6018,-121.185
nostr.luisschwab.net,40.7357,-74.1724
relay.electriclifestyle.com,26.2897,-80.1293
vitor.nostr1.com,40.7128,-74.006
relay.origin.land,35.6673,139.751
srtrelay.c-stellar.net,43.6532,-79.3832
alienos.libretechsystems.xyz,55.4724,9.87335
relay.fountain.fm,39.0997,-94.5786
wot.dtonon.com,43.6532,-79.3832
nostr.hifish.org,47.4043,8.57398
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-relay.amethyst.name,39.0438,-77.4874
1 Relay URL Latitude Longitude
slick.mjex.me 39.048 -77.4817
relay.guggero.org 47.3769 8.54169
nostr-relay.online 40.7357 -74.1724
relay.bullishbounty.com 40.7357 -74.1724
nostr.kalf.org 52.3676 4.90414
relay.hook.cafe 40.7357 -74.1724
nostrcheck.me 43.6532 -79.3832
relay.vrtmrz.net 40.7357 -74.1724
nostr.notribe.net 40.8302 -74.1299
nos.lol 50.4754 12.3683
relay.notoshi.win 13.4166 101.335
nostrelites.org 41.8781 -87.6298
nostr-02.czas.top 53.471 9.88208
relay.2nix.de 60.1699 24.9384
nostr.faultables.net 43.6532 -79.3832
nostr.noones.com 50.1109 8.68213
relay.nostromo.social 49.4543 11.0746
keys.nostr1.com 40.7057 -74.0136
relay.toastr.net 40.8054 -74.0241
nostr.spicyz.io 40.7357 -74.1724
shu04.shugur.net 25.2604 55.2989
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay.freeplace.nl 52.3676 4.90414
relay.wolfcoil.com 35.6092 139.73
relay2.ngengine.org 43.6532 -79.3832
nostr.davidebtc.me 50.1109 8.68213
inbox.azzamo.net 52.2633 21.0283
relay.arx-ccn.com 50.4754 12.3683
nostr2.girino.org 43.6532 -79.3832
nostr.camalolo.com 24.1469 120.684
nostr.mom 50.4754 12.3683
relayone.soundhsa.com 33.1384 -95.6011
relay.zone667.com 60.1699 24.9384
nr.yay.so 46.2126 6.1154
r.lostr.net 52.3676 4.90414
zap.watch 45.5029 -73.5723
relay.evanverma.com 40.8302 -74.1299
nostr.sagaciousd.com 49.2827 -123.121
dev-nostr.bityacht.io 25.0797 121.234
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.mehdibekhtaoui.com 49.4939 -1.54813
nostr.night7.space 50.4754 12.3683
wot.basspistol.org 49.4521 11.0767
nostr.hekster.org 37.3986 -121.964
relay.lumina.rocks 49.0291 8.35695
offchain.pub 36.1809 -115.241
satsage.xyz 37.3986 -121.964
nostr.openhoofd.nl 51.9229 4.40833
nostrelay.memory-art.xyz 43.6532 -79.3832
nostr-02.yakihonne.com 1.32123 103.695
yabu.me 35.6092 139.73
relay.magiccity.live 25.8128 -80.2377
wot.soundhsa.com 33.1384 -95.6011
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.upleb.uk 52.2297 21.0122
soloco.nl 40.7357 -74.1724
nostr.plantroon.com 50.1013 8.62643
relay.angor.io 48.1046 11.6002
relay.sigit.io 50.4754 12.3683
khatru.nostrver.se 51.8933 4.42083
nostrelay.circum.space 51.2217 6.77616
nproxy.kristapsk.lv 60.1699 24.9384
nostr.chaima.info 51.223 6.78245
nostr.bilthon.dev 25.8128 -80.2377
nostr.azzamo.net 52.2633 21.0283
relay.fr13nd5.com 52.5233 13.3426
nostr.agentcampfire.com 50.8933 6.05805
relay.uid.ovh 43.6532 -79.3832
nostr-rs-relay-ishosta.phamthanh.me 40.7357 -74.1724
wot.sovbit.host 64.1466 -21.9426
nostr-relay.cbrx.io 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
relay.nostr.wirednet.jp 34.706 135.493
wot.nostr.place 30.2672 -97.7431
relay.libernet.app 43.6532 -79.3832
purpura.cloud 43.6532 -79.3832
adre.su 59.9311 30.3609
strfry.felixzieger.de 50.1013 8.62643
nostr-verified.wellorder.net 45.5201 -122.99
relay.islandbitcoin.com 12.8498 77.6545
gnostr.com 42.6978 23.3246
relay.davidebtc.me 50.1109 8.68213
freelay.sovbit.host 64.1476 -21.9392
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.red5d.dev 43.6532 -79.3832
relay.nostriches.club 43.6532 -79.3832
relay.basspistol.org 46.2044 6.14316
relay.ditto.pub 43.6532 -79.3832
nostr.rtvslawenia.com 49.4543 11.0746
relay.stream.labs.h3.se 59.4016 17.9455
a.nos.lol 50.4754 12.3683
relay.nostraddress.com 40.7357 -74.1724
dev-relay.lnfi.network 39.0997 -94.5786
relay.cosmicbolt.net 37.3986 -121.964
relay03.lnfi.network 39.0997 -94.5786
relay04.lnfi.network 39.0997 -94.5786
nostr.snowbla.de 60.1699 24.9384
santo.iguanatech.net 40.8302 -74.1299
nostr.myshosholoza.co.za 52.3676 4.90414
nostr-01.yakihonne.com 1.32123 103.695
relay.usefusion.ai 38.7134 -78.1591
relay.nostr.place 32.7767 -96.797
relay.bitcoinartclock.com 50.4754 12.3683
nostr.coincards.com 53.5501 -113.469
relay.coinos.io 40.7357 -74.1724
relay01.lnfi.network 39.0997 -94.5786
no.str.cr 9.92857 -84.0528
relay.holzeis.me 43.6532 -79.3832
relay.agora.social 50.7383 15.0648
theoutpost.life 64.1476 -21.9392
articles.layer3.news 37.3387 -121.885
relay.primal.net 40.7357 -74.1724
nostr-2.21crypto.ch 47.4988 8.72369
relay.puresignal.news 40.7357 -74.1724
pyramid.fiatjaf.com 51.5072 -0.127586
relay.wavlake.com 41.2619 -95.8608
nostr.0x7e.xyz 47.4988 8.72369
relay.21e6.cz 50.1682 14.0546
nostr-relay.zimage.com 34.282 -118.439
relay.fundstr.me 42.3601 -71.0589
temp.iris.to 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
relay.agorist.space 52.3734 4.89406
relay.mwaters.net 50.9871 2.12554
wot.utxo.one 40.7128 -74.006
relay.utxo.farm 35.6916 139.768
relayrs.notoshi.win 43.6532 -79.3832
r.bitcoinhold.net 43.6532 -79.3832
nostr.huszonegy.world 47.4979 19.0402
relay.nosto.re 51.8933 4.42083
relay.0xchat.com 1.35208 103.82
relay.nostrhub.tech 49.0291 8.35696
purplerelay.com 50.1109 8.68213
nostr.namek.link 40.7357 -74.1724
nostr.thebiglake.org 32.71 -96.6745
nostr-relay.psfoundation.info 39.0438 -77.4874
wot.dergigi.com 64.1476 -21.9392
nostr.carroarmato0.be 50.9928 3.26317
nostr.21crypto.ch 47.4988 8.72369
relay.nostr.net 50.4754 12.3683
cyberspace.nostr1.com 40.7128 -74.006
srtrelay.c-stellar.net 43.6532 -79.3832
relay.nostrdice.com -33.8688 151.209
wot.sebastix.social 51.8933 4.42083
nostr.satstralia.com 64.1476 -21.9392
relay.illuminodes.com 47.6061 -122.333
relay.letsfo.com 51.098 17.0321
relay-dev.satlantis.io 40.8302 -74.1299
mhp258zrpiiwn.clorecloud.net 43.6532 -79.3832
relay.nostrhub.fr 48.1046 11.6002
nostr.simplex.icu 50.8198 -1.08798
wot.nostr.party 36.1627 -86.7816
shu02.shugur.net 21.4902 39.2246
nostr.casa21.space 40.7357 -74.1724
schnorr.me 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
nostr.lostr.space 43.6532 -79.3832
relay.barine.co 40.7357 -74.1724
nostr-relay.xbytez.io 50.6924 3.20113
relay.javi.space 43.4633 11.8796
nostr.smartflowsocial.com 40.7357 -74.1724
bcast.seutoba.com.br 40.7357 -74.1724
nostr.oxtr.dev 50.4754 12.3683
nostr.n7ekb.net 47.4941 -122.294
fanfares.nostr1.com 40.7057 -74.0136
bcast.girino.org 43.6532 -79.3832
nostr.vulpem.com 49.4543 11.0746
shu05.shugur.net 48.8566 2.35222
nostr-03.dorafactory.org 1.35208 103.82
relay.artx.market 43.652 -79.3633
relay.chorus.community 50.1109 8.68213
nostream.breadslice.com 43.6532 -79.3832
nostr-relay.nextblockvending.com 47.2343 -119.853
relay.olas.app 50.4754 12.3683
strfry.elswa-dev.online 48.8566 2.35222
nostr-relay.amethyst.name 39.0438 -77.4874
relay.nostr.vet 52.6467 4.7395
2 relay02.lnfi.network 39.0997 -94.5786
3 nos.xmark.cc srtrelay.c-stellar.net 50.6924 43.6532 3.20113 -79.3832
nostr-01.uid.ovh 40.7357 -74.1724
nostr.liberty.fans 36.9104 -89.5875
nostr.4rs.nl 49.0291 8.35696
wheat.happytavern.co 40.7357 -74.1724
fenrir-s.notoshi.win 40.7357 -74.1724
relay.btcforplebs.com 43.6532 -79.3832
nostr.now 36.55 139.733
orangepiller.org 60.1699 24.9384
relay.satlantis.io 32.8769 -80.0114
relay.cypherflow.ai 48.8566 2.35222
relay.orangepill.ovh 49.1689 -0.358841
wot.brightbolt.net 47.6735 -116.781
wot.tealeaf.dev 33.7488 -84.3877
relay.wellorder.net 45.5201 -122.99
ynostr.yael.at 60.1699 24.9384
ribo.af.nostria.app -26.2041 28.0473
nostr.tadryanom.me 43.6532 -79.3832
relay.jeffg.fyi 43.6532 -79.3832
relay.bitcoinveneto.org 64.1466 -21.9426
nostr.data.haus 50.4754 12.3683
relay.varke.eu 52.6921 6.19372
relay.artiostr.ch 40.7357 -74.1724
relay.moinsen.com 50.4754 12.3683
nostr.makibisskey.work 40.7357 -74.1724
relay.snort.social 40.7357 -74.1724
premium.primal.net 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.lifpay.me 1.35208 103.82
strfry.openhoofd.nl 51.9229 4.40833
nostrcheck.tnsor.network 43.6532 -79.3832
noxir.kpherox.dev 34.8587 135.509
relay5.bitransfer.org 43.6532 -79.3832
ribo.us.nostria.app 41.5868 -93.625
nostr-pub.wellorder.net 45.5201 -122.99
relay.mostro.network 40.8302 -74.1299
wot.nostr.net 40.7357 -74.1724
relay.nostriot.com 41.5695 -83.9786
nostr.mikoshi.de 50.1109 8.68213
relay.mattybs.lol 40.7357 -74.1724
relay.nostr.band 60.1699 24.9384
strfry.shock.network 41.8959 -88.2169
x.kojira.io 43.6532 -79.3832
relay.etch.social 41.2619 -95.8608
relay.lightning.pub 41.8959 -88.2169
relay.damus.io 43.6532 -79.3832
relay.degmods.com 50.4754 12.3683
relay.nostrcheck.me 43.6532 -79.3832
relay.credenso.cafe 43.3601 -80.3127
shu01.shugur.net 21.4902 39.2246
relay.ru.ac.th 13.7584 100.622
relay.goodmorningbitcoin.com 40.7357 -74.1724
relay.bitcoindistrict.org 43.6532 -79.3832
black.nostrcity.club 41.8781 -87.6298
nostr.jerrynya.fun 31.2304 121.474
orangesync.tech 50.1109 8.68213
nostr.blankfors.se 60.1699 24.9384
relay.chatbett.de 28.0199 -82.5248
relay.siamdev.cc 13.8434 100.363
relay.seq1.net 40.7128 -74.006
dizzyspells.nostr1.com 40.7057 -74.0136
strfry.bonsai.com 37.8715 -122.273
nostr.stakey.net 52.3676 4.90414
ithurtswhenip.ee 51.223 6.78245
relay.mccormick.cx 52.3563 4.95714
alien.macneilmediagroup.com 43.6532 -79.3832
relay-rpi.edufeed.org 49.4543 11.0746
relay.aloftus.io 34.0881 -118.379
nostr.girino.org 40.7357 -74.1724
nostr.einundzwanzig.space 50.1109 8.68213
nostr.rikmeijer.nl 50.4754 12.3683
relay2.angor.io 48.1046 11.6002
nostr.zenon.network 43.5009 -70.4428
relay.internationalright-wing.org -22.5022 -48.7114
relayb.uid.ovh 40.7357 -74.1724
prl.plus 38.9072 -77.0369
relay.ngengine.org 40.7357 -74.1724
nostr.tac.lol 47.4748 -122.273
nostr.88mph.life 43.6532 -79.3832
relay.13room.space 40.7357 -74.1724
relay.hasenpfeffr.com 39.0438 -77.4874
nostr.zoracle.org 45.6018 -121.185
nostr.luisschwab.net 40.7357 -74.1724
relay.electriclifestyle.com 26.2897 -80.1293
vitor.nostr1.com 40.7128 -74.006
relay.origin.land 35.6673 139.751
4 alienos.libretechsystems.xyz 55.4724 9.87335
5 relay.fountain.fm relay.uid.ovh 39.0997 43.6532 -94.5786 -79.3832
6 wot.dtonon.com relay.cypherflow.ai 43.6532 48.8566 -79.3832 2.35222
7 nostr.hifish.org wot.tealeaf.dev 47.4043 33.7488 8.57398 -84.3877
8 relay.divine.video 43.6532 -79.3832
9 relay.tagayasu.xyz 43.6715 -79.38
10 nostr.camalolo.com 24.1469 120.684
11 wot.sudocarlos.com 51.5072 -0.127586
12 nostr.vulpem.com 49.4543 11.0746
13 relay2.angor.io 48.1046 11.6002
14 relay.nostr.nisshiee.org 37.3387 -121.885
15 wot.nostr.party 36.1627 -86.7816
16 nostr.luisschwab.net 43.6532 -79.3832
17 cyberspace.nostr1.com 40.7128 -74.006
18 strfry.felixzieger.de 50.1013 8.62643
19 relay.agora.social 50.7383 15.0648
20 nostr.n7ekb.net 47.4941 -122.294
21 relay.bitcoinveneto.org 64.1466 -21.9426
22 relay.stream.labs.h3.se 59.4016 17.9455
23 nostr.azzamo.net 52.2633 21.0283
24 relay.nostrhub.tech 49.0291 8.35696
25 relay.21e6.cz 50.7383 15.0648
26 nostr-02.czas.top 51.2277 6.77346
27 nostr.czas.top 50.1109 8.68213
28 wot.nostr.place 32.7767 -96.797
29 strfry.shock.network 39.0438 -77.4874
30 relay.orangepill.ovh 49.1689 -0.358841
31 relay.illuminodes.com 47.6061 -122.333
32 bcast.girino.org 43.6532 -79.3832
33 nostr.stakey.net 52.3676 4.90414
34 nostr.lkjsxc.com 43.6532 -79.3832
35 nostr.coincrowd.fund 39.0438 -77.4874
36 relay.damus.io 43.6532 -79.3832
37 nostr.myshosholoza.co.za 52.3676 4.90414
38 nostr.88mph.life 60.1699 24.9384
39 relay.sharegap.net 43.6532 -79.3832
40 purplerelay.com 50.1109 8.68213
41 ribo.us.nostria.app 41.5868 -93.625
42 nostr.snowbla.de 60.1699 24.9384
43 relay.internationalright-wing.org -22.5986 -48.8003
44 relay.btcforplebs.com 43.6532 -79.3832
45 nostr.red5d.dev 43.6532 -79.3832
46 orangepiller.org 60.1699 24.9384
47 relay.nosto.re 51.1792 5.89444
48 relay04.lnfi.network 39.0997 -94.5786
49 nostr.now 36.55 139.733
50 relay.binaryrobot.com 43.6532 -79.3832
51 premium.primal.net 43.6532 -79.3832
52 relay.davidebtc.me 51.5072 -0.127586
53 purpura.cloud 43.6532 -79.3832
54 nostr.mikoshi.de 50.1109 8.68213
55 nostr.simplex.icu 51.5121 -0.0005238
56 articles.layer3.news 37.3387 -121.885
57 nostr.spicyz.io 43.6532 -79.3832
58 nostrcheck.me 43.6532 -79.3832
59 relay.holzeis.me 43.6532 -79.3832
60 nostr-01.uid.ovh 43.6532 -79.3832
61 relay.nostar.org 43.6532 -79.3832
62 soloco.nl 43.6532 -79.3832
63 nostr.thebiglake.org 32.71 -96.6745
64 relay.nostr-check.me 43.6532 -79.3832
65 nproxy.kristapsk.lv 60.1699 24.9384
66 relay-freeharmonypeople.space 38.7223 -9.13934
67 nostrelay.memory-art.xyz 43.6532 -79.3832
68 orangesync.tech 50.1109 8.68213
69 nostr.data.haus 50.4754 12.3683
70 vitor.nostr1.com 40.7057 -74.0136
71 nostr.overmind.lol 43.6532 -79.3832
72 nostr-relay.online 43.6532 -79.3832
73 relay.bitcoindistrict.org 43.6532 -79.3832
74 relay.javi.space 43.4633 11.8796
75 fanfares.nostr1.com 40.7057 -74.0136
76 temp.iris.to 43.6532 -79.3832
77 relay.guggero.org 47.3769 8.54169
78 relay.degmods.com 50.4754 12.3683
79 relay.nostr.net 43.6532 -79.3832
80 relay.wolfcoil.com 35.6092 139.73
81 relay.sigit.io 50.4754 12.3683
82 shu04.shugur.net 25.2604 55.2989
83 nostr.rtvslawenia.com 49.4543 11.0746
84 nostr.kalf.org 52.3676 4.90414
85 relay-testnet.k8s.layer3.news 37.3387 -121.885
86 nostr.rblb.it 43.7094 10.6582
87 nostr.ovia.to 43.6532 -79.3832
88 nostr-relay.nextblockvending.com 47.2343 -119.853
89 relay.agorist.space 52.3734 4.89406
90 relay.jeffg.fyi 43.6532 -79.3832
91 relay.ditto.pub 43.6532 -79.3832
92 nostr.huszonegy.world 47.4979 19.0402
93 nostr.coincards.com 53.5501 -113.469
94 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
95 nostream.breadslice.com 1.35208 103.82
96 ribo.eu.nostria.app 52.3676 4.90414
97 nostr.oxtr.dev 50.4754 12.3683
98 nostr.jerrynya.fun 31.2304 121.474
99 black.nostrcity.club 48.8575 2.35138
100 wheat.happytavern.co 43.6532 -79.3832
101 nostr-relay.psfoundation.info 39.0438 -77.4874
102 relay.zone667.com 60.1699 24.9384
103 theoutpost.life 64.1476 -21.9392
104 nostrelay.circum.space 52.3676 4.90414
105 relay.origin.land 35.6673 139.751
106 relay.lumina.rocks 49.0291 8.35695
107 relay.chorus.community 50.1109 8.68213
108 kotukonostr.onrender.com 37.7775 -122.397
109 nostrcheck.tnsor.network 43.6532 -79.3832
110 relay01.lnfi.network 39.0997 -94.5786
111 bitsat.molonlabe.holdings 51.4012 -1.3147
112 relay.nsnip.io 60.1699 24.9384
113 nostr.mehdibekhtaoui.com 49.4939 -1.54813
114 relay.ru.ac.th 13.7607 100.627
115 relay.thibautduchene.fr 43.6532 -79.3832
116 nos.xmark.cc 50.6924 3.20113
117 wot.dtonon.com 43.6532 -79.3832
118 relay.satmaxt.xyz 43.6532 -79.3832
119 wot.brightbolt.net 47.6735 -116.781
120 nostr.bond 50.1109 8.68213
121 relay.vrtmrz.net 43.6532 -79.3832
122 dev-relay.lnfi.network 39.0997 -94.5786
123 wot.sebastix.social 51.1792 5.89444
124 nostr.hifish.org 47.4043 8.57398
125 divine.diy 43.6532 -79.3832
126 bitcoiner.social 39.1585 -94.5728
127 relay.islandbitcoin.com 12.8498 77.6545
128 nostr-2.21crypto.ch 47.5356 8.73209
129 relay.snort.social 53.3498 -6.26031
130 shu05.shugur.net 48.8566 2.35222
131 relay.nostrcheck.me 43.6532 -79.3832
132 relay.0xchat.com 1.35208 103.82
133 relay.dwadziesciajeden.pl 52.2297 21.0122
134 relay.getsafebox.app 43.6532 -79.3832
135 relay.moinsen.com 50.4754 12.3683
136 relay.primal.net 43.6532 -79.3832
137 relay.npubhaus.com 43.6532 -79.3832
138 nostr.tuckerbradford.com 43.663 -70.2569
139 nostr.rikmeijer.nl 50.4754 12.3683
140 nostr.agentcampfire.com 52.3676 4.90414
141 shu02.shugur.net 21.4902 39.2246
142 librerelay.aaroniumii.com 43.6532 -79.3832
143 fenrir-s.notoshi.win 43.6532 -79.3832
144 relayrs.notoshi.win 43.6532 -79.3832
145 nostr.girino.org 43.6532 -79.3832
146 ribo.af.nostria.app -26.2056 28.0337
147 relay.bitcoinartclock.com 50.4754 12.3683
148 relay.nostrdice.com -33.8688 151.209
149 strfry.elswa-dev.online 50.1109 8.68213
150 nostr.21crypto.ch 47.5356 8.73209
151 wot.shaving.kiwi 43.6532 -79.3832
152 nostr.mom 50.4754 12.3683
153 relay.lightning.pub 39.0438 -77.4874
154 nostr-02.yakihonne.com 1.32123 103.695
155 relay.mccormick.cx 52.3563 4.95714
156 ynostr.yael.at 60.1699 24.9384
157 nostr.faultables.net 43.6532 -79.3832
158 prl.plus 56.9677 24.1056
159 santo.iguanatech.net 40.8302 -74.1299
160 nostr.bitcoiner.social 39.1585 -94.5728
161 nostr-relay.corb.net 38.8353 -104.822
162 relay.letsfo.com 51.098 17.0321
163 nostr.calitabby.net 39.9268 -75.0246
164 strfry.bonsai.com 37.8715 -122.273
165 wot.nostr.net 43.6532 -79.3832
166 bcast.seutoba.com.br 43.6532 -79.3832
167 wot.sovbit.host 64.1466 -21.9426
168 relay2.ngengine.org 43.6532 -79.3832
169 r.bitcoinhold.net 43.6532 -79.3832
170 ithurtswhenip.ee 51.223 6.78245
171 freeben666.fr 43.7221 7.15296
172 relay.notoshi.win 13.311 101.112
173 yabu.me 35.6092 139.73
174 dev-nostr.bityacht.io 25.0797 121.234
175 nostr.commonshub.brussels 49.4543 11.0746
176 relay.nostx.io 43.6532 -79.3832
177 relay.nostr.band 60.1699 24.9384
178 relay.barine.co 43.6532 -79.3832
179 relay.arx-ccn.com 50.4754 12.3683
180 satsage.xyz 37.3986 -121.964
181 relay.nostr.wirednet.jp 34.706 135.493
182 relay.artx.market 43.652 -79.3633
183 nostr2.girino.org 43.6532 -79.3832
184 nostr.tadryanom.me 43.6532 -79.3832
185 relay.satlantis.io 32.8769 -80.0114
186 nostr.zoracle.org 45.6018 -121.185
187 nostr.bilthon.dev 25.8128 -80.2377
188 relay.minibolt.info 43.6532 -79.3832
189 relay.chakany.systems 43.6532 -79.3832
190 nostr.spaceshell.xyz 43.6532 -79.3832
191 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
192 offchain.pub 47.6743 -117.112
193 shu01.shugur.net 21.4902 39.2246
194 nostr-relay.xbytez.io 50.6924 3.20113
195 nostr.noones.com 50.1109 8.68213
196 relay.nostriot.com 41.5695 -83.9786
197 nostr-relay.zimage.com 34.0549 -118.243
198 relay.siamdev.cc 13.9178 100.424
199 nostr.4rs.nl 49.0291 8.35696
200 relay.coinos.io 43.6532 -79.3832
201 khatru.nostrver.se 51.1792 5.89444
202 x.kojira.io 43.6532 -79.3832
203 wot.yesnostr.net 50.9871 2.12554
204 nostr.casa21.space 43.6532 -79.3832
205 nos.lol 50.4754 12.3683
206 relay.mattybs.lol 43.6532 -79.3832
207 nostr-01.yakihonne.com 1.32123 103.695
208 nostr.tac.lol 47.4748 -122.273
209 relay.nuts.cash 34.0362 -118.443
210 relay.cosmicbolt.net 37.3986 -121.964
211 nostr.diakod.com 43.6532 -79.3832
212 relay.mostro.network 40.8302 -74.1299
213 relay.fr13nd5.com 52.5233 13.3426
214 relay.trustroots.org 43.6532 -79.3832
215 relay.thebluepulse.com 49.4521 11.0767
216 relay.wavefunc.live 34.0362 -118.443
217 relay.wavlake.com 41.2619 -95.8608
218 relay.libernet.app 43.6532 -79.3832
219 nostr-relay.cbrx.io 43.6532 -79.3832
220 relay.fountain.fm 39.0997 -94.5786
221 relay.hook.cafe 43.6532 -79.3832
222 notemine.io 52.2026 20.9397
223 relay.electriclifestyle.com 26.2897 -80.1293
224 inbox.azzamo.net 52.2633 21.0283
225 nostr.hekster.org 37.3986 -121.964
226 relay.routstr.com 43.6532 -79.3832
227 relay.puresignal.news 43.6532 -79.3832
228 wot.dergigi.com 64.1476 -21.9392
229 nostr-03.dorafactory.org 1.35208 103.82
230 relay.fundstr.me 42.3601 -71.0589
231 relayone.soundhsa.com 33.1384 -95.6011
232 nostr.plantroon.com 50.1013 8.62643
233 relay.ngengine.org 43.6532 -79.3832
234 relay-dev.satlantis.io 40.8302 -74.1299
235 relay.angor.io 48.1046 11.6002
236 relay.toastr.net 40.8054 -74.0241
237 relay.nostrhub.fr 48.1045 11.6004
238 relay-rpi.edufeed.org 49.4521 11.0767
239 relay.bitesize-media.com 49.4543 11.0746
240 relay.usefusion.ai 38.7134 -78.1591
241 chat-relay.zap-work.com 43.6532 -79.3832
242 nostr.0x7e.xyz 47.4988 8.72369
243 nostr.ps1829.com 33.8851 130.883
244 relay.bullishbounty.com 43.6532 -79.3832
245 nostr.openhoofd.nl 51.9229 4.40833
246 nostr.blankfors.se 60.1699 24.9384
247 strfry.openhoofd.nl 51.9229 4.40833
248 relay.magiccity.live 25.8128 -80.2377
249 relay.goodmorningbitcoin.com 43.6532 -79.3832
250 nostr.notribe.net 40.8302 -74.1299
251 slick.mjex.me 39.048 -77.4817
252 nostr.davidebtc.me 51.5072 -0.127586
253 alien.macneilmediagroup.com 43.6532 -79.3832
254 pyramid.treegaze.com 43.6532 -79.3832
255 relay03.lnfi.network 39.0997 -94.5786
256 relay.evanverma.com 40.8302 -74.1299
257 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
258 nostr.chaima.info 51.223 6.78245
259 no.str.cr 9.92857 -84.0528
260 relay.jabato.space 52.52 13.405
261 adre.su 59.9311 30.3609
262 relay.hasenpfeffr.com 39.0438 -77.4874
263 relay.etch.social 41.2619 -95.8608
264 schnorr.me 43.6532 -79.3832
265 relay.endfiat.money 43.6532 -79.3832
266 relay.nostr.place 32.7767 -96.797
267 nostr.sathoarder.com 48.5734 7.75211
268 orly.ft.hn 50.4754 12.3683
269 relay.samt.st 40.8302 -74.1299
270 nostr-relay.amethyst.name 39.0438 -77.4874
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager
import com.bitchat.android.net.ArtiTorManager
/**
* Main application class for bitchat Android
*/
class BitchatApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { }
try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
@@ -0,0 +1,34 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier.Companion
) {
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.Companion.size(18.dp)
)
}
}
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>()
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
_bookmarks.postValue(updated)
val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.value = updated
persist(updated)
// Resolve friendly name asynchronously
resolveNameIfNeeded(gh)
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (!membership.contains(gh)) return
membership.remove(gh)
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
_bookmarks.postValue(updated)
val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.value = updated
// Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) {
_bookmarkNames.postValue(names)
_bookmarkNames.value = names
persistNames(names)
}
persist(updated)
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
}
membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered)
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict)
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() {
try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames() {
try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
fun clearAll() {
try {
membership.clear()
_bookmarks.postValue(emptyList())
_bookmarkNames.postValue(emptyMap())
_bookmarks.value = emptyList()
_bookmarkNames.value = emptyMap()
prefs.edit()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val current = _bookmarkNames.value.toMutableMap()
current[gh] = name
_bookmarkNames.postValue(current)
_bookmarkNames.value = current
persistNames(current)
}
} catch (e: Exception) {
@@ -10,12 +10,12 @@ import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
private var dataManager: com.bitchat.android.ui.DataManager? = null
// Published state for UI bindings (matching iOS @Published properties)
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED)
val permissionState: LiveData<PermissionState> = _permissionState
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
val selectedChannel: LiveData<ChannelID> = _selectedChannel
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableLiveData(false)
val teleported: LiveData<Boolean> = _teleported
private val _teleported = MutableStateFlow(false)
val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
// Add a new LiveData property to indicate when location is being fetched
private val _isLoadingLocation = MutableLiveData(false)
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
// Add a new LiveData property to track if location services are enabled by user
private val _locationServicesEnabled = MutableLiveData(false)
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
init {
updatePermissionState()
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.postValue(PermissionState.NOT_DETERMINED)
_permissionState.value = PermissionState.NOT_DETERMINED
}
PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted")
_permissionState.postValue(PermissionState.DENIED)
_permissionState.value = PermissionState.DENIED
}
PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.postValue(PermissionState.AUTHORIZED)
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
}
}
@@ -180,7 +178,7 @@ class LocationChannelManager private constructor(private val context: Context) {
lastLocation?.let { location ->
when (channel) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -189,7 +187,7 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = channel.channel.level.precision
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.postValue(isTeleportedNow)
_teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
}
}
@@ -201,7 +199,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported")
_teleported.postValue(teleported)
_teleported.value = teleported
}
/**
@@ -209,7 +207,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun enableLocationServices() {
Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.postValue(true)
_locationServicesEnabled.value = true
saveLocationServicesState(true)
// If we have permission, start location operations
@@ -223,15 +221,15 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun disableLocationServices() {
Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
saveLocationServicesState(false)
// Stop any ongoing location operations
endLiveRefresh()
// Clear available channels when location is disabled
_availableChannels.postValue(emptyList())
_locationNames.postValue(emptyMap())
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) {
@@ -243,7 +241,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if location services are enabled by the user
*/
fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value ?: false
return _locationServicesEnabled.value
}
// MARK: - Location Operations
@@ -280,13 +278,13 @@ class LocationChannelManager private constructor(private val context: Context) {
if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation
_isLoadingLocation.postValue(false) // Make sure loading state is off
_isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation)
} else {
Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...")
@@ -294,7 +292,7 @@ class LocationChannelManager private constructor(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState()
}
}
@@ -308,7 +306,7 @@ class LocationChannelManager private constructor(private val context: Context) {
reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
// Remove this listener after getting the update
try {
@@ -322,13 +320,13 @@ class LocationChannelManager private constructor(private val context: Context) {
// Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() {
if (!hasLocationPermission()) {
_isLoadingLocation.postValue(false) // Turn off loading state if no permission
_isLoadingLocation.value = false // Turn off loading state if no permission
return
}
try {
// Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Try common providers in order of preference
val providers = listOf(
@@ -358,7 +356,7 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.w(TAG, "Received null location from getCurrentLocation")
}
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
)
} else {
@@ -378,14 +376,14 @@ class LocationChannelManager private constructor(private val context: Context) {
// If no provider was available, turn off loading state
if (!providerFound) {
Log.w(TAG, "No location providers available")
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
}
}
@@ -408,7 +406,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun updatePermissionState() {
val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.postValue(newState)
_permissionState.value = newState
}
private fun hasLocationPermission(): Boolean {
@@ -433,13 +431,13 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.v(TAG, "Generated ${level.displayName}: $geohash")
}
_availableChannels.postValue(result)
_availableChannels.value = result
// Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -448,12 +446,9 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = selectedChannelValue.channel.level.precision
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.postValue(isTeleported)
_teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
}
null -> {
_teleported.postValue(false)
}
}
}
@@ -482,7 +477,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.postValue(names)
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
@@ -601,26 +596,26 @@ class LocationChannelManager private constructor(private val context: Context) {
}
if (channel != null) {
_selectedChannel.postValue(channel)
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
}
@@ -629,7 +624,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
Log.d(TAG, "Cleared persisted channel selection")
}
@@ -653,11 +648,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.postValue(enabled)
_locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
}
}
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init {
powerManager.delegate = this
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
}
/**
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
}
// Update connection attempt atomically
val attempts = (currentAttempt?.attempts ?: 0) + 1
// If the previous attempt window expired, reset backoff to 1; otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
}
pendingConnections.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear()
pendingConnections.clear()
scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
}
/**
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -31,13 +32,6 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// RSSI monitoring constants
private const val RSSI_UPDATE_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS // 5 seconds
}
// Core Bluetooth components
@@ -182,10 +176,10 @@ class BluetoothGattClientManager(
Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
}
}
delay(RSSI_UPDATE_INTERVAL)
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
} catch (e: Exception) {
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
delay(RSSI_UPDATE_INTERVAL)
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
}
}
}
@@ -231,12 +225,12 @@ class BluetoothGattClientManager(
}
val scanFilter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID))
.setServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.build()
val scanFilters = listOf(scanFilter)
Log.d(TAG, "Starting BLE scan with target service UUID: $SERVICE_UUID")
Log.d(TAG, "Starting BLE scan with target service UUID: ${AppConstants.Mesh.Gatt.SERVICE_UUID}")
scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -321,7 +315,7 @@ class BluetoothGattClientManager(
val scanRecord = result.scanRecord
// CRITICAL: Only process devices that have our service UUID
val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true
val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == AppConstants.Mesh.Gatt.SERVICE_UUID } == true
if (!hasOurService) {
return
}
@@ -456,9 +450,9 @@ class BluetoothGattClientManager(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(SERVICE_UUID)
val service = gatt.getService(AppConstants.Mesh.Gatt.SERVICE_UUID)
if (service != null) {
val characteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID)
if (characteristic != null) {
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(characteristic = characteristic)
@@ -467,7 +461,7 @@ class BluetoothGattClientManager(
}
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(DESCRIPTOR_UUID)
val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
if (descriptor != null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -28,10 +29,6 @@ class BluetoothGattServerManager(
companion object {
private const val TAG = "BluetoothGattServerManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
// Core Bluetooth components
@@ -223,7 +220,7 @@ class BluetoothGattServerManager(
return
}
if (characteristic.uuid == CHARACTERISTIC_UUID) {
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
@@ -297,7 +294,7 @@ class BluetoothGattServerManager(
// Create characteristic with notification support
characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID,
AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
@@ -307,12 +304,12 @@ class BluetoothGattServerManager(
)
val descriptor = BluetoothGattDescriptor(
DESCRIPTOR_UUID,
AppConstants.Mesh.Gatt.DESCRIPTOR_UUID,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
characteristic?.addDescriptor(descriptor)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic)
gattServer?.addService(service)
@@ -357,7 +354,7 @@ class BluetoothGattServerManager(
val settings = powerManager.getAdvertiseSettings()
val data = AdvertiseData.Builder()
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.addServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.setIncludeTxPowerLevel(false)
.setIncludeDeviceName(false)
.build()
@@ -409,25 +409,17 @@ class BluetoothMeshService(private val context: Context) {
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) {
// First ANNOUNCE over a device connection defines a direct neighbor.
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE")
connectionManager.noteAnnounceReceived(deviceAddress)
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark this peer as directly connected for UI
try {
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Mark as directly connected (upgrades from routed if needed)
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
// Schedule initial sync for this new directly connected peer only
// Initial sync for this newly direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
}
@@ -958,12 +950,11 @@ class BluetoothMeshService(private val context: Context) {
* Send leave announcement
*/
private fun sendLeaveAnnouncement() {
val nickname = delegate?.getNickname() ?: myPeerID
val packet = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = MAX_TTL,
senderID = myPeerID,
payload = nickname.toByteArray()
payload = byteArrayOf()
)
// Sign the packet before broadcasting
@@ -180,8 +180,12 @@ class FragmentManager {
incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString)
Log.d(TAG, "Successfully reassembled and decoded original packet of ${reassembledData.size} bytes")
return originalPacket
// Suppress re-broadcast of the reassembled packet by zeroing TTL.
// We already relayed the incoming fragments; setting TTL=0 ensures
// PacketRelayManager will skip relaying this reconstructed packet.
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
return suppressedTtlPacket
} else {
val metadata = fragmentMetadata[fragmentIDString]
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
@@ -50,33 +50,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false
}
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong()
val timeDiff = kotlin.math.abs(currentTime - packetTime)
// if (timeDiff > MESSAGE_TIMEOUT) {
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
// return false
// }
val messageType = MessageType.fromValue(packet.type)
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
if (messageType != MessageType.ANNOUNCE) {
if (processedMessages.contains(messageID)) {
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.
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// NEW: Signature verification logging (not rejecting yet)
verifyPacketSignatureWithLogging(packet, peerID)
@@ -2,6 +2,7 @@ package com.bitchat.android.net
import android.app.Application
import android.util.Log
import com.bitchat.android.util.AppConstants
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong
/**
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
* Uses Arti (Tor in Rust) for improved security and reliability.
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
*
* This singleton provides Tor anonymity features using a custom Arti build
* compiled with 16KB page size support for Google Play compliance.
*
* Based on the original TorManager implementation.
*/
object TorManager {
private const val TAG = "TorManager"
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
class ArtiTorManager private constructor() {
enum class TorState {
OFF,
STARTING,
BOOTSTRAPPING,
RUNNING,
STOPPING,
ERROR
}
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = "",
val state: TorState = TorState.OFF
)
companion object {
private const val TAG = "ArtiTorManager"
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
@Volatile
private var INSTANCE: ArtiTorManager? = null
fun getInstance(): ArtiTorManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
}
}
}
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false
@Volatile private var socksAddr: InetSocketAddress? = null
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
@Volatile private var lastMode: TorMode = TorMode.OFF
@Volatile
private var initialized = false
@Volatile
private var socksAddr: InetSocketAddress? = null
@Volatile
private var artiProxy: ArtiProxy? = null
@Volatile
private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var lastLogTime = AtomicLong(0L)
@Volatile private var retryAttempts = 0
@Volatile private var bindRetryAttempts = 0
@Volatile
private var desiredMode: TorMode = TorMode.OFF
@Volatile
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile
private var lastLogTime = AtomicLong(0L)
@Volatile
private var retryAttempts = 0
@Volatile
private var bindRetryAttempts = 0
private var inactivityJob: Job? = null
private var retryJob: Job? = null
private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
@Volatile
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
val lastLogLine: String = "",
val state: TorState = TorState.OFF
private val _statusFlow = MutableStateFlow(
TorStatus(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
lastLogLine = "",
state = TorState.OFF
)
)
private val _status = MutableStateFlow(TorStatus())
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
fun isProxyEnabled(): Boolean {
val s = _status.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
val s = _statusFlow.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
socksAddr != null && s.state == TorState.RUNNING
}
fun init(application: Application) {
@@ -82,7 +127,21 @@ object TorManager {
currentApplication = application
TorPreferenceManager.init(application)
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_statusFlow.update { it.copy(lastLogLine = s) }
handleArtiLogLine(s)
}
artiProxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
val savedMode = TorPreferenceManager.get(application)
if (savedMode == TorMode.ON) {
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
@@ -90,13 +149,15 @@ object TorManager {
}
desiredMode = savedMode
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
} // Only reset OkHttp during init
}
appScope.launch {
applyMode(application, savedMode)
}
// Observe changes
appScope.launch {
TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode)
@@ -112,53 +173,63 @@ object TorManager {
try {
desiredMode = mode
lastMode = mode
val s = _status.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
val s = _statusFlow.value
if (mode == s.mode && mode != TorMode.OFF &&
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
) {
Log.i(
TAG,
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
)
return
}
when (mode) {
TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
stopArti() // non-suspending immediate request
// Best-effort wait for STOPPED before we declare OFF
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
stopArti()
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.OFF
)
currentSocksPort = DEFAULT_SOCKS_PORT
bindRetryAttempts = 0
lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
resetNetworkConnections()
}
TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti")
// Reset port to default unless we're already using a higher port
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
currentSocksPort = DEFAULT_SOCKS_PORT
}
bindRetryAttempts = 0
lifecycleState = LifecycleState.STARTING
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
// Immediately set the planned SOCKS address so all traffic is forced through it,
// even before Tor is fully bootstrapped. This prevents any direct connections.
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.ON,
running = false,
bootstrapPercent = 0,
state = TorState.STARTING
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
startArti(application, useDelay = false)
// Defer enabling proxy until bootstrap completes
appScope.launch {
waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) {
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
}
}
}
@@ -171,84 +242,95 @@ object TorManager {
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
try {
// Ensure any previous instance is fully stopped before starting a new one
stopArtiAndWait()
Log.i(TAG, "Starting Arti on port $currentSocksPort")
if (useDelay) {
delay(RESTART_DELAY_MS)
}
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text.toString()
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
handleArtiLogLine(s)
val proxy = artiProxy ?: run {
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
_statusFlow.update { it.copy(state = TorState.ERROR) }
return
}
val proxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start()
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
_statusFlow.update {
it.copy(
running = true,
bootstrapPercent = 0,
state = TorState.STARTING
)
}
lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR)
// Check if this is a bind error
_statusFlow.update { it.copy(state = TorState.ERROR) }
val isBindError = isBindError(e)
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
bindRetryAttempts++
currentSocksPort++
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
// Update planned SOCKS address immediately so all new connections target the new port
Log.w(
TAG,
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
// Immediate retry with incremented port, no exponential backoff for bind errors
resetNetworkConnections()
startArti(application, useDelay = false)
} else if (isBindError) {
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
lifecycleState = LifecycleState.STOPPED
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
_statusFlow.update {
it.copy(
running = false,
bootstrapPercent = 0,
state = TorState.ERROR
)
}
} else {
// For non-bind errors, use the existing retry mechanism
scheduleRetry(application)
}
}
}
/**
* Checks if the exception indicates a port binding failure
*/
private fun isBindError(exception: Exception): Boolean {
val message = exception.message?.lowercase() ?: ""
return message.contains("bind") ||
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
}
/**
* Reset network connections after Tor state changes.
* Rebuilds OkHttp clients and reconnects Nostr relays.
*/
private fun resetNetworkConnections() {
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
}
try {
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) {
}
}
private fun stopArtiInternal() {
try {
val proxy = artiProxyRef.getAndSet(null)
val proxy = artiProxy
if (proxy != null) {
Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {}
try {
proxy.stop()
} catch (_: Throwable) {
}
}
stopInactivityMonitoring()
stopRetryMonitoring()
@@ -260,15 +342,16 @@ object TorManager {
private fun stopArti() {
stopArtiInternal()
socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
_statusFlow.value = _statusFlow.value.copy(
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
}
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
// Request stop
stopArtiInternal()
// Wait for confirmation via logs (Stopped) or timeout
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
// Small grace period before relaunch to let file locks clear
delay(200)
}
@@ -276,7 +359,7 @@ object TorManager {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiAndWait()
delay(RESTART_DELAY_MS)
startArti(application, useDelay = false) // Already delayed above
startArti(application, useDelay = false)
}
private fun startInactivityMonitoring() {
@@ -287,13 +370,16 @@ object TorManager {
val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
val bootstrapPercent = _status.value.bootstrapPercent
val bootstrapPercent = _statusFlow.value.bootstrapPercent
if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
Log.w(
TAG,
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
)
currentApplication?.let { app ->
appScope.launch {
restartArti(app)
@@ -316,11 +402,11 @@ object TorManager {
retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch {
delay(delayMs)
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application)
@@ -337,50 +423,110 @@ object TorManager {
}
private suspend fun waitUntilBootstrapped() {
val current = _status.value
val current = _statusFlow.value
if (!current.running) return
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
// Suspend until we observe RUNNING at least once
while (true) {
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
val s = statusFlow.first {
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
!it.running ||
it.state == TorState.ERROR
}
if (!s.running || s.state == TorState.ERROR) return
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
}
}
private fun handleArtiLogLine(s: String) {
val currentState = _statusFlow.value.state
val currentLifecycle = lifecycleState
when {
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
s.contains(
"Sufficiently bootstrapped; system SOCKS now functional",
ignoreCase = true
) -> {
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
bootstrapPercent = 75,
state = TorState.BOOTSTRAPPING
)
}
retryAttempts = 0
bindRetryAttempts = 0
startInactivityMonitoring()
}
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.RUNNING,
bootstrapPercent = 100,
running = true
)
}
completeWaitersIf(TorState.RUNNING)
}
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
if (currentLifecycle != LifecycleState.STOPPING) {
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.STOPPING,
running = false
)
}
}
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
Log.w(
TAG,
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
)
return
}
_statusFlow.update {
it.copy(
state = TorState.OFF,
running = false,
bootstrapPercent = 0
)
}
completeWaitersIf(TorState.OFF)
}
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
// Signal error; we'll likely need to wait longer before restart
_status.value = _status.value.copy(state = TorState.ERROR)
_statusFlow.update { it.copy(state = TorState.ERROR) }
}
}
}
@@ -395,13 +541,11 @@ object TorManager {
val def = CompletableDeferred<TorState>()
stateChangeDeferred.getAndSet(def)?.cancel()
return withTimeoutOrNull(timeoutMs) {
// Fast-path: if we're already there
val cur = _status.value.state
val cur = _statusFlow.value.state
if (cur == target) return@withTimeoutOrNull cur
def.await()
}
}
// Visible for instrumentation tests to validate installation
fun installResourcesForTest(application: Application): Boolean { return true }
fun isTorAvailable(): Boolean = true
}
@@ -42,8 +42,9 @@ object OkHttpProvider {
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
val torProvider = ArtiTorManager.getInstance()
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
// even before bootstrap, to prevent any direct connections from occurring.
if (socks != null) {
val proxy = Proxy(Proxy.Type.SOCKS, socks)
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson
import java.util.Date
@@ -112,7 +111,7 @@ class GeohashRepository(
val geohash = currentGeohash
if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList())
state.setGeohashPeople(emptyList())
return
}
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
@@ -143,7 +142,7 @@ class GeohashRepository(
)
}.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people)
state.setGeohashPeople(people)
}
fun updateReactiveParticipantCounts() {
@@ -155,7 +154,7 @@ class GeohashRepository(
counts[gh] = active
}
// Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts)
state.setGeohashParticipantCounts(counts)
}
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding
* iOS-compatible implementation with StateFlow for Android UI binding
*/
@MainThread
class LocationNotesManager private constructor() {
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
NO_RELAYS
}
// Published state (LiveData for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes
// Published state (StateFlow for Android)
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _geohash = MutableLiveData<String?>(null)
val geohash: LiveData<String?> = _geohash
private val _geohash = MutableStateFlow<String?>(null)
val geohash: StateFlow<String?> = _geohash.asStateFlow()
private val _initialLoadComplete = MutableLiveData(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
private val _initialLoadComplete = MutableStateFlow(false)
val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
private val _state = MutableLiveData(State.IDLE)
val state: LiveData<State> = _state
private val _state = MutableStateFlow(State.IDLE)
val state: StateFlow<State> = _state.asStateFlow()
private val _errorMessage = MutableLiveData<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* High-level Nostr client that manages identity, connections, and messaging
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
private var currentIdentity: NostrIdentity? = null
// Client state
private val _isInitialized = MutableLiveData<Boolean>()
val isInitialized: LiveData<Boolean> = _isInitialized
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableLiveData<String>()
val currentNpub: LiveData<String> = _currentNpub
private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) {
_currentNpub.postValue(currentIdentity!!.npub)
_currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays
relayManager.connect()
_isInitialized.postValue(true)
_isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully")
} else {
Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.postValue(false)
_isInitialized.value = false
}
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.postValue(false)
_isInitialized.value = false
}
}
}
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
fun shutdown() {
Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect()
_isInitialized.postValue(false)
_isInitialized.value = false
}
/**
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
/**
* Get relay connection status
*/
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/**
* Get relay information
*/
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods
@@ -1,9 +1,10 @@
package com.bitchat.android.nostr
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
)
// Published state
private val _relays = MutableLiveData<List<Relay>>()
val relays: LiveData<List<Relay>> = _relays
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>()
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state
private val relaysList = mutableListOf<Relay>()
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
updateConnectionStatus()
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback
_relays.postValue(emptyList())
_isConnected.postValue(false)
_relays.value = emptyList()
_isConnected.value = false
}
}
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
}
private fun updateRelaysList() {
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
}
private fun updateConnectionStatus() {
val connected = relaysList.any { it.isConnected }
_isConnected.postValue(connected)
_isConnected.value = connected
}
private fun generateSubscriptionId(): String {
@@ -11,14 +11,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@@ -28,9 +28,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.debug.DebugSettingsSheet
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
/**
* About Sheet for bitchat app information
* Matches the design language of LocationChannelsSheet
@@ -240,7 +245,7 @@ fun AboutSheet(
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 8.dp)
)
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle()
Row(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -276,8 +281,8 @@ fun AboutSheet(
PoWPreferenceManager.init(context)
}
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(horizontal = 24.dp),
@@ -383,7 +388,9 @@ fun AboutSheet(
// Network (Tor) section
item(key = "network_section") {
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Text(
text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge,
@@ -398,19 +405,22 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
selected = torMode.value == TorMode.OFF,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.OFF
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
torMode.value = TorMode.OFF
TorPreferenceManager.set(context, torMode.value)
},
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
selected = torMode.value == TorMode.ON,
onClick = {
torMode.value = com.bitchat.android.net.TorMode.ON
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
if (torAvailable) {
torMode.value = TorMode.ON
TorPreferenceManager.set(context, torMode.value)
}
},
enabled = torAvailable,
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -428,13 +438,49 @@ fun AboutSheet(
}
}
)
if (!torAvailable) {
val tooltipState = rememberTooltipState()
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
tooltipState.show()
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.6f
),
modifier = Modifier.size(18.dp)
)
}
}
}
}
Text(
text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
if (torMode.value == com.bitchat.android.net.TorMode.ON) {
if (torMode.value == TorMode.ON) {
val statusText = if (torStatus.running) "Running" else "Stopped"
// Debug status (temporary)
Surface(
@@ -551,18 +597,12 @@ fun AboutSheet(
.height(64.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
CloseButton(
onClick = onDismiss,
modifier = Modifier
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
.padding(horizontal = 16.dp),
)
}
}
}
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* Header components for ChatScreen
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when {
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
when {
selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive(
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader(
peerID = selectedPrivatePeer,
@@ -523,18 +523,18 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val isConnected by viewModel.isConnected.observeAsState(false)
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
// Bookmarks store for current geohash toggle (iOS parity)
val context = androidx.compose.ui.platform.LocalContext.current
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth(),
@@ -653,8 +653,8 @@ private fun LocationChannelsButton(
val colorScheme = MaterialTheme.colorScheme
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
val teleported by viewModel.isTeleported.observeAsState(false)
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
@@ -25,6 +24,7 @@ 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
import com.bitchat.android.ui.media.FullScreenImageViewer
@@ -41,22 +41,22 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val currentChannel by viewModel.currentChannel.observeAsState()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -78,11 +78,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
showPasswordDialog = showPasswordPrompt
}
val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines)
val displayMessages = when {
@@ -1,10 +1,17 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Centralized state definitions and data classes for the chat system
@@ -21,171 +28,162 @@ data class CommandSuggestion(
/**
* Contains all the observable state for the chat system
*/
class ChatState {
class ChatState(
scope: CoroutineScope
) {
// Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
private val _nickname = MutableLiveData<String>()
val nickname: LiveData<String> = _nickname
private val _nickname = MutableStateFlow<String>("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
// Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
private val _currentChannel = MutableLiveData<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel
private val _currentChannel = MutableStateFlow<String?>(null)
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
private val _passwordPromptChannel = MutableLiveData<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state
private val _showSidebar = MutableLiveData(false)
val showSidebar: LiveData<Boolean> = _showSidebar
private val _showSidebar = MutableStateFlow(false)
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
// Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
private val _showMentionSuggestions = MutableStateFlow(false)
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
// Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
// Peer fingerprint state for reactive favorites (for reactive UI updates)
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
// Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo
private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
// Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
private val _isTeleported = MutableLiveData<Boolean>(false)
val isTeleported: LiveData<Boolean> = _isTeleported
private val _isTeleported = MutableStateFlow<Boolean>(false)
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
// Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
// Geohash participant counts reactive state (for real-time location channel counts)
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
.map { unreadMap -> unreadMap.values.any { it > 0 } }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
init {
// Initialize unread state mediators
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
}
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
.map { unreadSet -> unreadSet.isNotEmpty() }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
// Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList()
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
fun getMessagesValue() = _messages.value
fun getConnectedPeersValue() = _connectedPeers.value
fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
fun getPrivateChatsValue() = _privateChats.value
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
fun getShowAppInfoValue() = _showAppInfo.value ?: false
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
fun getMentionSuggestionsValue() = _mentionSuggestions.value
fun getFavoritePeersValue() = _favoritePeers.value
fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
// Setters for state updates
fun setMessages(messages: List<BitchatMessage>) {
@@ -197,7 +195,7 @@ class ChatState {
}
fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported)
_teleportedGeo.value = teleported
}
fun setNickname(nickname: String) {
@@ -269,7 +267,7 @@ class ChatState {
}
fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
val currentValue = _favoritePeers.value
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
@@ -278,8 +276,7 @@ class ChatState {
// Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
}
fun setPeerSessionStates(states: Map<String, String>) {
@@ -4,8 +4,8 @@ import android.app.Application
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
@@ -47,7 +47,9 @@ class ChatViewModel(
}
// MARK: - State management
private val state = ChatState()
private val state = ChatState(
scope = viewModelScope,
)
// Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>()
@@ -103,40 +105,40 @@ class ChatViewModel(
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers
val nickname: LiveData<String> = state.nickname
val isConnected: LiveData<Boolean> = state.isConnected
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
val currentChannel: LiveData<String?> = state.currentChannel
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
val showSidebar: LiveData<Boolean> = state.showSidebar
val messages: StateFlow<List<BitchatMessage>> = state.messages
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: StateFlow<String> = state.nickname
val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: LiveData<Boolean> = state.isTeleported
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
init {
// Note: Mesh service delegate is now set by MainActivity
@@ -570,7 +572,7 @@ class ChatViewModel(
private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================")
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
/**
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val isTeleported by viewModel.isTeleported.observeAsState(false)
val nickname by viewModel.nickname.observeAsState("")
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column {
// Header matching iOS style
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
@@ -55,9 +55,9 @@ class GeohashViewModel(
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() {
subscriptionManager.connect()
@@ -73,12 +73,16 @@ class GeohashViewModel(
}
try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
viewModelScope.launch {
locationChannelManager?.selectedChannel?.collect { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
}
locationChannelManager?.teleported?.observeForever { teleported ->
state.setIsTeleported(teleported)
viewModelScope.launch {
locationChannelManager?.teleported?.collect { teleported ->
state.setIsTeleported(teleported)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
@@ -120,7 +124,7 @@ class GeohashViewModel(
}
try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
@@ -231,7 +235,7 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
// Observe location manager state
val permissionState by locationManager.permissionState.observeAsState()
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val selectedChannel by locationManager.selectedChannel.observeAsState()
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
// Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// UI state
var customGeohash by remember { mutableStateOf("") }
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
.height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
CloseButton(
onClick = onDismiss,
modifier = Modifier
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
.padding(horizontal = 16.dp),
)
}
}
}
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -35,10 +35,10 @@ fun LocationNotesButton(
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -46,7 +46,7 @@ fun LocationNotesButton(
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList())
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -25,6 +24,7 @@ import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
val locationManager = remember { LocationChannelManager.getInstance(context) }
// State
val notes by notesManager.notes.observeAsState(emptyList())
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
val notes by notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size
// Get location name (building or block) - matches iOS locationNames lookup
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) {
// Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK]
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId)
}
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
val isMining by PoWPreferenceManager.isMining.collectAsState()
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -22,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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
@@ -39,14 +39,14 @@ fun SidebarOverlay(
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
val currentChannel by viewModel.currentChannel.observeAsState()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val nickname by viewModel.nickname.observeAsState("")
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box(
modifier = modifier
@@ -110,7 +110,7 @@ fun SidebarOverlay(
// People section - switch between mesh and geohash lists (iOS-compatible)
item {
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -291,10 +291,10 @@ fun PeopleSection(
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
@@ -384,7 +384,7 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
@@ -1,5 +1,7 @@
package com.bitchat.android.util
import java.util.UUID
/**
* Centralized application-wide constants.
*/
@@ -22,6 +24,12 @@ object AppConstants {
// GATT client RSSI updates
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
object Gatt {
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
}
object Sync {
@@ -0,0 +1,9 @@
package info.guardianproject.arti
/**
* Exception thrown by Arti operations.
*
* This exception is thrown when the native Arti library encounters
* an error during initialization, startup, or other operations.
*/
class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -0,0 +1,17 @@
package info.guardianproject.arti
/**
* Listener interface for Arti log messages.
*
* This interface is called from the native Arti implementation whenever
* a log line is produced. It allows the application to monitor Tor's
* bootstrap progress and connection status.
*/
fun interface ArtiLogListener {
/**
* Called when Arti produces a log line.
*
* @param logLine The log message from Arti, or null if no message
*/
fun onLogLine(logLine: String?)
}
@@ -0,0 +1,183 @@
package info.guardianproject.arti
import android.app.Application
import android.util.Log
import org.torproject.arti.ArtiNative
import java.io.File
/**
* Arti Tor proxy implementation compatible with Guardian Project API.
*
* This class provides a wrapper around the custom-built Arti native library,
* maintaining API compatibility with Guardian Project's arti-mobile-ex library.
*
* Usage:
* ```
* val proxy = ArtiProxy.Builder(application)
* .setSocksPort(9050)
* .setDnsPort(9051)
* .setLogListener { logLine -> Log.i("Arti", logLine ?: "") }
* .build()
*
* proxy.start()
* // ... use proxy ...
* proxy.stop()
* ```
*/
class ArtiProxy private constructor(
private val application: Application,
private val socksPort: Int,
private val dnsPort: Int,
private val logListener: ArtiLogListener?
) {
companion object {
private const val TAG = "ArtiProxy"
}
@Volatile
private var isRunning = false
/**
* Start the Arti Tor proxy.
*
* This method:
* 1. Registers log callback
* 2. Initializes Arti runtime with data directory
* 3. Starts SOCKS proxy on configured port
*
* @throws ArtiException if initialization or startup fails
*/
fun start() {
if (isRunning) {
Log.w(TAG, "Arti already running")
return
}
try {
logListener?.let { listener ->
Log.d(TAG, "Registering log callback")
ArtiNative.setLogCallback(listener)
}
val dataDir = getDataDirectory()
Log.i(TAG, "Initializing Arti with data directory: $dataDir")
val initResult = ArtiNative.initialize(dataDir.absolutePath)
if (initResult != 0) {
throw ArtiException("Failed to initialize Arti: error code $initResult")
}
Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)")
val startResult = ArtiNative.startSocksProxy(socksPort)
when (startResult) {
0 -> {
isRunning = true
Log.i(TAG, "Arti started successfully")
}
-1 -> throw ArtiException("Arti client not initialized")
-2 -> throw ArtiException("Tokio runtime not initialized")
-3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)")
else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start Arti", e)
if (e is ArtiException) {
throw e
} else {
throw ArtiException("Failed to start Arti: ${e.message}", e)
}
}
}
/**
* Stop the Arti Tor proxy.
*
* This method gracefully shuts down the Tor client and SOCKS proxy.
*/
fun stop() {
if (!isRunning) {
Log.w(TAG, "Arti not running")
return
}
try {
Log.i(TAG, "Stopping Arti...")
val stopResult = ArtiNative.stop()
if (stopResult != 0) {
Log.w(TAG, "Stop returned error code: $stopResult")
}
isRunning = false
Log.i(TAG, "Arti stopped successfully")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Arti", e)
}
}
/**
* Get or create Arti data directory.
*
* Directory structure:
* - {app_data}/arti/cache - Tor directory cache
* - {app_data}/arti/state - Tor persistent state
*/
private fun getDataDirectory(): File {
val artiDir = File(application.filesDir, "arti")
if (!artiDir.exists()) {
artiDir.mkdirs()
}
File(artiDir, "cache").apply { if (!exists()) mkdirs() }
File(artiDir, "state").apply { if (!exists()) mkdirs() }
return artiDir
}
/**
* Builder for ArtiProxy instances.
*
* Provides fluent API for configuring proxy settings before creation.
*/
class Builder(private val application: Application) {
private var socksPort: Int = 9050
private var dnsPort: Int = 9051
private var logListener: ArtiLogListener? = null
/**
* Set SOCKS proxy port.
* @param port Port number (default: 9050)
* @return this Builder for chaining
*/
fun setSocksPort(port: Int) = apply {
this.socksPort = port
}
/**
* Set DNS port.
* @param port Port number (default: 9051)
* @return this Builder for chaining
*/
fun setDnsPort(port: Int) = apply {
this.dnsPort = port
}
/**
* Set log listener for Arti log messages.
* @param listener Callback for log lines
* @return this Builder for chaining
*/
fun setLogListener(listener: ArtiLogListener) = apply {
this.logListener = listener
}
/**
* Build and return the configured ArtiProxy instance.
* @return Configured ArtiProxy (not yet started)
*/
fun build(): ArtiProxy {
return ArtiProxy(application, socksPort, dnsPort, logListener)
}
}
}
@@ -0,0 +1,54 @@
package org.torproject.arti
import info.guardianproject.arti.ArtiLogListener
/**
* JNI wrapper for custom-built Arti (Tor implementation in Rust)
*
* This class provides native bindings to libarti_android.so compiled from
* the latest Arti source with rustls (no OpenSSL dependency).
*
* Features:
* - Latest Arti v1.7.0 code
* - 16KB page size support (Google Play Nov 2025 ready)
* - Onion service client support
* - Pure Rust TLS (rustls)
*/
object ArtiNative {
init {
System.loadLibrary("arti_android")
}
/**
* Get Arti version string
* @return Version string from native library
*/
external fun getVersion(): String
/**
* Set log callback for Arti logs
* @param callback Callback object with onLogLine(String?) method
*/
external fun setLogCallback(callback: ArtiLogListener)
/**
* Initialize Arti runtime
* @param dataDir Directory for Arti state/cache
* @return 0 on success, error code otherwise
*/
external fun initialize(dataDir: String): Int
/**
* Start SOCKS proxy on specified port
* @param port Port number for SOCKS proxy (e.g., 9050)
* @return 0 on success, error code otherwise
*/
external fun startSocksProxy(port: Int): Int
/**
* Stop Arti and cleanup
* @return 0 on success, error code otherwise
*/
external fun stop(): Int
}
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -378,8 +378,9 @@
<string name="pwd_label">Password</string>
<string name="join">Join</string>
<string name="cancel">Cancel</string>
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
<!-- Plurals -->
<!-- Plurals -->
<plurals name="notification_and_more">
<item quantity="one">and %d more</item>
<item quantity="other">and %d more</item>