diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 55c6389d..f6de2444 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
- versionCode = 26
- versionName = "1.5.1"
+ versionCode = 27
+ versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index cd316d72..4bc0bb5c 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -243,17 +243,6 @@
column="35"/>
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -48,6 +57,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv
index 2c07fe69..ab075fb4 100644
--- a/app/src/main/assets/nostr_relays.csv
+++ b/app/src/main/assets/nostr_relays.csv
@@ -1,270 +1,295 @@
Relay URL,Latitude,Longitude
-relay02.lnfi.network,39.0997,-94.5786
-srtrelay.c-stellar.net,43.6532,-79.3832
-alienos.libretechsystems.xyz,55.4724,9.87335
-relay.uid.ovh,43.6532,-79.3832
-relay.cypherflow.ai,48.8566,2.35222
-wot.tealeaf.dev,33.7488,-84.3877
-relay.divine.video,43.6532,-79.3832
-relay.tagayasu.xyz,43.6715,-79.38
-nostr.camalolo.com,24.1469,120.684
-wot.sudocarlos.com,51.5072,-0.127586
-nostr.vulpem.com,49.4543,11.0746
-relay2.angor.io,48.1046,11.6002
-relay.nostr.nisshiee.org,37.3387,-121.885
-wot.nostr.party,36.1627,-86.7816
-nostr.luisschwab.net,43.6532,-79.3832
-cyberspace.nostr1.com,40.7128,-74.006
-strfry.felixzieger.de,50.1013,8.62643
-relay.agora.social,50.7383,15.0648
-nostr.n7ekb.net,47.4941,-122.294
-relay.bitcoinveneto.org,64.1466,-21.9426
-relay.stream.labs.h3.se,59.4016,17.9455
-nostr.azzamo.net,52.2633,21.0283
-relay.nostrhub.tech,49.0291,8.35696
-relay.21e6.cz,50.7383,15.0648
-nostr-02.czas.top,51.2277,6.77346
-nostr.czas.top,50.1109,8.68213
-wot.nostr.place,32.7767,-96.797
strfry.shock.network,39.0438,-77.4874
-relay.orangepill.ovh,49.1689,-0.358841
-relay.illuminodes.com,47.6061,-122.333
-bcast.girino.org,43.6532,-79.3832
-nostr.stakey.net,52.3676,4.90414
-nostr.lkjsxc.com,43.6532,-79.3832
-nostr.coincrowd.fund,39.0438,-77.4874
-relay.damus.io,43.6532,-79.3832
-nostr.myshosholoza.co.za,52.3676,4.90414
-nostr.88mph.life,60.1699,24.9384
-relay.sharegap.net,43.6532,-79.3832
-purplerelay.com,50.1109,8.68213
-ribo.us.nostria.app,41.5868,-93.625
-nostr.snowbla.de,60.1699,24.9384
-relay.internationalright-wing.org,-22.5986,-48.8003
-relay.btcforplebs.com,43.6532,-79.3832
-nostr.red5d.dev,43.6532,-79.3832
-orangepiller.org,60.1699,24.9384
-relay.nosto.re,51.1792,5.89444
-relay04.lnfi.network,39.0997,-94.5786
-nostr.now,36.55,139.733
-relay.binaryrobot.com,43.6532,-79.3832
-premium.primal.net,43.6532,-79.3832
-relay.davidebtc.me,51.5072,-0.127586
-purpura.cloud,43.6532,-79.3832
-nostr.mikoshi.de,50.1109,8.68213
-nostr.simplex.icu,51.5121,-0.0005238
-articles.layer3.news,37.3387,-121.885
-nostr.spicyz.io,43.6532,-79.3832
-nostrcheck.me,43.6532,-79.3832
-relay.holzeis.me,43.6532,-79.3832
-nostr-01.uid.ovh,43.6532,-79.3832
-relay.nostar.org,43.6532,-79.3832
-soloco.nl,43.6532,-79.3832
-nostr.thebiglake.org,32.71,-96.6745
-relay.nostr-check.me,43.6532,-79.3832
-nproxy.kristapsk.lv,60.1699,24.9384
-relay-freeharmonypeople.space,38.7223,-9.13934
-nostrelay.memory-art.xyz,43.6532,-79.3832
-orangesync.tech,50.1109,8.68213
-nostr.data.haus,50.4754,12.3683
-vitor.nostr1.com,40.7057,-74.0136
-nostr.overmind.lol,43.6532,-79.3832
-nostr-relay.online,43.6532,-79.3832
-relay.bitcoindistrict.org,43.6532,-79.3832
-relay.javi.space,43.4633,11.8796
-fanfares.nostr1.com,40.7057,-74.0136
-temp.iris.to,43.6532,-79.3832
-relay.guggero.org,47.3769,8.54169
-relay.degmods.com,50.4754,12.3683
-relay.nostr.net,43.6532,-79.3832
-relay.wolfcoil.com,35.6092,139.73
-relay.sigit.io,50.4754,12.3683
-shu04.shugur.net,25.2604,55.2989
-nostr.rtvslawenia.com,49.4543,11.0746
-nostr.kalf.org,52.3676,4.90414
-relay-testnet.k8s.layer3.news,37.3387,-121.885
-nostr.rblb.it,43.7094,10.6582
-nostr.ovia.to,43.6532,-79.3832
-nostr-relay.nextblockvending.com,47.2343,-119.853
-relay.agorist.space,52.3734,4.89406
-relay.jeffg.fyi,43.6532,-79.3832
-relay.ditto.pub,43.6532,-79.3832
-nostr.huszonegy.world,47.4979,19.0402
-nostr.coincards.com,53.5501,-113.469
-nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
-nostream.breadslice.com,1.35208,103.82
-ribo.eu.nostria.app,52.3676,4.90414
-nostr.oxtr.dev,50.4754,12.3683
-nostr.jerrynya.fun,31.2304,121.474
-black.nostrcity.club,48.8575,2.35138
-wheat.happytavern.co,43.6532,-79.3832
-nostr-relay.psfoundation.info,39.0438,-77.4874
-relay.zone667.com,60.1699,24.9384
-theoutpost.life,64.1476,-21.9392
-nostrelay.circum.space,52.3676,4.90414
-relay.origin.land,35.6673,139.751
-relay.lumina.rocks,49.0291,8.35695
-relay.chorus.community,50.1109,8.68213
-kotukonostr.onrender.com,37.7775,-122.397
-nostrcheck.tnsor.network,43.6532,-79.3832
-relay01.lnfi.network,39.0997,-94.5786
-bitsat.molonlabe.holdings,51.4012,-1.3147
-relay.nsnip.io,60.1699,24.9384
-nostr.mehdibekhtaoui.com,49.4939,-1.54813
-relay.ru.ac.th,13.7607,100.627
-relay.thibautduchene.fr,43.6532,-79.3832
-nos.xmark.cc,50.6924,3.20113
-wot.dtonon.com,43.6532,-79.3832
-relay.satmaxt.xyz,43.6532,-79.3832
-wot.brightbolt.net,47.6735,-116.781
-nostr.bond,50.1109,8.68213
-relay.vrtmrz.net,43.6532,-79.3832
-dev-relay.lnfi.network,39.0997,-94.5786
-wot.sebastix.social,51.1792,5.89444
-nostr.hifish.org,47.4043,8.57398
-divine.diy,43.6532,-79.3832
-bitcoiner.social,39.1585,-94.5728
-relay.islandbitcoin.com,12.8498,77.6545
-nostr-2.21crypto.ch,47.5356,8.73209
-relay.snort.social,53.3498,-6.26031
-shu05.shugur.net,48.8566,2.35222
-relay.nostrcheck.me,43.6532,-79.3832
-relay.0xchat.com,1.35208,103.82
-relay.dwadziesciajeden.pl,52.2297,21.0122
-relay.getsafebox.app,43.6532,-79.3832
-relay.moinsen.com,50.4754,12.3683
-relay.primal.net,43.6532,-79.3832
-relay.npubhaus.com,43.6532,-79.3832
-nostr.tuckerbradford.com,43.663,-70.2569
-nostr.rikmeijer.nl,50.4754,12.3683
-nostr.agentcampfire.com,52.3676,4.90414
-shu02.shugur.net,21.4902,39.2246
-librerelay.aaroniumii.com,43.6532,-79.3832
-fenrir-s.notoshi.win,43.6532,-79.3832
-relayrs.notoshi.win,43.6532,-79.3832
-nostr.girino.org,43.6532,-79.3832
-ribo.af.nostria.app,-26.2056,28.0337
-relay.bitcoinartclock.com,50.4754,12.3683
-relay.nostrdice.com,-33.8688,151.209
-strfry.elswa-dev.online,50.1109,8.68213
-nostr.21crypto.ch,47.5356,8.73209
-wot.shaving.kiwi,43.6532,-79.3832
-nostr.mom,50.4754,12.3683
-relay.lightning.pub,39.0438,-77.4874
-nostr-02.yakihonne.com,1.32123,103.695
-relay.mccormick.cx,52.3563,4.95714
-ynostr.yael.at,60.1699,24.9384
-nostr.faultables.net,43.6532,-79.3832
-prl.plus,56.9677,24.1056
-santo.iguanatech.net,40.8302,-74.1299
-nostr.bitcoiner.social,39.1585,-94.5728
-nostr-relay.corb.net,38.8353,-104.822
-relay.letsfo.com,51.098,17.0321
-nostr.calitabby.net,39.9268,-75.0246
-strfry.bonsai.com,37.8715,-122.273
-wot.nostr.net,43.6532,-79.3832
-bcast.seutoba.com.br,43.6532,-79.3832
-wot.sovbit.host,64.1466,-21.9426
-relay2.ngengine.org,43.6532,-79.3832
-r.bitcoinhold.net,43.6532,-79.3832
-ithurtswhenip.ee,51.223,6.78245
-freeben666.fr,43.7221,7.15296
-relay.notoshi.win,13.311,101.112
-yabu.me,35.6092,139.73
-dev-nostr.bityacht.io,25.0797,121.234
-nostr.commonshub.brussels,49.4543,11.0746
-relay.nostx.io,43.6532,-79.3832
-relay.nostr.band,60.1699,24.9384
-relay.barine.co,43.6532,-79.3832
-relay.arx-ccn.com,50.4754,12.3683
-satsage.xyz,37.3986,-121.964
-relay.nostr.wirednet.jp,34.706,135.493
-relay.artx.market,43.652,-79.3633
-nostr2.girino.org,43.6532,-79.3832
-nostr.tadryanom.me,43.6532,-79.3832
-relay.satlantis.io,32.8769,-80.0114
-nostr.zoracle.org,45.6018,-121.185
-nostr.bilthon.dev,25.8128,-80.2377
-relay.minibolt.info,43.6532,-79.3832
-relay.chakany.systems,43.6532,-79.3832
-nostr.spaceshell.xyz,43.6532,-79.3832
-nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
-offchain.pub,47.6743,-117.112
-shu01.shugur.net,21.4902,39.2246
-nostr-relay.xbytez.io,50.6924,3.20113
-nostr.noones.com,50.1109,8.68213
-relay.nostriot.com,41.5695,-83.9786
-nostr-relay.zimage.com,34.0549,-118.243
-relay.siamdev.cc,13.9178,100.424
-nostr.4rs.nl,49.0291,8.35696
-relay.coinos.io,43.6532,-79.3832
-khatru.nostrver.se,51.1792,5.89444
-x.kojira.io,43.6532,-79.3832
-wot.yesnostr.net,50.9871,2.12554
-nostr.casa21.space,43.6532,-79.3832
-nos.lol,50.4754,12.3683
-relay.mattybs.lol,43.6532,-79.3832
-nostr-01.yakihonne.com,1.32123,103.695
-nostr.tac.lol,47.4748,-122.273
-relay.nuts.cash,34.0362,-118.443
-relay.cosmicbolt.net,37.3986,-121.964
-nostr.diakod.com,43.6532,-79.3832
-relay.mostro.network,40.8302,-74.1299
-relay.fr13nd5.com,52.5233,13.3426
-relay.trustroots.org,43.6532,-79.3832
-relay.thebluepulse.com,49.4521,11.0767
-relay.wavefunc.live,34.0362,-118.443
-relay.wavlake.com,41.2619,-95.8608
-relay.libernet.app,43.6532,-79.3832
-nostr-relay.cbrx.io,43.6532,-79.3832
-relay.fountain.fm,39.0997,-94.5786
-relay.hook.cafe,43.6532,-79.3832
-notemine.io,52.2026,20.9397
-relay.electriclifestyle.com,26.2897,-80.1293
-inbox.azzamo.net,52.2633,21.0283
-nostr.hekster.org,37.3986,-121.964
-relay.routstr.com,43.6532,-79.3832
-relay.puresignal.news,43.6532,-79.3832
-wot.dergigi.com,64.1476,-21.9392
-nostr-03.dorafactory.org,1.35208,103.82
-relay.fundstr.me,42.3601,-71.0589
-relayone.soundhsa.com,33.1384,-95.6011
-nostr.plantroon.com,50.1013,8.62643
-relay.ngengine.org,43.6532,-79.3832
-relay-dev.satlantis.io,40.8302,-74.1299
-relay.angor.io,48.1046,11.6002
-relay.toastr.net,40.8054,-74.0241
-relay.nostrhub.fr,48.1045,11.6004
-relay-rpi.edufeed.org,49.4521,11.0767
-relay.bitesize-media.com,49.4543,11.0746
-relay.usefusion.ai,38.7134,-78.1591
-chat-relay.zap-work.com,43.6532,-79.3832
-nostr.0x7e.xyz,47.4988,8.72369
-nostr.ps1829.com,33.8851,130.883
-relay.bullishbounty.com,43.6532,-79.3832
-nostr.openhoofd.nl,51.9229,4.40833
+wot.nostr.party,36.1627,-86.7816
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
+nostr.now,36.55,139.733
+nostr.simplex.icu,51.5121,-0.0005238
+relay.threenine.services,51.5524,-0.29686
+relay.barine.co,43.6532,-79.3832
+theoutpost.life,64.1476,-21.9392
+purplerelay.com,50.1109,8.68213
+nostrue.com,40.8054,-74.0241
+relay-arg.zombi.cloudrodion.com,1.35208,103.82
+shu02.shugur.net,21.4902,39.2246
+relayone.geektank.ai,18.2148,-63.0574
relay03.lnfi.network,39.0997,-94.5786
-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
+soloco.nl,43.6532,-79.3832
+relay-testnet.k8s.layer3.news,37.3387,-121.885
+nostr.azzamo.net,52.2633,21.0283
+nostr.red5d.dev,43.6532,-79.3832
+nostr.thebiglake.org,32.71,-96.6745
+nostr.tadryanom.me,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
-relay.endfiat.money,43.6532,-79.3832
-relay.nostr.place,32.7767,-96.797
-nostr.sathoarder.com,48.5734,7.75211
+ithurtswhenip.ee,51.223,6.78245
orly.ft.hn,50.4754,12.3683
+relay02.lnfi.network,39.0997,-94.5786
+relay.nosto.re,51.1792,5.89444
+strfry.openhoofd.nl,51.9229,4.40833
+relays.diggoo.com,43.6532,-79.3832
+relay.nostrhub.tech,49.0291,8.35696
+relay.artx.market,43.652,-79.3633
+relay.sigit.io,50.4754,12.3683
+nostr-relay.psfoundation.info,39.0438,-77.4874
+wot.brightbolt.net,47.6735,-116.781
+relay.bitcoinartclock.com,50.4754,12.3683
+khatru.nostrver.se,51.1792,5.89444
+relay.cosmicbolt.net,37.3986,-121.964
+adre.su,59.9311,30.3609
+nostrelay.memory-art.xyz,43.6532,-79.3832
+temp.iris.to,43.6532,-79.3832
+nproxy.kristapsk.lv,60.1699,24.9384
+relay.ngengine.org,43.6532,-79.3832
+relay.puresignal.news,43.6532,-79.3832
+nostr.stakey.net,52.3676,4.90414
+nostr.commonshub.brussels,49.4543,11.0746
+wot.shaving.kiwi,43.6532,-79.3832
+espelho.girino.org,43.6532,-79.3832
+relay.thibautduchene.fr,43.6532,-79.3832
+nostr-relay.corb.net,38.8353,-104.822
+wot.soundhsa.com,33.1384,-95.6011
+relay.jabato.space,52.52,13.405
+relay.credenso.cafe,43.3601,-80.3127
+relayone.soundhsa.com,33.1384,-95.6011
+relay.bitcoinveneto.org,64.1466,-21.9426
relay.samt.st,40.8302,-74.1299
+relay.vrtmrz.net,43.6532,-79.3832
+nostr.data.haus,50.4754,12.3683
+relay.npubhaus.com,43.6532,-79.3832
+strfry.elswa-dev.online,50.1109,8.68213
+relay.wavefunc.live,34.0362,-118.443
+wot.sovbit.host,64.1466,-21.9426
+relay.smies.me,33.7501,-84.3885
+nostr.bilthon.dev,25.8128,-80.2377
+relay-fra.zombi.cloudrodion.com,48.8566,2.35222
+santo.iguanatech.net,40.8302,-74.1299
+prl.plus,42.6978,23.3246
+relay-freeharmonypeople.space,38.7223,-9.13934
+nostream.breadslice.com,1.35208,103.82
+nostr.notribe.net,40.8302,-74.1299
+relay.fountain.fm,39.0997,-94.5786
+orangepiller.org,60.1699,24.9384
+nostr.21crypto.ch,47.5356,8.73209
+nostr.camalolo.com,24.1469,120.684
+okn.czas.top,51.267,6.81738
+relay.wolfcoil.com,35.6092,139.73
+nostr.quali.chat,60.1699,24.9384
+nostr-relay.online,43.6532,-79.3832
+alienos.libretechsystems.xyz,55.4724,9.87335
+ribo.eu.nostria.app,52.3676,4.90414
+wot.yesnostr.net,50.9871,2.12554
+nostr.overmind.lol,43.6532,-79.3832
+relay.chakany.systems,43.6532,-79.3832
+relay.hasenpfeffr.com,39.0438,-77.4874
+nostrelites.org,41.8781,-87.6298
+strfry.bonsai.com,37.8715,-122.273
+relay.agora.social,50.7383,15.0648
+alien.macneilmediagroup.com,43.6532,-79.3832
+relay.getsafebox.app,43.6532,-79.3832
+relay.nuts.cash,34.0362,-118.443
+notemine.io,52.2026,20.9397
+relay.nostr.place,32.7767,-96.797
+relay.21e6.cz,50.7383,15.0648
+nostr.casa21.space,43.6532,-79.3832
+premium.primal.net,43.6532,-79.3832
+relay5.bitransfer.org,43.6532,-79.3832
+nostr.rblb.it,43.7094,10.6582
+relay.goodmorningbitcoin.com,43.6532,-79.3832
+relay.camelus.app,45.5201,-122.99
+relay.origin.land,35.6673,139.751
+relay.fr13nd5.com,52.5233,13.3426
+nos.lol,50.4754,12.3683
+wot.nostr.net,43.6532,-79.3832
+relay.javi.space,43.4633,11.8796
+relay2.ngengine.org,43.6532,-79.3832
+relay.angor.io,48.1046,11.6002
+pyramid.treegaze.com,43.6532,-79.3832
+relay.letsfo.com,52.2633,21.0283
+relay.nostrzh.org,43.6532,-79.3832
+nostr.hekster.org,37.3986,-121.964
+relay.0xchat.com,1.35208,103.82
+nostr.czas.top,50.1109,8.68213
+nostr.superfriends.online,43.6532,-79.3832
+hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832
+relay.mccormick.cx,52.3563,4.95714
+nostr.88mph.life,51.5072,-0.127586
+strfry.ymir.cloud,34.0965,-117.585
+nostr.lkjsxc.com,43.6532,-79.3832
+yabu.me,35.6092,139.73
+relay.chorus.community,50.1109,8.68213
+nostr-02.yakihonne.com,1.32123,103.695
+neuromancer.nettek.io,39.1429,-94.573
+relay04.lnfi.network,39.0997,-94.5786
nostr-relay.amethyst.name,39.0438,-77.4874
+nostrcheck.tnsor.network,43.6532,-79.3832
+wot.nostr.place,32.7767,-96.797
+cyberspace.nostr1.com,40.7057,-74.0136
+relay.guggero.org,47.3769,8.54169
+nostr.calitabby.net,39.9268,-75.0246
+x.kojira.io,43.6532,-79.3832
+nostr.agentcampfire.com,52.3676,4.90414
+relay.bullishbounty.com,43.6532,-79.3832
+relay.nostrverse.net,43.6532,-79.3832
+nostr.robosats.org,64.1476,-21.9392
+nostr-verified.wellorder.net,45.5201,-122.99
+nostr.huszonegy.world,47.4979,19.0402
+relay.cypherflow.ai,48.8566,2.35222
+nostr-relay.xbytez.io,50.6924,3.20113
+nostr.faultables.net,43.6532,-79.3832
+relay.libernet.app,43.6532,-79.3832
+relay.magiccity.live,25.8128,-80.2377
+relay.wellorder.net,45.5201,-122.99
+black.nostrcity.club,48.8575,2.35138
+relay.jeffg.fyi,43.6532,-79.3832
+freeben666.fr,43.7221,7.15296
+relay.nostr.wirednet.jp,34.706,135.493
+nostrelay.circum.space,52.3676,4.90414
+relay.islandbitcoin.com,12.8498,77.6545
+nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
+relay.nostriot.com,41.5695,-83.9786
+relayrs.notoshi.win,43.6532,-79.3832
+nostr-pub.wellorder.net,45.5201,-122.99
+nostr.vulpem.com,49.4543,11.0746
+nostr.sathoarder.com,48.5734,7.75211
+wheat.happytavern.co,43.6532,-79.3832
+relay.nostr.net,43.6532,-79.3832
+bcast.girino.org,43.6532,-79.3832
+nostr-02.czas.top,51.2277,6.77346
+vault.iris.to,43.6532,-79.3832
+ynostr.yael.at,60.1699,24.9384
+nostr.nodesmap.com,59.3327,18.0656
+nostr.n7ekb.net,47.4941,-122.294
+relayb.uid.ovh,43.6532,-79.3832
+shu05.shugur.net,48.8566,2.35222
+dev-relay.lnfi.network,39.0997,-94.5786
+relay.bitcoindistrict.org,43.6532,-79.3832
+nostr.spicyz.io,43.6532,-79.3832
+nostr.0x7e.xyz,47.4988,8.72369
+relay.dwadziesciajeden.pl,52.2297,21.0122
+relay.zone667.com,60.1699,24.9384
+nostr-dev.wellorder.net,45.5201,-122.99
+nos.xmark.cc,50.6924,3.20113
+relay.etch.social,41.2619,-95.8608
+nostr.na.social,43.6532,-79.3832
+relay.orangepill.ovh,49.1689,-0.358841
+relay.olas.app,50.4754,12.3683
+relay.holzeis.me,43.6532,-79.3832
+relay2.angor.io,48.1046,11.6002
+relay.degmods.com,50.4754,12.3683
+vitor.nostr1.com,40.7128,-74.006
+relay.btcforplebs.com,43.6532,-79.3832
+nostr.luisschwab.net,43.6532,-79.3832
+relay.moinsen.com,50.4754,12.3683
+czas.xyz,48.8566,2.35222
+nostr.bitcoiner.social,39.1585,-94.5728
+nostr.mehdibekhtaoui.com,49.4939,-1.54813
+inbox.azzamo.net,52.2633,21.0283
+nostr.ovia.to,43.6532,-79.3832
+nostr.myshosholoza.co.za,52.3676,4.90414
+fenrir-s.notoshi.win,43.6532,-79.3832
+relay-rpi.edufeed.org,49.4521,11.0767
+chat-relay.zap-work.com,43.6532,-79.3832
+nostr.bond,50.1109,8.68213
+relay01.lnfi.network,39.0997,-94.5786
+relay.seq1.net,43.6532,-79.3832
+nostr.rikmeijer.nl,50.4754,12.3683
+offchain.pub,47.6743,-117.112
+nostr.spaceshell.xyz,43.6532,-79.3832
+nos4smartnkind.tech,40.1872,44.5152
+kotukonostr.onrender.com,37.7775,-122.397
+nostr.ps1829.com,33.8851,130.883
+relay.lightning.pub,39.0438,-77.4874
+nostr-relay.gateway.in.th,15.2634,100.344
+relay.thebluepulse.com,49.4521,11.0767
+relay.malxte.de,52.52,13.405
+relay.usefusion.ai,38.7134,-78.1591
+nostr-relay.cbrx.io,43.6532,-79.3832
+shu01.shugur.net,21.4902,39.2246
+nostr.jerrynya.fun,31.2304,121.474
+dev-nostr.bityacht.io,25.0797,121.234
+bitsat.molonlabe.holdings,51.4012,-1.3147
+relay.primal.net,43.6532,-79.3832
+relay.wavlake.com,41.2619,-95.8608
+nostr-relay.nextblockvending.com,47.2343,-119.853
+v-relay.d02.vrtmrz.net,34.6937,135.502
+nostr.mikoshi.de,47.74,12.0917
+nostr.coincards.com,53.5501,-113.469
+relay.snort.social,53.3498,-6.26031
+bitcoiner.social,39.1585,-94.5728
+nostr.noones.com,50.1109,8.68213
+wot.dergigi.com,64.1476,-21.9392
+relay.lumina.rocks,49.0291,8.35695
+nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
+nostr.snowbla.de,60.1699,24.9384
+strfry.felixzieger.de,50.1013,8.62643
+relay.satlantis.io,32.8769,-80.0114
+relay-dev.satlantis.io,40.8302,-74.1299
+nostr.davidebtc.me,51.5072,-0.127586
+relay.mattybs.lol,43.6532,-79.3832
+relay.fundstr.me,42.3601,-71.0589
+relay.tagayasu.xyz,43.6715,-79.38
+nostr-relay.zimage.com,34.0549,-118.243
+nostrcheck.me,43.6532,-79.3832
+bucket.coracle.social,37.7775,-122.397
+relay.siamdev.cc,13.9178,100.424
+r.bitcoinhold.net,43.6532,-79.3832
+skeme.vanderwarker.family,40.8218,-74.45
+articles.layer3.news,37.3387,-121.885
+no.str.cr,9.92857,-84.0528
+srtrelay.c-stellar.net,43.6532,-79.3832
+relay.comcomponent.com,34.7062,135.493
+relay.illuminodes.com,47.6061,-122.333
+nostr.openhoofd.nl,51.9229,4.40833
+nostr.plantroon.com,50.1013,8.62643
+slick.mjex.me,39.048,-77.4817
+relay.minibolt.info,43.6532,-79.3832
+nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
+relay.nostrdice.com,-33.8688,151.209
+wot.dtonon.com,43.6532,-79.3832
+shu04.shugur.net,25.2604,55.2989
+nostr.tavux.tech,48.8575,2.35138
+nostr-02.uid.ovh,43.6532,-79.3832
+relay.nostrcheck.me,43.6532,-79.3832
+relay.nsnip.io,60.1699,24.9384
+relay.toastr.net,40.8054,-74.0241
+relay.divine.video,43.6532,-79.3832
+nostr.4rs.nl,49.0291,8.35696
+wot.sebastix.social,51.1792,5.89444
+relay.openfarmtools.org,60.1699,24.9384
+relay.damus.io,43.6532,-79.3832
+purpura.cloud,43.6532,-79.3832
+wot.sudocarlos.com,51.5072,-0.127586
+relay.arx-ccn.com,50.4754,12.3683
+nostr.zoracle.org,45.6018,-121.185
+nostr.tac.lol,47.4748,-122.273
+relay.coinos.io,43.6532,-79.3832
+nostr.rtvslawenia.com,49.4543,11.0746
+relay.davidebtc.me,51.5072,-0.127586
+ribo.us.nostria.app,41.5868,-93.625
+relay.upleb.uk,51.9194,19.1451
+librerelay.aaroniumii.com,43.6532,-79.3832
+relay.endfiat.money,43.6532,-79.3832
+relay.nostar.org,43.6532,-79.3832
+relay.electriclifestyle.com,26.2897,-80.1293
+relay.nostrhub.fr,48.1045,11.6004
+relay.agorist.space,52.3734,4.89406
+freelay.sovbit.host,64.1476,-21.9392
+nostr.hifish.org,47.4043,8.57398
+nostr-01.yakihonne.com,1.32123,103.695
+relay.routstr.com,43.6532,-79.3832
+nr.yay.so,46.2126,6.1154
+bcast.seutoba.com.br,43.6532,-79.3832
+fanfares.nostr1.com,40.7128,-74.006
+nostr.girino.org,43.6532,-79.3832
+nostr2.girino.org,43.6532,-79.3832
+relay.notoshi.win,13.311,101.112
+nostrja-kari.heguro.com,43.6532,-79.3832
+relay.mostro.network,40.8302,-74.1299
+satsage.xyz,37.3986,-121.964
+relay.evanverma.com,40.8302,-74.1299
+nostr-2.21crypto.ch,47.5356,8.73209
+relay.mitchelltribe.com,39.0438,-77.4874
+relaynostr.breadslice.com,43.6532,-79.3832
+nostr.chaima.info,51.223,6.78245
+nostr.mom,50.4754,12.3683
+relay.nostr-check.me,43.6532,-79.3832
+relay.ditto.pub,43.6532,-79.3832
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 6998cd60..012f388d 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -46,6 +46,11 @@ class BitchatApplication : Application() {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
+ // Initialize mesh service preferences
+ try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
+
+ // Proactively start the foreground service to keep mesh alive
+ try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
// TorManager already initialized above
}
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index cadc4547..28d0d163 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
- // Core mesh service - managed at app level
+ // Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
@@ -63,16 +63,55 @@ class MainActivity : OrientationAwareActivity() {
}
}
+ private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
+ if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
+ android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
+ finishAffinity()
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ // Register receiver for force finish signal from shutdown coordinator
+ val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
+ if (android.os.Build.VERSION.SDK_INT >= 33) {
+ registerReceiver(
+ forceFinishReceiver,
+ filter,
+ com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
+ null,
+ android.content.Context.RECEIVER_NOT_EXPORTED
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ registerReceiver(
+ forceFinishReceiver,
+ filter,
+ com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
+ null
+ )
+ }
+
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
+ finish()
+ return
+ }
+
+ com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
+
// Enable edge-to-edge display for modern Android look
enableEdgeToEdge()
// Initialize permission management
permissionManager = PermissionManager(this)
- // Initialize core mesh service first
- meshService = BluetoothMeshService(this)
+ // Ensure foreground service is running and get mesh instance from holder
+ try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
+ meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
// Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays
try { com.bitchat.android.wifiaware.WifiAwareController.setBleMeshService(meshService) } catch (_: Exception) { }
bluetoothStatusManager = BluetoothStatusManager(
@@ -678,6 +717,17 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
+ setIntent(intent)
+
+ // Check if this is a quit request from the notification
+ if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
+ android.util.Log.d("MainActivity", "Quit request received, finishing activity")
+ finish()
+ return
+ }
+
+ com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
+
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
@@ -688,6 +738,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
+ // Reattach mesh delegate to new ChatViewModel instance after Activity recreation
+ try { meshService.delegate = chatViewModel } catch (_: Exception) { }
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
@@ -713,13 +765,15 @@ class MainActivity : OrientationAwareActivity() {
}
}
- override fun onPause() {
+ override fun onPause() {
super.onPause()
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
+ // Detach UI delegate so the foreground service can own DM notifications while UI is closed
+ try { meshService.delegate = null } catch (_: Exception) { }
}
}
@@ -786,6 +840,8 @@ class MainActivity : OrientationAwareActivity() {
override fun onDestroy() {
super.onDestroy()
+ try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
+
// Cleanup location status manager
try {
locationStatusManager.cleanup()
@@ -794,14 +850,6 @@ class MainActivity : OrientationAwareActivity() {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}
- // Stop mesh services if app was fully initialized
- if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
- try {
- meshService.stopServices()
- Log.d("MainActivity", "Mesh services stopped successfully")
- } catch (e: Exception) {
- Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
- }
- }
+ // Do not stop mesh here; ForegroundService owns lifecycle for background reliability
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
index 3ac87764..8e0fa15b 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -149,6 +149,7 @@ class BluetoothConnectionManager(
try {
isActive = true
+ Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@@ -179,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
+ Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@@ -189,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
+ Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@@ -214,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false
connectionScope.launch {
+ Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
@@ -230,6 +234,18 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped")
}
}
+
+ /**
+ * Indicates whether this instance can be safely reused for a future start.
+ * Returns false if its coroutine scope has been cancelled.
+ */
+ fun isReusable(): Boolean {
+ val active = connectionScope.isActive
+ if (!active) {
+ Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
+ }
+ return active
+ }
/**
* Set app background state for power optimization
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index e619534a..6e31932a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
+ // Service-level notification manager for background (no-UI) DMs
+ private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
+ context.applicationContext,
+ androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
+ com.bitchat.android.util.NotificationIntervalManager()
+ )
// Service state management
private var isActive = false
@@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ // Tracks whether this instance has been terminated via stopServices()
+ private var terminated = false
init {
+ Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
+ Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun startPeriodicDebugLogging() {
serviceScope.launch {
+ Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) {
try {
delay(10000) // 10 seconds
@@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
}
+ Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
}
}
@@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch {
+ Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
delay(30000) // 30 seconds
@@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
}
}
+ Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
}
}
@@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
+ Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List) {
+ // Update process-wide state first
+ try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
+ // Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
@@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
+ Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100)
sendAnnouncementToPeer(peerID)
@@ -353,7 +372,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
+ // Always reflect into process-wide store so UI can hydrate after recreation
+ try {
+ when {
+ message.isPrivate -> {
+ val peer = message.senderPeerID ?: ""
+ if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
+ }
+ message.channel != null -> {
+ com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
+ }
+ else -> {
+ com.bitchat.android.services.AppStateStore.addPublicMessage(message)
+ }
+ }
+ } catch (_: Exception) { }
+ // And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
+
+ // If no UI delegate attached (app closed), show DM notification via service manager
+ if (delegate == null && message.isPrivate) {
+ try {
+ val senderPeerID = message.senderPeerID
+ if (senderPeerID != null) {
+ val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
+ val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
+ serviceNotificationManager.setAppBackgroundState(true)
+ serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
+ }
+ } catch (_: Exception) { }
+ }
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -478,12 +526,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
+ // Log incoming for debug graphs (do not double-count anywhere else)
+ try {
+ val nick = getPeerNicknames()[peerID]
+ com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
+ packetType = packet.type.toString(),
+ fromPeerID = peerID,
+ fromNickname = nick,
+ fromDeviceAddress = device?.address
+ )
+ } catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
serviceScope.launch {
+ Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200)
sendBroadcastAnnounce()
}
@@ -498,6 +557,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
+ Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
@@ -536,6 +596,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
+ if (terminated) {
+ // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
+ Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
+ return
+ }
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -547,6 +612,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
gossipSyncManager.start()
+ Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
@@ -568,11 +634,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement()
serviceScope.launch {
+ Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send
// Stop all components
gossipSyncManager.stop()
+ Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
+ Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -580,9 +649,24 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown()
packetProcessor.shutdown()
+ // Mark this instance as terminated and cancel its scope so it won't be reused
+ terminated = true
serviceScope.cancel()
+ Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
}
}
+
+ /**
+ * Whether this instance can be safely reused. Returns false after stopServices() or if
+ * any critical internal scope has been cancelled.
+ */
+ fun isReusable(): Boolean {
+ val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
+ if (!reusable) {
+ Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
+ }
+ return reusable
+ }
/**
* Send public message
@@ -802,7 +886,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
-
+
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
@@ -813,8 +897,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
-
+
try {
+ // Avoid duplicate read receipts: check persistent store first
+ val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
+ if (seenStore?.hasRead(messageID) == true) {
+ Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
+ return@launch
+ }
+
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -840,7 +931,10 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
-
+
+ // Persist as read after successful send
+ try { seenStore?.markRead(messageID) } catch (_: Exception) { }
+
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
@@ -853,7 +947,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce")
serviceScope.launch {
- val nickname = delegate?.getNickname() ?: myPeerID
+ val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -902,7 +996,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return
- val nickname = delegate?.getNickname() ?: myPeerID
+ val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -1001,6 +1095,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID)
}
+ /**
+ * Get current active peer count (for status/notifications)
+ */
+ fun getActivePeerCount(): Int {
+ return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
+ }
+
/**
* Get peer info for verification purposes
*/
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index b3474217..04e5d756 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
- val isRelay = (incomingAddr != null || incomingPeer != null)
-
- com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
+ val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
+ // Always log outgoing for the actual transmission target
+ manager.logOutgoing(
+ packetType = typeName,
+ toPeerID = toPeer,
+ toNickname = toNick,
+ toDeviceAddress = toDeviceAddress,
+ previousHopPeerID = incomingPeer
+ )
+ // Keep the verbose relay message for human readability
+ manager.logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
- isRelay = isRelay
+ isRelay = true
)
} catch (_: Exception) {
// Silently ignore debug logging failures
diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
index 9db444e6..46bc394f 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
@@ -224,26 +224,29 @@ class PowerManager(private val context: Context) {
}
private fun updatePowerMode() {
- val newMode = when {
- // Always use performance mode when charging (unless in background too long)
+ // Determine the base mode from battery/charging state only
+ val baseMode = when {
+ // Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
-
- // Critical battery - use ultra low power
+
+ // Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
-
- // Low battery - use power saver
+
+ // Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
-
- // Background app with medium battery - use power saver
- isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
-
- // Background app with good battery - use balanced
- isAppInBackground -> PowerMode.BALANCED
-
- // Foreground with good battery - use balanced
+
+ // Otherwise balanced
else -> PowerMode.BALANCED
}
-
+
+ // If app is in background (including when running as a foreground service),
+ // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
+ val newMode = if (isAppInBackground) {
+ if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
+ } else {
+ baseMode
+ }
+
if (newMode != currentMode) {
val oldMode = currentMode
currentMode = newMode
diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
new file mode 100644
index 00000000..1c3abaf9
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
@@ -0,0 +1,95 @@
+package com.bitchat.android.service
+
+import android.app.Application
+import android.os.Process
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.mesh.BluetoothMeshService
+import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.net.TorMode
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Coordinates a full application shutdown:
+ * - Stop mesh cleanly
+ * - Stop Tor without changing persistent setting
+ * - Clear in-memory AppState
+ * - Stop foreground service/notification
+ * - Kill the process after completion or after a 5s timeout
+ */
+object AppShutdownCoordinator {
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+ private val shutdownToken = AtomicLong(0L)
+ @Volatile
+ private var shutdownJob: Job? = null
+
+ fun cancelPendingShutdown() {
+ shutdownToken.incrementAndGet()
+ shutdownJob?.cancel()
+ shutdownJob = null
+ }
+
+ fun requestFullShutdownAndKill(
+ app: Application,
+ mesh: BluetoothMeshService?,
+ notificationManager: NotificationManagerCompat,
+ stopForeground: () -> Unit,
+ stopService: () -> Unit
+ ) {
+ val token = shutdownToken.incrementAndGet()
+ shutdownJob?.cancel()
+ val job = scope.launch {
+ // Signal UI to finish gracefully before we kill the process
+ try {
+ val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
+ .setPackage(app.packageName)
+ app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH)
+ } catch (_: Exception) { }
+
+ // Stop mesh (best-effort)
+ try { mesh?.stopServices() } catch (_: Exception) { }
+
+ // Stop Tor temporarily (do not change user setting)
+ val torProvider = ArtiTorManager.getInstance()
+ val torStop = async {
+ try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
+ }
+
+ // Clear AppState in-memory store
+ try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
+
+ // Stop foreground and clear notification
+ try { stopForeground() } catch (_: Exception) { }
+ try { notificationManager.cancel(10001) } catch (_: Exception) { }
+
+ // Wait up to 5 seconds for shutdown tasks
+ withTimeoutOrNull(5000) {
+ try { torStop.await() } catch (_: Exception) { }
+ delay(100)
+ }
+
+ // Stop the service itself
+ if (!isActive || shutdownToken.get() != token) return@launch
+ try { stopService() } catch (_: Exception) { }
+
+ // Hard kill the app process
+ if (!isActive || shutdownToken.get() != token) return@launch
+ try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
+ try { System.exit(0) } catch (_: Exception) { }
+ }
+ shutdownJob = job
+ job.invokeOnCompletion {
+ if (shutdownJob === job) {
+ shutdownJob = null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt
new file mode 100644
index 00000000..03a9dcd8
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt
@@ -0,0 +1,16 @@
+package com.bitchat.android.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class BootCompletedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ // Ensure preferences are initialized on cold boot before reading values
+ try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { }
+
+ if (MeshServicePreferences.isAutoStartEnabled(true)) {
+ MeshForegroundService.start(context.applicationContext)
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
new file mode 100644
index 00000000..e92274f8
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -0,0 +1,343 @@
+package com.bitchat.android.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.MainActivity
+import com.bitchat.android.R
+import com.bitchat.android.mesh.BluetoothMeshService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+class MeshForegroundService : Service() {
+
+ companion object {
+ private const val CHANNEL_ID = "bitchat_mesh_service"
+ private const val NOTIFICATION_ID = 10001
+
+ const val ACTION_START = "com.bitchat.android.service.START"
+ const val ACTION_STOP = "com.bitchat.android.service.STOP"
+ const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
+ const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
+ const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
+
+ fun start(context: Context) {
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
+
+ // On API >= 26, avoid background-service start restrictions by using startForegroundService
+ // only when we can actually post a notification (Android 13+ requires runtime notif permission)
+ val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true)
+ val hasNotifPerm = hasNotificationPermissionStatic(context)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (bgEnabled && hasNotifPerm) {
+ context.startForegroundService(intent)
+ } else {
+ // Do not attempt to start a background service from headless context without notif permission
+ // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException.
+ android.util.Log.i(
+ "MeshForegroundService",
+ "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)"
+ )
+ }
+ } else {
+ if (bgEnabled) {
+ context.startService(intent)
+ } else {
+ android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)")
+ }
+ }
+ }
+
+ /**
+ * Helper to be invoked right after POST_NOTIFICATIONS is granted to try
+ * promoting/starting the foreground service immediately without polling.
+ */
+ fun onNotificationPermissionGranted(context: Context) {
+ // If background is enabled and permission now granted, start/promo service
+ val hasNotifPerm = hasNotificationPermissionStatic(context)
+ if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return
+
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // Safe now that we can show a notification
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
+ context.startService(intent)
+ }
+
+ private fun shouldStartAsForeground(context: Context): Boolean {
+ return MeshServicePreferences.isBackgroundEnabled(true) &&
+ hasBluetoothPermissionsStatic(context) &&
+ hasNotificationPermissionStatic(context)
+ }
+
+ private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ fine || coarse
+ }
+ }
+
+ private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= 33) {
+ androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else true
+ }
+ }
+
+ private lateinit var notificationManager: NotificationManagerCompat
+ private var updateJob: Job? = null
+ private var meshService: BluetoothMeshService? = null
+ private val serviceJob = Job()
+ private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
+ private var isInForeground: Boolean = false
+ private var isShuttingDown: Boolean = false
+
+ override fun onCreate() {
+ super.onCreate()
+ notificationManager = NotificationManagerCompat.from(this)
+ createChannel()
+
+ // Adopt or create the mesh service
+ val existing = MeshServiceHolder.meshService
+ meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
+ if (existing != null) {
+ android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
+ } else {
+ android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
+ }
+ MeshServiceHolder.attach(meshService!!)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (isShuttingDown && intent?.action == ACTION_START) {
+ AppShutdownCoordinator.cancelPendingShutdown()
+ isShuttingDown = false
+ }
+ if (isShuttingDown && intent?.action != ACTION_QUIT) {
+ return START_NOT_STICKY
+ }
+ when (intent?.action) {
+ ACTION_STOP -> {
+ // Stop FGS and mesh cleanly
+ try { meshService?.stopServices() } catch (_: Exception) { }
+ try { MeshServiceHolder.clear() } catch (_: Exception) { }
+ try { stopForeground(true) } catch (_: Exception) { }
+ notificationManager.cancel(NOTIFICATION_ID)
+ isInForeground = false
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ ACTION_QUIT -> {
+ isShuttingDown = true
+ updateJob?.cancel()
+ updateJob = null
+ try { stopForeground(true) } catch (_: Exception) { }
+ notificationManager.cancel(NOTIFICATION_ID)
+ isInForeground = false
+ // Fully stop all background activity, stop Tor (without changing setting), then kill the app
+ AppShutdownCoordinator.requestFullShutdownAndKill(
+ app = application,
+ mesh = meshService,
+ notificationManager = notificationManager,
+ stopForeground = {
+ try { stopForeground(true) } catch (_: Exception) { }
+ isInForeground = false
+ },
+ stopService = { stopSelf() }
+ )
+ return START_NOT_STICKY
+ }
+ ACTION_UPDATE_NOTIFICATION -> {
+ // If we became eligible and are not in foreground yet, promote once
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
+ val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ startForeground(NOTIFICATION_ID, n)
+ isInForeground = true
+ } else {
+ updateNotification(force = true)
+ }
+ }
+ else -> { /* ACTION_START or null */ }
+ }
+
+ // Ensure mesh is running (only after permissions are granted)
+ ensureMeshStarted()
+
+ // Promote exactly once when eligible, otherwise stay background (or stop)
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
+ val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
+ startForeground(NOTIFICATION_ID, notification)
+ isInForeground = true
+ }
+
+ // Periodically refresh the notification with live network size
+ if (updateJob == null) {
+ updateJob = scope.launch {
+ while (isActive) {
+ // Retry enabling mesh/foreground once permissions become available
+ ensureMeshStarted()
+ val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
+ if (eligible) {
+ // Only update the notification; do not re-call startForeground()
+ updateNotification(force = false)
+ } else {
+ // If disabled or perms missing, ensure we are not in foreground and clear notif
+ if (isInForeground) {
+ try { stopForeground(false) } catch (_: Exception) { }
+ isInForeground = false
+ }
+ notificationManager.cancel(NOTIFICATION_ID)
+ }
+ delay(5000)
+ }
+ }
+ }
+
+ return START_STICKY
+ }
+
+ private fun ensureMeshStarted() {
+ if (isShuttingDown) return
+ if (!hasBluetoothPermissions()) return
+ try {
+ android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
+ meshService?.startServices()
+ } catch (e: Exception) {
+ android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
+ }
+ }
+
+ private fun updateNotification(force: Boolean) {
+ if (isShuttingDown) {
+ notificationManager.cancel(NOTIFICATION_ID)
+ return
+ }
+ val count = meshService?.getActivePeerCount() ?: 0
+ val notification = buildNotification(count)
+ if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ } else if (force) {
+ // If disabled and forced, make sure to remove any prior foreground state
+ try { stopForeground(false) } catch (_: Exception) { }
+ notificationManager.cancel(NOTIFICATION_ID)
+ isInForeground = false
+ }
+ }
+
+ private fun hasAllRequiredPermissions(): Boolean {
+ // For starting FGS with connectedDevice|dataSync, we need:
+ // - Foreground service permissions (declared in manifest)
+ // - One of the device-related permissions (we request BL perms at runtime)
+ // - On Android 13+, POST_NOTIFICATIONS to actually show notification
+ return hasBluetoothPermissions() && hasNotificationPermission()
+ }
+
+ private fun hasBluetoothPermissions(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ // Prior to S, scanning requires location permissions
+ val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ fine || coarse
+ }
+ }
+
+ private fun hasNotificationPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= 33) {
+ androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else true
+ }
+
+ private fun buildNotification(activeUsers: Int): Notification {
+ val openIntent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, openIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+
+ // Action: Quit Bitchat
+ val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT }
+ val quitPendingIntent = PendingIntent.getService(
+ this, 1, quitIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
+ )
+
+ val title = getString(R.string.app_name)
+ val content = getString(R.string.mesh_service_notification_content, activeUsers)
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setContentIntent(pendingIntent)
+ // Add an action button that appears when notification is expanded
+ .addAction(
+ android.R.drawable.ic_menu_close_clear_cancel,
+ getString(R.string.notification_action_quit_bitchat),
+ quitPendingIntent
+ )
+ .build()
+ }
+
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.mesh_service_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = getString(R.string.mesh_service_channel_desc)
+ setShowBadge(false)
+ }
+ (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
+ .createNotificationChannel(channel)
+ }
+ }
+
+ override fun onDestroy() {
+ updateJob?.cancel()
+ updateJob = null
+ // Cancel the service coroutine scope to prevent leaks
+ try { serviceJob.cancel() } catch (_: Exception) { }
+ // Best-effort ensure we are not marked foreground
+ if (isInForeground) {
+ try { stopForeground(true) } catch (_: Exception) { }
+ isInForeground = false
+ }
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
new file mode 100644
index 00000000..d271ab29
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt
@@ -0,0 +1,60 @@
+package com.bitchat.android.service
+
+import android.content.Context
+import com.bitchat.android.mesh.BluetoothMeshService
+
+/**
+ * Process-wide holder to share a single BluetoothMeshService instance
+ * between the foreground service and UI (MainActivity/ViewModels).
+ */
+object MeshServiceHolder {
+ private const val TAG = "MeshServiceHolder"
+ @Volatile
+ var meshService: BluetoothMeshService? = null
+ private set
+
+ @Synchronized
+ fun getOrCreate(context: Context): BluetoothMeshService {
+ val existing = meshService
+ if (existing != null) {
+ // If the existing instance is healthy, reuse it; otherwise, replace it.
+ return try {
+ if (existing.isReusable()) {
+ android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance")
+ existing
+ } else {
+ android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance")
+ // Best-effort stop before replacing
+ try { existing.stopServices() } catch (e: Exception) {
+ android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}")
+ }
+ val created = BluetoothMeshService(context.applicationContext)
+ android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
+ meshService = created
+ created
+ }
+ } catch (e: Exception) {
+ android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
+ val created = BluetoothMeshService(context.applicationContext)
+ meshService = created
+ created
+ }
+ }
+ val created = BluetoothMeshService(context.applicationContext)
+ android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
+ meshService = created
+ return created
+ }
+
+ @Synchronized
+ fun attach(service: BluetoothMeshService) {
+ android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
+ meshService = service
+ }
+
+ @Synchronized
+ fun clear() {
+ android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
+ meshService = null
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt
new file mode 100644
index 00000000..47335ee3
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt
@@ -0,0 +1,32 @@
+package com.bitchat.android.service
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object MeshServicePreferences {
+ private const val PREFS_NAME = "bitchat_mesh_service_prefs"
+ private const val KEY_AUTO_START = "auto_start_on_boot"
+ private const val KEY_BACKGROUND_ENABLED = "background_enabled"
+
+ private lateinit var prefs: SharedPreferences
+
+ fun init(context: Context) {
+ prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun isAutoStartEnabled(default: Boolean = true): Boolean {
+ return prefs.getBoolean(KEY_AUTO_START, default)
+ }
+
+ fun setAutoStartEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply()
+ }
+
+ fun isBackgroundEnabled(default: Boolean = true): Boolean {
+ return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default)
+ }
+
+ fun setBackgroundEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
new file mode 100644
index 00000000..07f146bd
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -0,0 +1,109 @@
+package com.bitchat.android.services
+
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Process-wide in-memory state store that survives Activity recreation.
+ * The foreground Mesh service updates this store; UI subscribes/hydrates from it.
+ */
+object AppStateStore {
+ // Global de-dup set by message id to avoid duplicate keys in Compose lists
+ private val seenMessageIds = mutableSetOf()
+ // Connected peer IDs (mesh ephemeral IDs)
+ private val _peers = MutableStateFlow>(emptyList())
+ val peers: StateFlow> = _peers.asStateFlow()
+
+ // Public mesh timeline messages (non-channel)
+ private val _publicMessages = MutableStateFlow>(emptyList())
+ val publicMessages: StateFlow> = _publicMessages.asStateFlow()
+
+ // Private messages by peerID
+ private val _privateMessages = MutableStateFlow