Fix wifi aware socket binding 2 (#536)

* Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design

* bump version 1.6.0 (#524)

* Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025

* bump targetSdk (#526)

* Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025

* Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025

* Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalue <>
This commit is contained in:
callebtc
2026-01-03 23:39:54 +07:00
committed by GitHub
co-authored by GitHub Action aidenvalue <>
parent ecc7e93930
commit 463600a6c3
28 changed files with 2627 additions and 932 deletions
+2 -2
View File
@@ -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 {
-11
View File
@@ -243,17 +243,6 @@
column="35"/>
</issue>
<issue
id="OldTargetApi"
message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the android.os.Build.VERSION_CODES javadoc for details."
errorLine1=" targetSdk = 34"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="14"
column="9"/>
</issue>
<issue
id="RedundantLabel"
message="Redundant label can be removed"
+42
View File
@@ -26,6 +26,15 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Connected device foreground service type for BLE operations (API 34+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -48,6 +57,10 @@
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application
android:name=".BitchatApplication"
android:allowBackup="false"
@@ -86,5 +99,34 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Persistent foreground service to run the mesh in background -->
<service
android:name=".service.MeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync"
tools:ignore="DataExtractionRules">
</service>
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
<receiver
android:name=".service.NotificationPermissionChangedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
</intent-filter>
</receiver>
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
+287 -262
View File
@@ -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
1 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
2 strfry.shock.network 39.0438 -77.4874
3 relay.orangepill.ovh wot.nostr.party 49.1689 36.1627 -0.358841 -86.7816
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
4 nostr.blankfors.se 60.1699 24.9384
5 strfry.openhoofd.nl nostr.now 51.9229 36.55 4.40833 139.733
6 relay.magiccity.live nostr.simplex.icu 25.8128 51.5121 -80.2377 -0.0005238
7 relay.goodmorningbitcoin.com relay.threenine.services 43.6532 51.5524 -79.3832 -0.29686
8 nostr.notribe.net relay.barine.co 40.8302 43.6532 -74.1299 -79.3832
9 slick.mjex.me theoutpost.life 39.048 64.1476 -77.4817 -21.9392
10 nostr.davidebtc.me purplerelay.com 51.5072 50.1109 -0.127586 8.68213
11 alien.macneilmediagroup.com nostrue.com 43.6532 40.8054 -79.3832 -74.0241
12 pyramid.treegaze.com relay-arg.zombi.cloudrodion.com 43.6532 1.35208 -79.3832 103.82
13 shu02.shugur.net 21.4902 39.2246
14 relayone.geektank.ai 18.2148 -63.0574
15 relay03.lnfi.network 39.0997 -94.5786
16 relay.evanverma.com soloco.nl 40.8302 43.6532 -74.1299 -79.3832
17 nostr-rs-relay-ishosta.phamthanh.me relay-testnet.k8s.layer3.news 43.6532 37.3387 -79.3832 -121.885
18 nostr.chaima.info nostr.azzamo.net 51.223 52.2633 6.78245 21.0283
19 no.str.cr nostr.red5d.dev 9.92857 43.6532 -84.0528 -79.3832
20 relay.jabato.space nostr.thebiglake.org 52.52 32.71 13.405 -96.6745
21 adre.su nostr.tadryanom.me 59.9311 43.6532 30.3609 -79.3832
relay.hasenpfeffr.com 39.0438 -77.4874
relay.etch.social 41.2619 -95.8608
22 schnorr.me 43.6532 -79.3832
23 relay.endfiat.money ithurtswhenip.ee 43.6532 51.223 -79.3832 6.78245
relay.nostr.place 32.7767 -96.797
nostr.sathoarder.com 48.5734 7.75211
24 orly.ft.hn 50.4754 12.3683
25 relay02.lnfi.network 39.0997 -94.5786
26 relay.nosto.re 51.1792 5.89444
27 strfry.openhoofd.nl 51.9229 4.40833
28 relays.diggoo.com 43.6532 -79.3832
29 relay.nostrhub.tech 49.0291 8.35696
30 relay.artx.market 43.652 -79.3633
31 relay.sigit.io 50.4754 12.3683
32 nostr-relay.psfoundation.info 39.0438 -77.4874
33 wot.brightbolt.net 47.6735 -116.781
34 relay.bitcoinartclock.com 50.4754 12.3683
35 khatru.nostrver.se 51.1792 5.89444
36 relay.cosmicbolt.net 37.3986 -121.964
37 adre.su 59.9311 30.3609
38 nostrelay.memory-art.xyz 43.6532 -79.3832
39 temp.iris.to 43.6532 -79.3832
40 nproxy.kristapsk.lv 60.1699 24.9384
41 relay.ngengine.org 43.6532 -79.3832
42 relay.puresignal.news 43.6532 -79.3832
43 nostr.stakey.net 52.3676 4.90414
44 nostr.commonshub.brussels 49.4543 11.0746
45 wot.shaving.kiwi 43.6532 -79.3832
46 espelho.girino.org 43.6532 -79.3832
47 relay.thibautduchene.fr 43.6532 -79.3832
48 nostr-relay.corb.net 38.8353 -104.822
49 wot.soundhsa.com 33.1384 -95.6011
50 relay.jabato.space 52.52 13.405
51 relay.credenso.cafe 43.3601 -80.3127
52 relayone.soundhsa.com 33.1384 -95.6011
53 relay.bitcoinveneto.org 64.1466 -21.9426
54 relay.samt.st 40.8302 -74.1299
55 relay.vrtmrz.net 43.6532 -79.3832
56 nostr.data.haus 50.4754 12.3683
57 relay.npubhaus.com 43.6532 -79.3832
58 strfry.elswa-dev.online 50.1109 8.68213
59 relay.wavefunc.live 34.0362 -118.443
60 wot.sovbit.host 64.1466 -21.9426
61 relay.smies.me 33.7501 -84.3885
62 nostr.bilthon.dev 25.8128 -80.2377
63 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
64 santo.iguanatech.net 40.8302 -74.1299
65 prl.plus 42.6978 23.3246
66 relay-freeharmonypeople.space 38.7223 -9.13934
67 nostream.breadslice.com 1.35208 103.82
68 nostr.notribe.net 40.8302 -74.1299
69 relay.fountain.fm 39.0997 -94.5786
70 orangepiller.org 60.1699 24.9384
71 nostr.21crypto.ch 47.5356 8.73209
72 nostr.camalolo.com 24.1469 120.684
73 okn.czas.top 51.267 6.81738
74 relay.wolfcoil.com 35.6092 139.73
75 nostr.quali.chat 60.1699 24.9384
76 nostr-relay.online 43.6532 -79.3832
77 alienos.libretechsystems.xyz 55.4724 9.87335
78 ribo.eu.nostria.app 52.3676 4.90414
79 wot.yesnostr.net 50.9871 2.12554
80 nostr.overmind.lol 43.6532 -79.3832
81 relay.chakany.systems 43.6532 -79.3832
82 relay.hasenpfeffr.com 39.0438 -77.4874
83 nostrelites.org 41.8781 -87.6298
84 strfry.bonsai.com 37.8715 -122.273
85 relay.agora.social 50.7383 15.0648
86 alien.macneilmediagroup.com 43.6532 -79.3832
87 relay.getsafebox.app 43.6532 -79.3832
88 relay.nuts.cash 34.0362 -118.443
89 notemine.io 52.2026 20.9397
90 relay.nostr.place 32.7767 -96.797
91 relay.21e6.cz 50.7383 15.0648
92 nostr.casa21.space 43.6532 -79.3832
93 premium.primal.net 43.6532 -79.3832
94 relay5.bitransfer.org 43.6532 -79.3832
95 nostr.rblb.it 43.7094 10.6582
96 relay.goodmorningbitcoin.com 43.6532 -79.3832
97 relay.camelus.app 45.5201 -122.99
98 relay.origin.land 35.6673 139.751
99 relay.fr13nd5.com 52.5233 13.3426
100 nos.lol 50.4754 12.3683
101 wot.nostr.net 43.6532 -79.3832
102 relay.javi.space 43.4633 11.8796
103 relay2.ngengine.org 43.6532 -79.3832
104 relay.angor.io 48.1046 11.6002
105 pyramid.treegaze.com 43.6532 -79.3832
106 relay.letsfo.com 52.2633 21.0283
107 relay.nostrzh.org 43.6532 -79.3832
108 nostr.hekster.org 37.3986 -121.964
109 relay.0xchat.com 1.35208 103.82
110 nostr.czas.top 50.1109 8.68213
111 nostr.superfriends.online 43.6532 -79.3832
112 hsuite-nostr-relay.hbarsuite.workers.dev 43.6532 -79.3832
113 relay.mccormick.cx 52.3563 4.95714
114 nostr.88mph.life 51.5072 -0.127586
115 strfry.ymir.cloud 34.0965 -117.585
116 nostr.lkjsxc.com 43.6532 -79.3832
117 yabu.me 35.6092 139.73
118 relay.chorus.community 50.1109 8.68213
119 nostr-02.yakihonne.com 1.32123 103.695
120 neuromancer.nettek.io 39.1429 -94.573
121 relay04.lnfi.network 39.0997 -94.5786
122 nostr-relay.amethyst.name 39.0438 -77.4874
123 nostrcheck.tnsor.network 43.6532 -79.3832
124 wot.nostr.place 32.7767 -96.797
125 cyberspace.nostr1.com 40.7057 -74.0136
126 relay.guggero.org 47.3769 8.54169
127 nostr.calitabby.net 39.9268 -75.0246
128 x.kojira.io 43.6532 -79.3832
129 nostr.agentcampfire.com 52.3676 4.90414
130 relay.bullishbounty.com 43.6532 -79.3832
131 relay.nostrverse.net 43.6532 -79.3832
132 nostr.robosats.org 64.1476 -21.9392
133 nostr-verified.wellorder.net 45.5201 -122.99
134 nostr.huszonegy.world 47.4979 19.0402
135 relay.cypherflow.ai 48.8566 2.35222
136 nostr-relay.xbytez.io 50.6924 3.20113
137 nostr.faultables.net 43.6532 -79.3832
138 relay.libernet.app 43.6532 -79.3832
139 relay.magiccity.live 25.8128 -80.2377
140 relay.wellorder.net 45.5201 -122.99
141 black.nostrcity.club 48.8575 2.35138
142 relay.jeffg.fyi 43.6532 -79.3832
143 freeben666.fr 43.7221 7.15296
144 relay.nostr.wirednet.jp 34.706 135.493
145 nostrelay.circum.space 52.3676 4.90414
146 relay.islandbitcoin.com 12.8498 77.6545
147 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
148 relay.nostriot.com 41.5695 -83.9786
149 relayrs.notoshi.win 43.6532 -79.3832
150 nostr-pub.wellorder.net 45.5201 -122.99
151 nostr.vulpem.com 49.4543 11.0746
152 nostr.sathoarder.com 48.5734 7.75211
153 wheat.happytavern.co 43.6532 -79.3832
154 relay.nostr.net 43.6532 -79.3832
155 bcast.girino.org 43.6532 -79.3832
156 nostr-02.czas.top 51.2277 6.77346
157 vault.iris.to 43.6532 -79.3832
158 ynostr.yael.at 60.1699 24.9384
159 nostr.nodesmap.com 59.3327 18.0656
160 nostr.n7ekb.net 47.4941 -122.294
161 relayb.uid.ovh 43.6532 -79.3832
162 shu05.shugur.net 48.8566 2.35222
163 dev-relay.lnfi.network 39.0997 -94.5786
164 relay.bitcoindistrict.org 43.6532 -79.3832
165 nostr.spicyz.io 43.6532 -79.3832
166 nostr.0x7e.xyz 47.4988 8.72369
167 relay.dwadziesciajeden.pl 52.2297 21.0122
168 relay.zone667.com 60.1699 24.9384
169 nostr-dev.wellorder.net 45.5201 -122.99
170 nos.xmark.cc 50.6924 3.20113
171 relay.etch.social 41.2619 -95.8608
172 nostr.na.social 43.6532 -79.3832
173 relay.orangepill.ovh 49.1689 -0.358841
174 relay.olas.app 50.4754 12.3683
175 relay.holzeis.me 43.6532 -79.3832
176 relay2.angor.io 48.1046 11.6002
177 relay.degmods.com 50.4754 12.3683
178 vitor.nostr1.com 40.7128 -74.006
179 relay.btcforplebs.com 43.6532 -79.3832
180 nostr.luisschwab.net 43.6532 -79.3832
181 relay.moinsen.com 50.4754 12.3683
182 czas.xyz 48.8566 2.35222
183 nostr.bitcoiner.social 39.1585 -94.5728
184 nostr.mehdibekhtaoui.com 49.4939 -1.54813
185 inbox.azzamo.net 52.2633 21.0283
186 nostr.ovia.to 43.6532 -79.3832
187 nostr.myshosholoza.co.za 52.3676 4.90414
188 fenrir-s.notoshi.win 43.6532 -79.3832
189 relay-rpi.edufeed.org 49.4521 11.0767
190 chat-relay.zap-work.com 43.6532 -79.3832
191 nostr.bond 50.1109 8.68213
192 relay01.lnfi.network 39.0997 -94.5786
193 relay.seq1.net 43.6532 -79.3832
194 nostr.rikmeijer.nl 50.4754 12.3683
195 offchain.pub 47.6743 -117.112
196 nostr.spaceshell.xyz 43.6532 -79.3832
197 nos4smartnkind.tech 40.1872 44.5152
198 kotukonostr.onrender.com 37.7775 -122.397
199 nostr.ps1829.com 33.8851 130.883
200 relay.lightning.pub 39.0438 -77.4874
201 nostr-relay.gateway.in.th 15.2634 100.344
202 relay.thebluepulse.com 49.4521 11.0767
203 relay.malxte.de 52.52 13.405
204 relay.usefusion.ai 38.7134 -78.1591
205 nostr-relay.cbrx.io 43.6532 -79.3832
206 shu01.shugur.net 21.4902 39.2246
207 nostr.jerrynya.fun 31.2304 121.474
208 dev-nostr.bityacht.io 25.0797 121.234
209 bitsat.molonlabe.holdings 51.4012 -1.3147
210 relay.primal.net 43.6532 -79.3832
211 relay.wavlake.com 41.2619 -95.8608
212 nostr-relay.nextblockvending.com 47.2343 -119.853
213 v-relay.d02.vrtmrz.net 34.6937 135.502
214 nostr.mikoshi.de 47.74 12.0917
215 nostr.coincards.com 53.5501 -113.469
216 relay.snort.social 53.3498 -6.26031
217 bitcoiner.social 39.1585 -94.5728
218 nostr.noones.com 50.1109 8.68213
219 wot.dergigi.com 64.1476 -21.9392
220 relay.lumina.rocks 49.0291 8.35695
221 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
222 nostr.snowbla.de 60.1699 24.9384
223 strfry.felixzieger.de 50.1013 8.62643
224 relay.satlantis.io 32.8769 -80.0114
225 relay-dev.satlantis.io 40.8302 -74.1299
226 nostr.davidebtc.me 51.5072 -0.127586
227 relay.mattybs.lol 43.6532 -79.3832
228 relay.fundstr.me 42.3601 -71.0589
229 relay.tagayasu.xyz 43.6715 -79.38
230 nostr-relay.zimage.com 34.0549 -118.243
231 nostrcheck.me 43.6532 -79.3832
232 bucket.coracle.social 37.7775 -122.397
233 relay.siamdev.cc 13.9178 100.424
234 r.bitcoinhold.net 43.6532 -79.3832
235 skeme.vanderwarker.family 40.8218 -74.45
236 articles.layer3.news 37.3387 -121.885
237 no.str.cr 9.92857 -84.0528
238 srtrelay.c-stellar.net 43.6532 -79.3832
239 relay.comcomponent.com 34.7062 135.493
240 relay.illuminodes.com 47.6061 -122.333
241 nostr.openhoofd.nl 51.9229 4.40833
242 nostr.plantroon.com 50.1013 8.62643
243 slick.mjex.me 39.048 -77.4817
244 relay.minibolt.info 43.6532 -79.3832
245 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
246 relay.nostrdice.com -33.8688 151.209
247 wot.dtonon.com 43.6532 -79.3832
248 shu04.shugur.net 25.2604 55.2989
249 nostr.tavux.tech 48.8575 2.35138
250 nostr-02.uid.ovh 43.6532 -79.3832
251 relay.nostrcheck.me 43.6532 -79.3832
252 relay.nsnip.io 60.1699 24.9384
253 relay.toastr.net 40.8054 -74.0241
254 relay.divine.video 43.6532 -79.3832
255 nostr.4rs.nl 49.0291 8.35696
256 wot.sebastix.social 51.1792 5.89444
257 relay.openfarmtools.org 60.1699 24.9384
258 relay.damus.io 43.6532 -79.3832
259 purpura.cloud 43.6532 -79.3832
260 wot.sudocarlos.com 51.5072 -0.127586
261 relay.arx-ccn.com 50.4754 12.3683
262 nostr.zoracle.org 45.6018 -121.185
263 nostr.tac.lol 47.4748 -122.273
264 relay.coinos.io 43.6532 -79.3832
265 nostr.rtvslawenia.com 49.4543 11.0746
266 relay.davidebtc.me 51.5072 -0.127586
267 ribo.us.nostria.app 41.5868 -93.625
268 relay.upleb.uk 51.9194 19.1451
269 librerelay.aaroniumii.com 43.6532 -79.3832
270 relay.endfiat.money 43.6532 -79.3832
271 relay.nostar.org 43.6532 -79.3832
272 relay.electriclifestyle.com 26.2897 -80.1293
273 relay.nostrhub.fr 48.1045 11.6004
274 relay.agorist.space 52.3734 4.89406
275 freelay.sovbit.host 64.1476 -21.9392
276 nostr.hifish.org 47.4043 8.57398
277 nostr-01.yakihonne.com 1.32123 103.695
278 relay.routstr.com 43.6532 -79.3832
279 nr.yay.so 46.2126 6.1154
280 bcast.seutoba.com.br 43.6532 -79.3832
281 fanfares.nostr1.com 40.7128 -74.006
282 nostr.girino.org 43.6532 -79.3832
283 nostr2.girino.org 43.6532 -79.3832
284 relay.notoshi.win 13.311 101.112
285 nostrja-kari.heguro.com 43.6532 -79.3832
286 relay.mostro.network 40.8302 -74.1299
287 satsage.xyz 37.3986 -121.964
288 relay.evanverma.com 40.8302 -74.1299
289 nostr-2.21crypto.ch 47.5356 8.73209
290 relay.mitchelltribe.com 39.0438 -77.4874
291 relaynostr.breadslice.com 43.6532 -79.3832
292 nostr.chaima.info 51.223 6.78245
293 nostr.mom 50.4754 12.3683
294 relay.nostr-check.me 43.6532 -79.3832
295 relay.ditto.pub 43.6532 -79.3832
@@ -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
}
@@ -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 WiFi Aware controller for cross-transport relays
try { com.bitchat.android.wifiaware.WifiAwareController.setBleMeshService(meshService) } catch (_: Exception) { }
bluetoothStatusManager = BluetoothStatusManager(
@@ -131,6 +170,13 @@ class MainActivity : OrientationAwareActivity() {
if (running && svc != null) {
svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate {
override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) {
if (message.isPrivate) {
message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) }
} else if (message.channel != null) {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message)
} else {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
chatViewModel.didReceiveMessage(message)
}
override fun didUpdatePeerList(peers: List<String>) {
@@ -678,6 +724,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 +745,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 +772,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 +847,8 @@ class MainActivity : OrientationAwareActivity() {
override fun onDestroy() {
super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager
try {
locationStatusManager.cleanup()
@@ -794,14 +857,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
}
}
@@ -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
@@ -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()
@@ -85,6 +94,9 @@ class BluetoothMeshService(private val context: Context) {
} catch (_: Exception) { 0.01 }
}
)
// Register as shared instance for Wi-Fi Aware transport
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
// Wire sync manager delegate
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
@@ -98,6 +110,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +118,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 +130,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 +139,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 +148,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 +156,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 +164,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
// 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 +186,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 +375,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 +529,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 +560,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 +599,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 +615,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 +637,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 +652,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 +889,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 +900,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 +934,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 +950,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 +999,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 +1098,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
*/
@@ -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
@@ -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
@@ -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
}
}
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -0,0 +1,66 @@
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 sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null
private set
fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr }
@Volatile
var meshService: BluetoothMeshService? = null
private set
@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
}
}
@@ -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()
}
}
@@ -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<String>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
// Public mesh timeline messages (non-channel)
private val _publicMessages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val publicMessages: StateFlow<List<BitchatMessage>> = _publicMessages.asStateFlow()
// Private messages by peerID
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
// Channel messages by channel name
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) {
_peers.value = ids
}
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
_publicMessages.value = _publicMessages.value + msg
}
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
}
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0
}
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
val map = _privateMessages.value.toMutableMap()
var changed = false
map.keys.toList().forEach { peer ->
val list = map[peer]?.toMutableList() ?: mutableListOf()
val idx = list.indexOfFirst { it.id == messageID }
if (idx >= 0) {
val current = list[idx].deliveryStatus
// Do not downgrade (e.g., Read -> Delivered)
if (statusPriority(status) >= statusPriority(current)) {
list[idx] = list[idx].copy(deliveryStatus = status)
map[peer] = list
changed = true
}
}
}
if (changed) {
_privateMessages.value = map
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _channelMessages.value.toMutableMap()
val list = (map[channel] ?: emptyList()) + msg
map[channel] = list
_channelMessages.value = map
}
}
// Clear all in-memory state (used for full app shutdown)
fun clear() {
synchronized(this) {
seenMessageIds.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
}
}
}
@@ -0,0 +1,21 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.ui.DataManager
/**
* Provides current user's nickname for announcements and leave messages.
* If no nickname saved, falls back to the provided peerID.
*/
object NicknameProvider {
fun getNickname(context: Context, myPeerID: String): String {
return try {
val dm = DataManager(context.applicationContext)
val nick = dm.loadNickname()
if (nick.isNullOrBlank()) myPeerID else nick
} catch (_: Exception) {
myPeerID
}
}
}
File diff suppressed because it is too large Load Diff
@@ -143,6 +143,41 @@ class ChatViewModel(
init {
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
state.setMessages(msgs)
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
// Replace with store snapshot
state.setPrivateChats(byPeer)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: meshService.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
} catch (_: Exception) { }
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
// Replace with store snapshot
state.setChannelMessages(byChannel)
} } catch (_: Exception) { }
}
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
viewModelScope.launch {
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
@@ -619,8 +654,8 @@ class ChatViewModel(
// Merge nicknames from BLE and WiFi Aware to display names for all peers
val bleNick = meshService.getPeerNicknames()
val awareNick = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknames() } catch (_: Exception) { null }
val mergedNick = if (awareNick != null) bleNick + awareNick.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick
val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null }
val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick
state.setPeerNicknames(mergedNick)
val rssiValues = meshService.getPeerRSSI()
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
if (message.isPrivate) {
// Private message
privateChatManager.handleIncomingPrivateMessage(message)
// Reactive read receipts: Send immediately if user is currently viewing this chat
// Reactive read receipts: if chat is focused, send immediately for this message
message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(senderPeerID)
sendReadReceiptIfFocused(message)
}
// Show notification with enhanced information - now includes senderPeerID
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
)
}
} else if (message.channel != null) {
// Channel message
// Channel message: AppStateStore is the source of truth for list; only manage unread
if (state.getJoinedChannelsValue().contains(message.channel)) {
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
val channel = message.channel
val viewingClassic = state.getCurrentChannelValue() == channel
val viewingGeohash = try {
if (channel.startsWith("geo:")) {
val geo = channel.removePrefix("geo:")
val selected = state.selectedLocationChannel.value
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
} else false
} catch (_: Exception) { false }
if (!viewingClassic && !viewingGeohash) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
}
}
} else {
// Public mesh message - always store to preserve message history
messageManager.addMessage(message)
// Check for mentions in mesh chat
// Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
// Still run mention detection/notifications
checkAndTriggerMeshMentionNotification(message)
}
@@ -266,21 +277,31 @@ class MeshDelegateHandler(
* Uses same logic as notification system - send read receipt if user is currently
* viewing the private chat with this sender AND app is in foreground.
*/
private fun sendReadReceiptIfFocused(senderPeerID: String) {
private fun sendReadReceiptIfFocused(message: BitchatMessage) {
// Get notification manager's focus state (mirror the notification logic)
val isAppInBackground = notificationManager.getAppBackgroundState()
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
// Send read receipt if user is currently focused on this specific chat
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID
val senderPeerID = message.senderPeerID
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID")
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService())
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
val nickname = state.getNicknameValue() ?: "unknown"
// Send directly for this message to avoid relying on unread queues
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
if (current.remove(senderPeerID)) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
}
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message)
state.setMessages(currentMessages)
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
}
// Log a system message into the main chat (visible to user)
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
channelMessageList.add(message)
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
// Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
}
fun clearPrivateMessages(peerID: String) {
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
// MARK: - Delivery Status Updates
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
}
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
return if (statusPriority(new) >= statusPriority(old)) new else old
}
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
// Update in private chats
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
val updatedMessages = messages.toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
updatedPrivateChats[peerID] = updatedMessages
updated = true
val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
updatedPrivateChats[peerID] = updatedMessages
updated = true
}
}
}
if (updated) {
state.setPrivateChats(updatedPrivateChats)
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
}
// Update in main messages
val updatedMessages = state.getMessagesValue().toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
state.setMessages(updatedMessages)
val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
state.setMessages(updatedMessages)
}
}
// Update in channel messages
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
val channelMessagesList = messages.toMutableList()
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
if (channelMessageIndex >= 0) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
updatedChannelMessages[channel] = channelMessagesList
val current = channelMessagesList[channelMessageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
updatedChannelMessages[channel] = channelMessagesList
}
}
}
state.setChannelMessages(updatedChannelMessages)
@@ -292,28 +292,30 @@ class PrivateChatManager(
}
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
message.senderPeerID?.let { senderPeerID ->
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) {
// Add to private messages
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
}
// Track as unread for read receipt purposes
var unreadCount = 0
if (!suppressUnread) {
// Ensure chat exists
messageManager.initializePrivateChat(senderPeerID)
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message)
unreadCount = unreadList.size
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(senderPeerID)
state.setUnreadPrivateMessages(currentUnread)
}
Log.d(
TAG,
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
)
}
return
}
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
} else {
messageManager.addPrivateMessage(inferredPeer, message)
}
}
@@ -322,27 +324,33 @@ class PrivateChatManager(
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
val unreadList = unreadReceivedMessages[peerID]
if (unreadList.isNullOrEmpty()) {
Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
return
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
}
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
// Send read receipt for each unread message - now using direct method call
unreadList.forEach { message ->
try {
val myNickname = state.getNicknameValue() ?: "unknown"
meshService.sendReadReceipt(message.id, peerID, myNickname)
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
val myNickname = state.getNicknameValue() ?: "unknown"
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
if (msg.senderPeerID == peerID) {
try {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
sentCount += 1
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
}
}
}
// Clear the unread list since we've sent read receipts
// Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(peerID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
}
fun cleanupDisconnectedPeer(peerID: String) {
@@ -43,9 +43,15 @@ class DebugSettingsManager private constructor() {
private val _wifiAwareEnabled = MutableStateFlow(false)
val wifiAwareEnabled: StateFlow<Boolean> = _wifiAwareEnabled.asStateFlow()
// Master transport toggles
private val _wifiAwareVerbose = MutableStateFlow(false)
val wifiAwareVerbose: StateFlow<Boolean> = _wifiAwareVerbose.asStateFlow()
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
// Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
@@ -89,12 +95,63 @@ class DebugSettingsManager private constructor() {
// Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
// Per-device and per-peer rolling timestamps for stacked graphs
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Additional buckets to split incoming vs outgoing
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Expose current per-second rates (updated when logging/pruning occurs)
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
// New flows used by UI for incoming/outgoing stacked plots
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
// Per-minute counts per key
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
// Totals per key (since app start)
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
// Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
private fun updateRelayStatsFromTimestamps() {
if (!_debugSheetVisible.value) return
val now = System.currentTimeMillis()
// prune older than 15m
while (true) {
@@ -103,18 +160,84 @@ class DebugSettingsManager private constructor() {
relayTimestamps.poll()
} else break
}
// prune per-device and per-peer and compute 1s/60s rates
fun pruneAndCount1s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
val iterator = map.entries.iterator()
while (iterator.hasNext()) {
val (key, q) = iterator.next()
// prune this queue
while (true) {
val ts = q.peek() ?: break
if (now - ts > 15 * 60 * 1000L) {
q.poll()
} else break
}
// count last 1s only
val count1s = q.count { now - it <= 1_000L }
if (q.isEmpty()) {
// cleanup empty queues to prevent unbounded growth
iterator.remove()
}
if (count1s > 0) result[key] = count1s
}
return result
}
fun pruneAndCount60s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
map.forEach { (key, q) ->
val count60 = q.count { now - it <= 60_000L }
if (count60 > 0) result[key] = count60
}
return result
}
val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps)
val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps)
_perDeviceLastSecond.value = perDevice1s
_perPeerLastSecond.value = perPeer1s
// Also compute incoming/outgoing per-key rates
_perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming)
_perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing)
_perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming)
_perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing)
_perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming)
_perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing)
_perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming)
_perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing)
val last1s = relayTimestamps.count { now - it <= 1_000L }
val last10s = relayTimestamps.count { now - it <= 10_000L }
val last1m = relayTimestamps.count { now - it <= 60_000L }
val last15m = relayTimestamps.size
val total = _relayStats.value.totalRelaysCount + 1
// And incoming/outgoing per-second counters
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
val last15mIncoming = incomingTimestamps.size
val last15mOutgoing = outgoingTimestamps.size
val totalIncoming = _relayStats.value.totalIncomingCount
val totalOutgoing = _relayStats.value.totalOutgoingCount
_relayStats.value = PacketRelayStats(
totalRelaysCount = total,
totalRelaysCount = totalIncoming + totalOutgoing,
lastSecondRelays = last1s,
last10SecondRelays = last10s,
lastMinuteRelays = last1m,
last15MinuteRelays = last15m,
lastResetTime = _relayStats.value.lastResetTime
lastResetTime = _relayStats.value.lastResetTime,
lastSecondIncoming = last1sIncoming,
lastSecondOutgoing = last1sOutgoing,
last10SecondIncoming = last10sIncoming,
last10SecondOutgoing = last10sOutgoing,
lastMinuteIncoming = last1mIncoming,
lastMinuteOutgoing = last1mOutgoing,
last15MinuteIncoming = last15mIncoming,
last15MinuteOutgoing = last15mOutgoing,
totalIncomingCount = totalIncoming,
totalOutgoingCount = totalOutgoing
)
}
@@ -381,11 +504,61 @@ class DebugSettingsManager private constructor() {
}
}
// Update rolling statistics only for relays
if (isRelay) {
relayTimestamps.offer(System.currentTimeMillis())
updateRelayStatsFromTimestamps()
// Do not update counters here; this path is for readable logs only.
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) incomingTimestamps.offer(now)
fromDeviceAddress?.let {
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
}
fromPeerID?.let {
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
}
// bump totals
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalIncomingCount = cur.totalIncomingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) outgoingTimestamps.offer(now)
toDeviceAddress?.let {
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
}
(toPeerID ?: previousHopPeerID)?.let {
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
}
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalOutgoingCount = cur.totalOutgoingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
}
// MARK: - Clear Data
@@ -452,5 +625,15 @@ data class PacketRelayStats(
val last10SecondRelays: Int = 0,
val lastMinuteRelays: Int = 0,
val last15MinuteRelays: Int = 0,
val lastResetTime: Date = Date()
val lastResetTime: Date = Date(),
val lastSecondIncoming: Int = 0,
val lastSecondOutgoing: Int = 0,
val last10SecondIncoming: Int = 0,
val last10SecondOutgoing: Int = 0,
val lastMinuteIncoming: Int = 0,
val lastMinuteOutgoing: Int = 0,
val last15MinuteIncoming: Int = 0,
val last15MinuteOutgoing: Int = 0,
val totalIncomingCount: Long = 0,
val totalOutgoingCount: Long = 0
)
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi
@@ -17,6 +19,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -29,8 +32,13 @@ import com.bitchat.android.mesh.BluetoothMeshService
import kotlinx.coroutines.launch
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.service.MeshServicePreferences
import com.bitchat.android.service.MeshForegroundService
@OptIn(ExperimentalMaterial3Api::class)
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DebugSettingsSheet(
isPresented: Boolean,
@@ -44,7 +52,7 @@ fun DebugSettingsSheet(
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
val gattClientEnabled by manager.gattClientEnabled.collectAsState()
val packetRelayEnabled by manager.packetRelayEnabled.collectAsState()
val packetRelayed by manager.packetRelayEnabled.collectAsState()
val maxOverall by manager.maxConnectionsOverall.collectAsState()
val maxServer by manager.maxServerConnections.collectAsState()
val maxClient by manager.maxClientConnections.collectAsState()
@@ -55,11 +63,14 @@ fun DebugSettingsSheet(
val seenCapacity by manager.seenPacketCapacity.collectAsState()
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
// Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) {
@@ -105,6 +116,11 @@ fun DebugSettingsSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
// Mark debug sheet visible/invisible to gate heavy work
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
DisposableEffect(Unit) {
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@@ -258,88 +274,256 @@ fun DebugSettingsSheet(
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
var series by remember { mutableStateOf(List(60) { 0f }) }
LaunchedEffect(isPresented) {
// Removed aggregate labels; we will show per-direction compact labels below titles
// Toggle: overall vs per-connection vs per-peer
var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
val nicknameMap = remember { mutableStateOf<Map<String, String?>>(emptyMap()) }
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(Unit) {
try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { }
// Try to fetch device->peer map periodically for legend resolution
while (isPresented) {
val s = relayStats.lastSecondRelays.toFloat()
val last = series.lastOrNull() ?: 0f
// Faster decay and smoothing
val v = last * 0.5f + s * 0.5f
series = (series + v).takeLast(60)
kotlinx.coroutines.delay(400)
try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
kotlinx.coroutines.delay(1000)
}
}
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
// Graph canvas
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = leftGutter.toPx() // reserved left gutter for labels
val barCount = series.size
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// Baseline at bottom (y = 0)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Mode selector
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = graphMode == GraphMode.OVERALL,
onClick = { graphMode = GraphMode.OVERALL },
label = { Text("Overall") }
)
// Bars from bottom-up; skip zeros entirely
series.forEachIndexed { i, value ->
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
FilterChip(
selected = graphMode == GraphMode.PER_DEVICE,
onClick = { graphMode = GraphMode.PER_DEVICE },
label = { Text("Per Device") },
leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
)
FilterChip(
selected = graphMode == GraphMode.PER_PEER,
onClick = { graphMode = GraphMode.PER_PEER },
label = { Text("Per Peer") },
leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
)
}
// Time series state
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var highlightedKey by rememberSaveable { mutableStateOf<String?>(null) }
// Color palette for stacked legend
val palette = remember {
listOf(
Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30),
Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759),
Color(0xFFFFCC00), Color(0xFF5856D6)
)
}
val colorForKey = remember { mutableStateMapOf<String, Color>() }
fun stableColorFor(key: String): Color {
// Deterministic fallback color based on key hash using HSV palette
val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360
return Color.hsv(h.toFloat(), 0.65f, 0.95f)
}
// Ensure colors are assigned for current keys before drawing
fun ensureColors(keys: List<String>) {
keys.forEachIndexed { idx, k ->
colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k))
}
}
LaunchedEffect(isPresented, graphMode) {
while (isPresented) {
when (graphMode) {
GraphMode.OVERALL -> {
val sIn = relayStats.lastSecondIncoming.toFloat()
val sOut = relayStats.lastSecondOutgoing.toFloat()
overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60)
overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60)
}
GraphMode.PER_DEVICE -> {
val snapshotIn = perDeviceIncoming
val snapshotOut = perDeviceOutgoing
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
val union = (base.keys + snap.keys).toSet()
union.forEach { k ->
val prev = base[k] ?: List(60) { 0f }
val s = (snap[k] ?: 0).toFloat()
next[k] = (prev + s).takeLast(60)
}
return next
}
// Advance and prune fully-stale series (all-zero in visible window)
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
}
GraphMode.PER_PEER -> {
val snapshotIn = perPeerIncoming
val snapshotOut = perPeerOutgoing
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
val union = (base.keys + snap.keys).toSet()
union.forEach { k ->
val prev = base[k] ?: List(60) { 0f }
val s = (snap[k] ?: 0).toFloat()
next[k] = (prev + s).takeLast(60)
}
return next
}
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
}
}
kotlinx.coroutines.delay(1000)
}
}
// Left gutter layout: unit + ticks neatly aligned
Row(Modifier.fillMaxSize()) {
Box(Modifier.width(leftGutter).fillMaxHeight()) {
// Unit label on the far left, centered vertically
Text(
"p/s",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
)
// Tick labels right-aligned in gutter, top and bottom aligned
Text(
"${maxVal.toInt()}",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp)
)
Text(
"0",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp)
)
// Helper functions moved to top-level composable below to avoid scope issues
// Render two blocks: Incoming and Outgoing
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Incoming",
stackedKeys = stackedKeysIncoming,
stackedSeries = stackedSeriesIncoming,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerIncoming[key] ?: 0
val m = perPeerIncoming1m[key] ?: 0
val t = (perPeerIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceIncoming[key] ?: 0
val m = perDeviceIncoming1m[key] ?: 0
val t = (perDeviceIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
Spacer(Modifier.weight(1f))
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
Spacer(Modifier.height(8.dp))
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Outgoing",
stackedKeys = stackedKeysOutgoing,
stackedSeries = stackedSeriesOutgoing,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerOutgoing[key] ?: 0
val m = perPeerOutgoing1m[key] ?: 0
val t = (perPeerOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceOutgoing[key] ?: 0
val m = perDeviceOutgoing1m[key] ?: 0
val t = (perDeviceOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
}
}
}
@@ -492,3 +676,146 @@ fun DebugSettingsSheet(
}
}
}
@Composable
private fun DrawGraphBlock(
title: String,
stackedKeys: List<String>,
stackedSeries: Map<String, List<Float>>,
overallSeries: List<Float>?,
graphMode: GraphMode,
highlightedKey: String?,
onToggleHighlight: (String) -> Unit,
ensureColors: (List<String>) -> Unit,
colorForKey: (String) -> Color,
legendTitleFor: (String) -> String,
legendMetricsFor: (String) -> String
) {
val colorScheme = MaterialTheme.colorScheme
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = leftGutter.toPx()
val barCount = 60
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
when (graphMode) {
GraphMode.OVERALL -> {
val maxValRaw = (overallSeries?.maxOrNull() ?: 0f)
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
(overallSeries ?: emptyList()).forEachIndexed { i, value ->
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
}
}
else -> {
val indices = 0 until 60
val totals = indices.map { idx ->
stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat()
}
val maxTotal = (totals.maxOrNull() ?: 0f)
val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
indices.forEach { i ->
var yTop = h
if (maxTotal > 0f) {
ensureColors(drawKeysBars)
drawKeysBars.forEach { k ->
val v = stackedSeries[k]?.getOrNull(i) ?: 0f
if (v > 0f) {
val ratio = (v / maxTotal).coerceIn(0f, 1f)
val segH = (h * ratio)
if (segH > 0.5f) {
val top = (yTop - segH)
val baseColor = colorForKey(k)
val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f)
drawRect(
color = c,
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top),
size = androidx.compose.ui.geometry.Size(w, segH)
)
yTop = top
}
}
}
}
}
}
}
}
Row(Modifier.fillMaxSize()) {
Box(Modifier.width(leftGutter).fillMaxHeight()) {
Text(
"p/s",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
)
val topLabel = when (graphMode) {
GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString()
else -> {
val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() }
(totals.maxOrNull() ?: 0f).toInt().toString()
}
}
Text(
topLabel,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
)
Text(
"0",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
)
}
Spacer(Modifier.weight(1f))
}
}
val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) {
Column(Modifier.fillMaxWidth()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
drawKeys.forEach { key ->
val baseColor = colorForKey(key)
val dimmed = highlightedKey != null && highlightedKey != key
val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable { onToggleHighlight(key) }
) {
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
Column {
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
}
}
}
}
}
}
}
@@ -80,8 +80,8 @@ object AppConstants {
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
}
object Nostr {
@@ -115,6 +115,8 @@ object AppConstants {
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
}
object Media {
@@ -106,24 +106,30 @@ class WifiAwareMeshService(private val context: Context) {
setupDelegates()
messageHandler.packetProcessor = packetProcessor
gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID,
scope = serviceScope,
configProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 500
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}
)
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
override fun sendPacket(packet: BitchatPacket) {
broadcastPacket(RoutedPacket(packet))
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
return signPacketBeforeBroadcast(packet)
// Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor)
val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager
if (shared != null) {
gossipSyncManager = shared
} else {
gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID,
scope = serviceScope,
configProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 500
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}
)
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
override fun sendPacket(packet: BitchatPacket) {
broadcastPacket(RoutedPacket(packet))
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
return signPacketBeforeBroadcast(packet)
}
}
}
}
@@ -162,10 +168,10 @@ class WifiAwareMeshService(private val context: Context) {
sock.getOutputStream().write(bytes)
sent++
} catch (e: IOException) {
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
}
}
Log.i(TAG, "TX: broadcast via WiFi Aware to $sent peers (bytes=${bytes.size})")
Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})")
}
/**
@@ -173,12 +179,12 @@ class WifiAwareMeshService(private val context: Context) {
*/
private fun broadcastPacket(routed: RoutedPacket) {
Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})")
// WiFi Aware uses full packets; no fragmentation
// Wi-Fi Aware uses full packets; no fragmentation
val data = routed.packet.toBinaryData() ?: return
serviceScope.launch { broadcastRaw(data) }
}
// Expose a public method so BLE can forward relays to WiFi Aware
// Expose a public method so BLE can forward relays to Wi-Fi Aware
fun broadcastRoutedPacket(routed: RoutedPacket) {
broadcastPacket(routed)
}
@@ -187,19 +193,19 @@ class WifiAwareMeshService(private val context: Context) {
* Send packet to connected peer.
*/
private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
// WiFi Aware uses full packets; no fragmentation
// Wi-Fi Aware uses full packets; no fragmentation
val data = packet.toBinaryData() ?: return
serviceScope.launch {
val sock = peerSockets[peerID]
if (sock == null) {
Log.w(TAG, "TX: no socket for ${peerID.take(8)}")
Log.w(TAG, "TX: no socket for ${peerID.take(8)}")
return@launch
}
try {
sock.getOutputStream().write(data)
Log.d(TAG, "TX: packet type=${packet.type} ${peerID.take(8)} (bytes=${data.size})")
Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})")
} catch (e: IOException) {
Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}")
Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}")
}
}
}
@@ -409,7 +415,7 @@ class WifiAwareMeshService(private val context: Context) {
fun startServices() {
if (isActive) return
isActive = true
Log.i(TAG, "Starting WiFi Aware mesh with peer ID: $myPeerID")
Log.i(TAG, "Starting Wi-Fi Aware mesh with peer ID: $myPeerID")
awareManager?.attach(object : AttachCallback() {
@SuppressLint("MissingPermission")
@@ -419,7 +425,7 @@ class WifiAwareMeshService(private val context: Context) {
])
override fun onAttached(session: WifiAwareSession) {
wifiAwareSession = session
Log.i(TAG, "WiFi Aware attached; starting publish & subscribe (peerID=$myPeerID)")
Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)")
// PUBLISH (server role)
session.publish(
@@ -440,7 +446,7 @@ class WifiAwareMeshService(private val context: Context) {
val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" }
handleToPeerId[peerHandle] = peerId
if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis()
Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}")
Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}")
}
@RequiresApi(Build.VERSION_CODES.Q)
@@ -481,7 +487,7 @@ class WifiAwareMeshService(private val context: Context) {
val msgId = (System.nanoTime() and 0x7fffffff).toInt()
subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray())
if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis()
Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)")
Log.d(TAG, "SUBSCRIBE: sent ping to '${peerId.take(16)}' (msgId=$msgId)")
}
@RequiresApi(Build.VERSION_CODES.Q)
@@ -493,7 +499,7 @@ class WifiAwareMeshService(private val context: Context) {
val peerId = handleToPeerId[peerHandle] ?: return
if (peerId == myPeerID) return
Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B")
Log.d(TAG, "SUBSCRIBE: onMessageReceived() → server-ready from ${peerId.take(8)} payload=${message.size}B")
handleServerReady(peerHandle, message)
}
},
@@ -536,8 +542,6 @@ class WifiAwareMeshService(private val context: Context) {
serverSockets.clear()
peerSockets.clear()
cm.bindProcessToNetwork(null)
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -583,13 +587,20 @@ class WifiAwareMeshService(private val context: Context) {
val ss = ServerSocket(0)
serverSockets[peerId] = ss
val port = ss.localPort
Log.d(TAG, "SERVER: listening for ${peerId.take(8)}… on port $port")
// Ensure port is set to reuse if connection was recently closed (TIME_WAIT)
try {
ss.reuseAddress = true
} catch (_: Exception) {}
Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on port $port")
val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle)
.setPskPassphrase(PSK)
.setPort(port)
.build()
// Default capabilities include NET_CAPABILITY_NOT_VPN.
// Keeping defaults for hardware interface handle acquisition compatibility with global VPNs.
val req = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
.setNetworkSpecifier(spec)
@@ -597,10 +608,11 @@ class WifiAwareMeshService(private val context: Context) {
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
cm.bindProcessToNetwork(network)
try {
val client = ss.accept().apply { keepAlive = true }
Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)}… addr=${client.inetAddress?.hostAddress}")
val client = ss.accept()
try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") }
client.keepAlive = true
Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}")
peerSockets[peerId] = client
try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
@@ -609,23 +621,22 @@ class WifiAwareMeshService(private val context: Context) {
// Kick off Noise handshake for this logical peer
if (myPeerID < peerId) {
messageHandler.delegate?.initiateNoiseHandshake(peerId)
Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)")
Log.d(TAG, "SERVER: initiating Noise handshake to ${peerId.take(8)} (lower ID)")
}
// Ensure fast presence even before handshake settles
serviceScope.launch { delay(150); sendBroadcastAnnounce() }
} catch (ioe: IOException) {
Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe)
Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe)
}
}
override fun onLost(network: Network) {
cm.bindProcessToNetwork(null)
networkCallbacks.remove(peerId)
Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}")
Log.d(TAG, "SERVER: network lost for ${peerId.take(8)}")
}
}
networkCallbacks[peerId] = cb
Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}")
Log.d(TAG, "SERVER: requesting Aware network for ${peerId.take(8)}")
cm.requestNetwork(req, cb)
val readyId = (System.nanoTime() and 0x7fffffff).toInt()
@@ -657,11 +668,13 @@ class WifiAwareMeshService(private val context: Context) {
) {
// TCP keep-alive pings
serviceScope.launch {
val os = client.getOutputStream()
while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break }
delay(2_000)
}
try {
val os = client.getOutputStream()
while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break }
delay(2_000)
}
} catch (_: Exception) {}
}
// Discovery keep-alive
serviceScope.launch {
@@ -694,7 +707,7 @@ class WifiAwareMeshService(private val context: Context) {
}
val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int
Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port")
Log.d(TAG, "CLIENT: connecting to ${peerId.take(8)} port=$port")
val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle)
.setPskPassphrase(PSK)
@@ -711,16 +724,31 @@ class WifiAwareMeshService(private val context: Context) {
override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
if (peerSockets.containsKey(peerId)) return
val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: return
val addr = info.peerIpv6Addr as Inet6Address
val addr = info.peerIpv6Addr as? Inet6Address ?: return
val lp = cm.getLinkProperties(network)
val iface = lp?.interfaceName
try {
// Some devices deny bindSocket() (EPERM). Rely on scoped IPv6 to route correctly.
val sock = Socket()
try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") }
sock.tcpNoDelay = true
sock.keepAlive = true
sock.connect(java.net.InetSocketAddress(addr, port), 7000)
Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)}… addr=$addr:$port")
// Use scoped IPv6 if interface name is available
val scopedAddr = if (iface != null && addr.scopeId == 0) {
try {
Inet6Address.getByAddress(null, addr.address, java.net.NetworkInterface.getByName(iface))
} catch (e: Exception) {
addr
}
} else {
addr
}
sock.connect(java.net.InetSocketAddress(scopedAddr, port), 7000)
Log.d(TAG, "CLIENT: TCP connected to ${peerId.take(8)} addr=$scopedAddr:$port (iface=$iface)")
peerSockets[peerId] = sock
try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { peerManager.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
@@ -729,22 +757,22 @@ class WifiAwareMeshService(private val context: Context) {
// Kick off Noise handshake for this logical peer
if (myPeerID < peerId) {
messageHandler.delegate?.initiateNoiseHandshake(peerId)
Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)")
Log.d(TAG, "CLIENT: initiating Noise handshake to ${peerId.take(8)} (lower ID)")
}
// Ensure fast presence even before handshake settles
serviceScope.launch { delay(150); sendBroadcastAnnounce() }
} catch (ioe: IOException) {
Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe)
Log.e(TAG, "CLIENT: socket connect failed to ${peerId.take(8)}", ioe)
}
}
override fun onLost(network: Network) {
networkCallbacks.remove(peerId)
Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}")
Log.d(TAG, "CLIENT: network lost for ${peerId.take(8)}")
}
}
networkCallbacks[peerId] = cb
Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}")
Log.d(TAG, "CLIENT: requesting Aware network for ${peerId.take(8)}")
cm.requestNetwork(req, cb)
}
@@ -758,11 +786,13 @@ class WifiAwareMeshService(private val context: Context) {
) {
// TCP keep-alive
serviceScope.launch {
val os = sock.getOutputStream()
while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break }
delay(2_000)
}
try {
val os = sock.getOutputStream()
while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break }
delay(2_000)
}
} catch (_: Exception) {}
}
// Discovery keep-alive
serviceScope.launch {
@@ -811,20 +841,39 @@ class WifiAwareMeshService(private val context: Context) {
peerSockets[routedPeerId] = socket
}
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})")
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} (bytes=${raw.size})")
packetProcessor.processPacket(RoutedPacket(pkt, routedPeerId))
}
// Breaking out of the loop means the socket is dead or service is stopping.
// We MUST notify the mesh layer so it removes the logical peer immediately to allow reconnection.
Log.i(TAG, "Socket loop terminated for ${initialLogicalPeerId.take(8)} removing peer.")
handlePeerDisconnection(initialLogicalPeerId, routedPeerId)
socket.closeQuietly()
routedPeerId?.let {
peerSockets.remove(it)
peerManager.removePeer(it)
}
private fun handlePeerDisconnection(initialId: String, routedId: String?) {
serviceScope.launch {
Log.d(TAG, "Cleaning up peer: $initialId / $routedId")
peerSockets.remove(initialId)?.closeQuietly()
serverSockets.remove(initialId)?.closeQuietly()
networkCallbacks.remove(initialId)?.let { runCatching { cm.unregisterNetworkCallback(it) } }
peerManager.removePeer(initialId)
routedId?.let { id ->
if (id != initialId) {
peerSockets.remove(id)?.closeQuietly()
serverSockets.remove(id)?.closeQuietly()
networkCallbacks.remove(id)?.let { runCatching { cm.unregisterNetworkCallback(it) } }
peerManager.removePeer(id)
}
}
}
}
/**
* Sends a broadcast message to all peers.
*
* @param content Text content of the message
* @param mentions Optional list of mentioned peer IDs
* @param channel Optional channel name
@@ -1029,9 +1078,7 @@ class WifiAwareMeshService(private val context: Context) {
senderID = myPeerID,
payload = tlvPayload
)
val signed = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let {
announcePacket.copy(signature = it)
} ?: announcePacket
val signed = signPacketBeforeBroadcast(announcePacket)
broadcastPacket(RoutedPacket(signed))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
@@ -1056,9 +1103,7 @@ class WifiAwareMeshService(private val context: Context) {
senderID = myPeerID,
payload = tlvPayload
)
val signed = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let {
packet.copy(signature = it)
} ?: packet
val signed = signPacketBeforeBroadcast(packet)
broadcastPacket(RoutedPacket(signed))
peerManager.markPeerAsAnnouncedTo(peerID)
@@ -1145,15 +1190,43 @@ class WifiAwareMeshService(private val context: Context) {
/**
* @return the current IPv4/IPv6 address of a connected peer, if any.
* Prefers the scoped IPv6 address format.
*/
fun getDeviceAddressForPeer(peerID: String): String? =
peerSockets[peerID]?.inetAddress?.hostAddress
peerSockets[peerID]?.let { resolveScopedAddress(it) }
/**
* Helper to resolve a scoped IPv6 address from a socket for UI display.
*/
private fun resolveScopedAddress(sock: Socket): String? {
val addr = sock.inetAddress as? Inet6Address ?: return sock.inetAddress?.hostAddress
if (addr.scopeId != 0 || addr.isLoopbackAddress) return addr.hostAddress
// If address has no scope but we are on Aware (Link-Local fe80), attempt interface resolution
val iface = try {
val lp = cm.getLinkProperties(cm.activeNetwork)
lp?.interfaceName ?: "aware0"
} catch (_: Exception) { "aware0" }
return "${addr.hostAddress}%$iface"
}
/**
* @return a mapping of peerID connected device IP address for all active sockets.
* Results are formatted as scoped addresses if applicable.
*/
fun getDeviceAddressToPeerMapping(): Map<String, String> =
peerSockets.mapValues { it.value.inetAddress.hostAddress }
fun getDeviceAddressToPeerMapping(): Map<String, String> {
val map = mutableMapOf<String, String>()
peerSockets.forEach { (pid, sock) ->
map[pid] = resolveScopedAddress(sock) ?: "unknown"
}
return map
}
/**
* @return map of peer ID to nickname, bridged for UI warning fix.
*/
fun getPeerNicknamesMap(): Map<String, String?> = peerManager.getAllPeerNicknames()
/** Returns recently discovered peer IDs via Aware discovery (may not be connected). */
fun getDiscoveredPeerIds(): Set<String> =
+8
View File
@@ -61,6 +61,11 @@
<string name="notification_more_locations">and %1$d more locations</string>
<string name="notification_and_more">and %1$d more</string>
<!-- Mesh Service Foreground Notification -->
<string name="mesh_service_channel_name">Mesh Background Service</string>
<string name="mesh_service_channel_desc">Keeps the Bluetooth mesh running in the background</string>
<string name="mesh_service_notification_content">Mesh running — %1$d users connected</string>
<!-- Favorites accessibility -->
<string name="cd_add_favorite">Add to favorites</string>
<string name="cd_remove_favorite">Remove from favorites</string>
@@ -152,6 +157,9 @@
<string name="cd_remove_bookmark">Remove bookmark</string>
<string name="cd_teleport">Teleport</string>
<string name="cd_selected">Selected</string>
<string name="notification_action_quit_bitchat">Quit bitchat</string>
<string name="about_background_title">run in background</string>
<string name="about_background_desc">keep mesh active when app is closed (foreground service)</string>
<string name="cd_leave_channel">Leave channel</string>
<string name="cd_reachable_via_nostr">Reachable via Nostr</string>
<string name="cd_offline_favorite">Offline favorite</string>
+1 -1
View File
@@ -4,7 +4,7 @@ agp = "8.10.1"
kotlin = "2.2.0"
compileSdk = "35"
minSdk = "26" # API 26 for proper BLE support
targetSdk = "34"
targetSdk = "35"
# AndroidX Core
core-ktx = "1.16.0"