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" applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 26 versionCode = 27
versionName = "1.5.1" versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
-11
View File
@@ -243,17 +243,6 @@
column="35"/> column="35"/>
</issue> </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 <issue
id="RedundantLabel" id="RedundantLabel"
message="Redundant label can be removed" message="Redundant label can be removed"
+42
View File
@@ -26,6 +26,15 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) --> <!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" /> <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 --> <!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -48,6 +57,10 @@
<!-- Device support hint for WiFi Aware (optional) --> <!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" /> <uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application <application
android:name=".BitchatApplication" android:name=".BitchatApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -86,5 +99,34 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
+287 -262
View File
@@ -1,270 +1,295 @@
Relay URL,Latitude,Longitude 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 strfry.shock.network,39.0438,-77.4874
relay.orangepill.ovh,49.1689,-0.358841 wot.nostr.party,36.1627,-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
nostr.blankfors.se,60.1699,24.9384 nostr.blankfors.se,60.1699,24.9384
strfry.openhoofd.nl,51.9229,4.40833 nostr.now,36.55,139.733
relay.magiccity.live,25.8128,-80.2377 nostr.simplex.icu,51.5121,-0.0005238
relay.goodmorningbitcoin.com,43.6532,-79.3832 relay.threenine.services,51.5524,-0.29686
nostr.notribe.net,40.8302,-74.1299 relay.barine.co,43.6532,-79.3832
slick.mjex.me,39.048,-77.4817 theoutpost.life,64.1476,-21.9392
nostr.davidebtc.me,51.5072,-0.127586 purplerelay.com,50.1109,8.68213
alien.macneilmediagroup.com,43.6532,-79.3832 nostrue.com,40.8054,-74.0241
pyramid.treegaze.com,43.6532,-79.3832 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 relay03.lnfi.network,39.0997,-94.5786
relay.evanverma.com,40.8302,-74.1299 soloco.nl,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.chaima.info,51.223,6.78245 nostr.azzamo.net,52.2633,21.0283
no.str.cr,9.92857,-84.0528 nostr.red5d.dev,43.6532,-79.3832
relay.jabato.space,52.52,13.405 nostr.thebiglake.org,32.71,-96.6745
adre.su,59.9311,30.3609 nostr.tadryanom.me,43.6532,-79.3832
relay.hasenpfeffr.com,39.0438,-77.4874
relay.etch.social,41.2619,-95.8608
schnorr.me,43.6532,-79.3832 schnorr.me,43.6532,-79.3832
relay.endfiat.money,43.6532,-79.3832 ithurtswhenip.ee,51.223,6.78245
relay.nostr.place,32.7767,-96.797
nostr.sathoarder.com,48.5734,7.75211
orly.ft.hn,50.4754,12.3683 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.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 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) val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled) com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { } } 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 // TorManager already initialized above
} }
@@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager 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 lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 // Enable edge-to-edge display for modern Android look
enableEdgeToEdge() enableEdgeToEdge()
// Initialize permission management // Initialize permission management
permissionManager = PermissionManager(this) permissionManager = PermissionManager(this)
// Initialize core mesh service first // Ensure foreground service is running and get mesh instance from holder
meshService = BluetoothMeshService(this) 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 // Expose BLE mesh to WiFi Aware controller for cross-transport relays
try { com.bitchat.android.wifiaware.WifiAwareController.setBleMeshService(meshService) } catch (_: Exception) { } try { com.bitchat.android.wifiaware.WifiAwareController.setBleMeshService(meshService) } catch (_: Exception) { }
bluetoothStatusManager = BluetoothStatusManager( bluetoothStatusManager = BluetoothStatusManager(
@@ -131,6 +170,13 @@ class MainActivity : OrientationAwareActivity() {
if (running && svc != null) { if (running && svc != null) {
svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate { svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate {
override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) { 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) chatViewModel.didReceiveMessage(message)
} }
override fun didUpdatePeerList(peers: List<String>) { override fun didUpdatePeerList(peers: List<String>) {
@@ -678,6 +724,17 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(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 // Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent) handleNotificationIntent(intent)
@@ -688,6 +745,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume() super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly // Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { 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 // Set app foreground state
meshService.connectionManager.setAppBackgroundState(false) meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false)
@@ -720,6 +779,8 @@ class MainActivity : OrientationAwareActivity() {
// Set app background state // Set app background state
meshService.connectionManager.setAppBackgroundState(true) meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager // Cleanup location status manager
try { try {
locationStatusManager.cleanup() locationStatusManager.cleanup()
@@ -794,14 +857,6 @@ class MainActivity : OrientationAwareActivity() {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
} }
// Stop mesh services if app was fully initialized // Do not stop mesh here; ForegroundService owns lifecycle for background reliability
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}")
}
}
} }
} }
@@ -149,6 +149,7 @@ class BluetoothConnectionManager(
try { try {
isActive = true 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 // set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try { // try {
@@ -179,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false this@BluetoothConnectionManager.isActive = false
return@launch return@launch
} }
Log.d(TAG, "GATT Server started")
} else { } else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting") Log.i(TAG, "GATT Server disabled by debug settings; not starting")
} }
@@ -189,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false this@BluetoothConnectionManager.isActive = false
return@launch return@launch
} }
Log.d(TAG, "GATT Client started")
} else { } else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting") Log.i(TAG, "GATT Client disabled by debug settings; not starting")
} }
@@ -214,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false isActive = false
connectionScope.launch { connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers // Stop component managers
clientManager.stop() clientManager.stop()
serverManager.stop() serverManager.stop()
@@ -231,6 +235,18 @@ class BluetoothConnectionManager(
} }
} }
/**
* 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 * 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 internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID) private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager 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 // Service state management
private var isActive = false private var isActive = false
@@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines // Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init { init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
setupDelegates() setupDelegates()
messageHandler.packetProcessor = packetProcessor messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging() //startPeriodicDebugLogging()
@@ -86,6 +95,9 @@ class BluetoothMeshService(private val context: Context) {
} }
) )
// Register as shared instance for Wi-Fi Aware transport
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
// Wire sync manager delegate // Wire sync manager delegate
gossipSyncManager.delegate = object : GossipSyncManager.Delegate { gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) { override fun sendPacket(packet: BitchatPacket) {
@@ -98,6 +110,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet) return signPacketBeforeBroadcast(packet)
} }
} }
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
} }
/** /**
@@ -105,6 +118,7 @@ class BluetoothMeshService(private val context: Context) {
*/ */
private fun startPeriodicDebugLogging() { private fun startPeriodicDebugLogging() {
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) { while (isActive) {
try { try {
delay(10000) // 10 seconds 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.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() { private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) { while (isActive) {
try { try {
delay(30000) // 30 seconds 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.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 * Setup delegate connections between components
*/ */
private fun setupDelegates() { private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs // Provide nickname resolver to BLE broadcaster for detailed logs
try { try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +164,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate // PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate { peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) { 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) delegate?.didUpdatePeerList(peerIDs)
} }
override fun onPeerRemoved(peerID: String) { override fun onPeerRemoved(peerID: String) {
@@ -165,6 +186,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange // Send announcement and cached messages after key exchange
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100) delay(100)
sendAnnouncementToPeer(peerID) sendAnnouncementToPeer(peerID)
@@ -353,7 +375,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks // Callbacks
override fun onMessageReceived(message: BitchatMessage) { 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) 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) { override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -478,12 +529,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates // BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate { connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// 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)) packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
} }
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready // Send initial announcements after services are ready
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200) delay(200)
sendBroadcastAnnounce() sendBroadcastAnnounce()
} }
@@ -498,6 +560,7 @@ class BluetoothMeshService(private val context: Context) {
} }
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag // Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr] 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") Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return 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") 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)") Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs // Start periodic syncs
gossipSyncManager.start() gossipSyncManager.start()
Log.d(TAG, "GossipSyncManager started")
} else { } else {
Log.e(TAG, "Failed to start Bluetooth services") Log.e(TAG, "Failed to start Bluetooth services")
} }
@@ -568,11 +637,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement() sendLeaveAnnouncement()
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send delay(200) // Give leave message time to send
// Stop all components // Stop all components
gossipSyncManager.stop() gossipSyncManager.stop()
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices() connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown() peerManager.shutdown()
fragmentManager.shutdown() fragmentManager.shutdown()
securityManager.shutdown() securityManager.shutdown()
@@ -580,10 +652,25 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown() messageHandler.shutdown()
packetProcessor.shutdown() packetProcessor.shutdown()
// Mark this instance as terminated and cancel its scope so it won't be reused
terminated = true
serviceScope.cancel() 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 * Send public message
*/ */
@@ -815,6 +902,13 @@ class BluetoothMeshService(private val context: Context) {
} }
try { 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 // Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload( val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -841,6 +935,9 @@ class BluetoothMeshService(private val context: Context) {
connectionManager.broadcastPacket(RoutedPacket(signedPacket)) connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") 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) { } catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
} }
@@ -853,7 +950,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() { fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce") Log.d(TAG, "Sending broadcast announce")
serviceScope.launch { 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 // Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey() val staticKey = encryptionService.getStaticPublicKey()
@@ -902,7 +999,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) { fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return 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 // Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey() val staticKey = encryptionService.getStaticPublicKey()
@@ -1001,6 +1098,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID) 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 * Get peer info for verification purposes
*/ */
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try { try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) } val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
val isRelay = (incomingAddr != null || incomingPeer != null) val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Always log outgoing for the actual transmission target
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( manager.logOutgoing(
packetType = typeName,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
packetType = typeName, packetType = typeName,
senderPeerID = senderPeerID, senderPeerID = senderPeerID,
senderNickname = senderNick, senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick, toNickname = toNick,
toDeviceAddress = toDeviceAddress, toDeviceAddress = toDeviceAddress,
ttl = ttl, ttl = ttl,
isRelay = isRelay isRelay = true
) )
} catch (_: Exception) { } catch (_: Exception) {
// Silently ignore debug logging failures // Silently ignore debug logging failures
@@ -224,26 +224,29 @@ class PowerManager(private val context: Context) {
} }
private fun updatePowerMode() { private fun updatePowerMode() {
val newMode = when { // Determine the base mode from battery/charging state only
// Always use performance mode when charging (unless in background too long) val baseMode = when {
// Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.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 batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
// Low battery - use power saver // Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
// Background app with medium battery - use power saver // Otherwise balanced
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
// Background app with good battery - use balanced
isAppInBackground -> PowerMode.BALANCED
// Foreground with good battery - use balanced
else -> PowerMode.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) { if (newMode != currentMode) {
val oldMode = currentMode val oldMode = currentMode
currentMode = newMode 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
}
}
}
@@ -12,6 +12,9 @@ import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -19,6 +22,7 @@ import androidx.compose.ui.Alignment
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -37,8 +41,162 @@ import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.ArtiTorManager
/** /**
* About Sheet for bitchat app information * Feature row for displaying app capabilities
* Matches the design language of LocationChannelsSheet */
@Composable
private fun FeatureRow(
icon: ImageVector,
title: String,
subtitle: String
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 18.sp
)
}
}
}
/**
* Theme selection chip with Apple-like styling
*/
@Composable
private fun ThemeChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
Surface(
modifier = modifier,
onClick = onClick,
shape = RoundedCornerShape(10.dp),
color = if (selected) {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
} else {
colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
/**
* Unified settings toggle row with icon, title, subtitle, and switch
* Apple-like design with proper spacing
*/
@Composable
private fun SettingsToggleRow(
icon: ImageVector,
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
statusIndicator: (@Composable () -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
)
statusIndicator?.invoke()
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
lineHeight = 16.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { if (enabled) onCheckedChange(it) },
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
uncheckedThumbColor = Color.White,
uncheckedTrackColor = colorScheme.surfaceVariant
)
)
}
}
/**
* Apple-like About/Settings Sheet with high-quality design
* Professional UX optimized for checkout scenarios
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -59,8 +217,6 @@ fun AboutSheet(
} }
} }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true skipPartiallyExpanded = true
) )
@@ -72,11 +228,10 @@ fun AboutSheet(
} }
} }
val topBarAlpha by animateFloatAsState( val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f, targetValue = if (isScrolled) 0.98f else 0f,
label = "topBarAlpha" label = "topBarAlpha"
) )
// Color scheme matching LocationChannelsSheet
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -85,285 +240,291 @@ fun AboutSheet(
modifier = modifier.statusBarsPadding(), modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background, containerColor = colorScheme.background,
dragHandle = null dragHandle = null
) { ) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp) contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
// Header Section // Header Section - App Identity
item(key = "header") { item(key = "header") {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 20.dp),
.padding(bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Bottom
) { ) {
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 32.sp fontSize = 28.sp,
letterSpacing = 1.sp
), ),
color = MaterialTheme.colorScheme.onBackground color = colorScheme.onBackground
) )
Text( Text(
text = stringResource(R.string.version_prefix, versionName?:""), text = stringResource(R.string.version_prefix, versionName ?: ""),
fontSize = 11.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f), color = colorScheme.onBackground.copy(alpha = 0.5f)
style = MaterialTheme.typography.bodySmall.copy(
baselineShift = BaselineShift(0.1f)
) )
)
}
Text( Text(
text = stringResource(R.string.about_tagline), text = stringResource(R.string.about_tagline),
fontSize = 12.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) color = colorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
// Features section // Features Section - Grouped Card
item(key = "feature_offline") { item(key = "features") {
Row( Column(modifier = Modifier.padding(horizontal = 20.dp)) {
verticalAlignment = Alignment.Top, Text(
modifier = Modifier text = stringResource(R.string.about_appearance).uppercase(),
.padding(horizontal = 24.dp) style = MaterialTheme.typography.labelSmall,
.padding(vertical = 8.dp) color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) { ) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = stringResource(R.string.cd_offline_mesh_chat),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column { Column {
Text( FeatureRow(
text = stringResource(R.string.about_offline_mesh_title), icon = Icons.Filled.Bluetooth,
style = MaterialTheme.typography.titleMedium, title = stringResource(R.string.about_offline_mesh_title),
fontWeight = FontWeight.Medium, subtitle = stringResource(R.string.about_offline_mesh_desc)
color = MaterialTheme.colorScheme.onBackground
) )
Spacer(modifier = Modifier.height(4.dp)) HorizontalDivider(
Text( modifier = Modifier.padding(start = 56.dp),
text = stringResource(R.string.about_offline_mesh_desc), color = colorScheme.outline.copy(alpha = 0.12f)
style = MaterialTheme.typography.bodySmall, )
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) FeatureRow(
icon = Icons.Default.Public,
title = stringResource(R.string.about_online_geohash_title),
subtitle = stringResource(R.string.about_online_geohash_desc)
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
FeatureRow(
icon = Icons.Default.Lock,
title = stringResource(R.string.about_e2e_title),
subtitle = stringResource(R.string.about_e2e_desc)
) )
} }
} }
} }
item(key = "feature_geohash") {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.padding(horizontal = 24.dp)
.padding(vertical = 8.dp)
) {
Icon(
imageVector = Icons.Default.Public,
contentDescription = stringResource(R.string.cd_online_geohash_channels),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = stringResource(R.string.about_online_geohash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.about_online_geohash_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
}
item(key = "feature_encryption") {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.padding(horizontal = 24.dp)
.padding(vertical = 8.dp)
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = stringResource(R.string.cd_end_to_end_encryption),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = stringResource(R.string.about_e2e_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.about_e2e_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
} }
// Appearance Section // Appearance Section
item(key = "appearance_section") { item(key = "appearance") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text( Text(
text = stringResource(R.string.about_appearance), text = "THEME",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), color = colorScheme.onBackground.copy(alpha = 0.5f),
modifier = Modifier letterSpacing = 0.5.sp,
.padding(horizontal = 24.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
.padding(top = 24.dp, bottom = 8.dp)
) )
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle() val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Row( Row(
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FilterChip( ThemeChip(
label = stringResource(R.string.about_system),
selected = themePref.isSystem, selected = themePref.isSystem,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) } modifier = Modifier.weight(1f)
) )
FilterChip( ThemeChip(
label = stringResource(R.string.about_light),
selected = themePref.isLight, selected = themePref.isLight,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) } modifier = Modifier.weight(1f)
) )
FilterChip( ThemeChip(
label = stringResource(R.string.about_dark),
selected = themePref.isDark, selected = themePref.isDark,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) } modifier = Modifier.weight(1f)
) )
} }
} }
// Proof of Work Section }
item(key = "pow_section") { }
// Settings Section - Unified Card with Toggles
item(key = "settings") {
LaunchedEffect(Unit) { PoWPreferenceManager.init(context) }
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) }
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text( Text(
text = stringResource(R.string.about_pow), text = "SETTINGS",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), color = colorScheme.onBackground.copy(alpha = 0.5f),
modifier = Modifier letterSpacing = 0.5.sp,
.padding(horizontal = 24.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
.padding(top = 24.dp, bottom = 8.dp)
) )
LaunchedEffect(Unit) {
PoWPreferenceManager.init(context)
}
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = !powEnabled,
onClick = { PoWPreferenceManager.setPowEnabled(false) },
label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = powEnabled,
onClick = { PoWPreferenceManager.setPowEnabled(true) },
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace)
// Show current difficulty
if (powEnabled) {
Surface( Surface(
color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(50) color = colorScheme.surface,
) { Box(Modifier.size(8.dp)) } shape = RoundedCornerShape(16.dp)
} ) {
Column {
// Background Mode Toggle
SettingsToggleRow(
icon = Icons.Filled.Bluetooth,
title = stringResource(R.string.about_background_title),
subtitle = stringResource(R.string.about_background_desc),
checked = backgroundEnabled,
onCheckedChange = { enabled ->
backgroundEnabled = enabled
com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled)
if (!enabled) {
com.bitchat.android.service.MeshForegroundService.stop(context)
} else {
com.bitchat.android.service.MeshForegroundService.start(context)
} }
} }
) )
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// Proof of Work Toggle
SettingsToggleRow(
icon = Icons.Filled.Speed,
title = stringResource(R.string.about_pow),
subtitle = stringResource(R.string.about_pow_tip),
checked = powEnabled,
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// Tor Toggle
SettingsToggleRow(
icon = Icons.Filled.Security,
title = "Tor Network",
subtitle = stringResource(R.string.about_tor_route),
checked = torMode.value == TorMode.ON,
onCheckedChange = { enabled ->
if (torAvailable) {
torMode.value = if (enabled) TorMode.ON else TorMode.OFF
TorPreferenceManager.set(context, torMode.value)
}
},
enabled = torAvailable,
statusIndicator = if (torMode.value == TorMode.ON) {
{
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
torStatus.running -> Color(0xFFFF9500)
else -> Color(0xFFFF3B30)
}
Surface(
color = statusColor,
shape = CircleShape,
modifier = Modifier.size(8.dp)
) {}
}
} else null
)
}
} }
// Tor unavailable hint
if (!torAvailable) {
Text( Text(
text = stringResource(R.string.about_pow_tip), text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 10.sp, fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f),
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
)
}
}
}
// PoW Difficulty Slider (when enabled)
item(key = "pow_slider") {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
if (powEnabled) {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Difficulty",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f) color = colorScheme.onSurface.copy(alpha = 0.6f)
) )
}
// Show difficulty slider when enabled
if (powEnabled) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
)
Slider( Slider(
value = powDifficulty.toFloat(), value = powDifficulty.toFloat(),
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
valueRange = 0f..32f, valueRange = 0f..32f,
steps = 33, steps = 31,
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
) )
) )
// Show difficulty description
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
Text( Text(
text = when { text = when {
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
@@ -374,135 +535,57 @@ fun AboutSheet(
powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high)
else -> stringResource(R.string.about_pow_desc_extreme) else -> stringResource(R.string.about_pow_desc_extreme)
}, },
fontSize = 10.sp, fontSize = 12.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f) color = colorScheme.onSurface.copy(alpha = 0.5f)
) )
} }
} }
} }
} }
} }
}
// Network (Tor) section // Tor Status (when enabled)
item(key = "network_section") { item(key = "tor_status") {
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) } val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
val torProvider = remember { ArtiTorManager.getInstance() } val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState() val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Text(
text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 8.dp)
)
Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = torMode.value == TorMode.OFF,
onClick = {
torMode.value = TorMode.OFF
TorPreferenceManager.set(context, torMode.value)
},
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = torMode.value == TorMode.ON,
onClick = {
if (torAvailable) {
torMode.value = TorMode.ON
TorPreferenceManager.set(context, torMode.value)
}
},
enabled = torAvailable,
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("tor on", fontFamily = FontFamily.Monospace)
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
else -> Color.Red
}
Surface(color = statusColor, shape = CircleShape) {
Box(Modifier.size(8.dp))
}
}
}
)
if (!torAvailable) {
val tooltipState = rememberTooltipState()
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
tooltipState.show()
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.6f
),
modifier = Modifier.size(18.dp)
)
}
}
}
}
Text(
text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
if (torMode.value == TorMode.ON) { if (torMode.value == TorMode.ON) {
val statusText = if (torStatus.running) "Running" else "Stopped" Column(modifier = Modifier.padding(horizontal = 20.dp)) {
// Debug status (temporary)
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f), color = colorScheme.surface,
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(16.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
torStatus.running -> Color(0xFFFF9500)
else -> Color(0xFFFF3B30)
}
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
Text( Text(
text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent), text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface.copy(alpha = 0.75f) fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
) )
val lastLog = torStatus.lastLogLine }
if (lastLog.isNotEmpty()) { if (torStatus.lastLogLine.isNotEmpty()) {
Text( Text(
text = stringResource(R.string.about_last, lastLog.take(160)), text = torStatus.lastLogLine.take(120),
style = MaterialTheme.typography.labelSmall, fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f) fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f),
maxLines = 2
) )
} }
} }
@@ -511,17 +594,14 @@ fun AboutSheet(
} }
} }
// Emergency Warning Section // Emergency Warning
item(key = "warning_section") { item(key = "warning") {
val colorScheme = MaterialTheme.colorScheme
val errorColor = colorScheme.error
Surface( Surface(
modifier = Modifier modifier = Modifier
.padding(horizontal = 24.dp, vertical = 24.dp) .padding(horizontal = 20.dp)
.fillMaxWidth(), .fillMaxWidth(),
color = errorColor.copy(alpha = 0.1f), color = colorScheme.error.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(16.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -530,61 +610,53 @@ fun AboutSheet(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Warning, imageVector = Icons.Filled.Warning,
contentDescription = stringResource(R.string.cd_warning), contentDescription = null,
tint = errorColor, tint = colorScheme.error,
modifier = Modifier.size(16.dp) modifier = Modifier.size(20.dp)
) )
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = stringResource(R.string.about_emergency_title), text = stringResource(R.string.about_emergency_title),
fontSize = 12.sp, style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold, color = colorScheme.error
color = errorColor
) )
Text( Text(
text = stringResource(R.string.about_emergency_tip), text = stringResource(R.string.about_emergency_tip),
fontSize = 11.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace, color = colorScheme.onSurface.copy(alpha = 0.7f)
color = colorScheme.onSurface.copy(alpha = 0.8f)
) )
} }
} }
} }
} }
// Footer Section // Footer
item(key = "footer") { item(key = "footer") {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 24.dp) .fillMaxWidth()
.fillMaxWidth(), .padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
if (onShowDebug != null) { if (onShowDebug != null) {
TextButton( TextButton(onClick = onShowDebug) {
onClick = onShowDebug,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
) {
Text( Text(
text = stringResource(R.string.about_debug_settings), text = stringResource(R.string.about_debug_settings),
fontSize = 11.sp, fontSize = 13.sp,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
color = colorScheme.primary
) )
} }
} }
Text( Text(
text = stringResource(R.string.about_footer), text = stringResource(R.string.about_footer),
fontSize = 11.sp, fontSize = 12.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), color = colorScheme.onSurface.copy(alpha = 0.4f)
) )
Spacer(modifier = Modifier.height(20.dp))
// Add extra space at bottom for gesture area
Spacer(modifier = Modifier.height(16.dp))
} }
} }
} }
@@ -143,6 +143,41 @@ class ChatViewModel(
init { init {
// Note: Mesh service delegate is now set by MainActivity // Note: Mesh service delegate is now set by MainActivity
loadAndInitialize() 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 // Subscribe to BLE transfer progress and reflect in message deliveryStatus
viewModelScope.launch { viewModelScope.launch {
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> 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 // Merge nicknames from BLE and WiFi Aware to display names for all peers
val bleNick = meshService.getPeerNicknames() val bleNick = meshService.getPeerNicknames()
val awareNick = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknames() } catch (_: Exception) { null } val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null }
val mergedNick = if (awareNick != null) bleNick + awareNick.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick 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) state.setPeerNicknames(mergedNick)
val rssiValues = meshService.getPeerRSSI() val rssiValues = meshService.getPeerRSSI()
@@ -47,9 +47,9 @@ class MeshDelegateHandler(
// Private message // Private message
privateChatManager.handleIncomingPrivateMessage(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 -> message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(senderPeerID) sendReadReceiptIfFocused(message)
} }
// Show notification with enhanced information - now includes senderPeerID // Show notification with enhanced information - now includes senderPeerID
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
) )
} }
} else if (message.channel != null) { } 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)) { 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 { } else {
// Public mesh message - always store to preserve message history // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
messageManager.addMessage(message) // Still run mention detection/notifications
// Check for mentions in mesh chat
checkAndTriggerMeshMentionNotification(message) checkAndTriggerMeshMentionNotification(message)
} }
@@ -266,17 +277,27 @@ class MeshDelegateHandler(
* Uses same logic as notification system - send read receipt if user is currently * 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. * 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) // Get notification manager's focus state (mirror the notification logic)
val isAppInBackground = notificationManager.getAppBackgroundState() val isAppInBackground = notificationManager.getAppBackgroundState()
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
// Send read receipt if user is currently focused on this specific chat // 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) { if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) 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 { } else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
} }
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
val currentMessages = state.getMessagesValue().toMutableList() val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message) currentMessages.add(message)
state.setMessages(currentMessages) 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) // Log a system message into the main chat (visible to user)
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
channelMessageList.add(message) channelMessageList.add(message)
currentChannelMessages[channel] = channelMessageList currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages) 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 // Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection // Consider both classic channels (state.currentChannel) and geohash location channel selection
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message) chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats) 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 // Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message) chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats) state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
} }
fun clearPrivateMessages(peerID: String) { fun clearPrivateMessages(peerID: String) {
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
// MARK: - Delivery Status Updates // 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) { fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
// Update in private chats // Update in private chats
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
@@ -215,23 +238,33 @@ class MessageManager(private val state: ChatState) {
val updatedMessages = messages.toMutableList() val updatedMessages = messages.toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) { if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
updatedPrivateChats[peerID] = updatedMessages updatedPrivateChats[peerID] = updatedMessages
updated = true updated = true
} }
} }
}
if (updated) { if (updated) {
state.setPrivateChats(updatedPrivateChats) 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 // Update in main messages
val updatedMessages = state.getMessagesValue().toMutableList() val updatedMessages = state.getMessagesValue().toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) { if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
state.setMessages(updatedMessages) state.setMessages(updatedMessages)
} }
}
// Update in channel messages // Update in channel messages
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
@@ -239,10 +272,14 @@ class MessageManager(private val state: ChatState) {
val channelMessagesList = messages.toMutableList() val channelMessagesList = messages.toMutableList()
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
if (channelMessageIndex >= 0) { if (channelMessageIndex >= 0) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) val current = channelMessagesList[channelMessageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
updatedChannelMessages[channel] = channelMessagesList updatedChannelMessages[channel] = channelMessagesList
} }
} }
}
state.setChannelMessages(updatedChannelMessages) state.setChannelMessages(updatedChannelMessages)
} }
@@ -292,28 +292,30 @@ class PrivateChatManager(
} }
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { 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)) { if (!isPeerBlocked(senderPeerID)) {
// Add to private messages // Ensure chat exists
if (suppressUnread) { messageManager.initializePrivateChat(senderPeerID)
messageManager.addPrivateMessageNoUnread(senderPeerID, message) // Track as unread for read receipt purposes if not focused
} else { if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
messageManager.addPrivateMessage(senderPeerID, message)
}
// Track as unread for read receipt purposes
var unreadCount = 0
if (!suppressUnread) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message) 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 * Called when the user focuses on a private chat
*/ */
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
val unreadList = unreadReceivedMessages[peerID] // Collect candidate messages: all incoming messages from this peer in the conversation
if (unreadList.isNullOrEmpty()) { val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
Log.d(TAG, "No unread messages to send read receipts for peer $peerID") val messages = chats[peerID].orEmpty()
return
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" val myNickname = state.getNicknameValue() ?: "unknown"
meshService.sendReadReceipt(message.id, peerID, myNickname) var sentCount = 0
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") 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) { } catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") 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) 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) { fun cleanupDisconnectedPeer(peerID: String) {
@@ -43,9 +43,15 @@ class DebugSettingsManager private constructor() {
private val _wifiAwareEnabled = MutableStateFlow(false) private val _wifiAwareEnabled = MutableStateFlow(false)
val wifiAwareEnabled: StateFlow<Boolean> = _wifiAwareEnabled.asStateFlow() val wifiAwareEnabled: StateFlow<Boolean> = _wifiAwareEnabled.asStateFlow()
// Master transport toggles
private val _wifiAwareVerbose = MutableStateFlow(false) private val _wifiAwareVerbose = MutableStateFlow(false)
val wifiAwareVerbose: StateFlow<Boolean> = _wifiAwareVerbose.asStateFlow() 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) // Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8) private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow() val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
@@ -89,12 +95,63 @@ class DebugSettingsManager private constructor() {
// Timestamps to compute rolling window stats // Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>() 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 // Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>() private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>() private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
private fun updateRelayStatsFromTimestamps() { private fun updateRelayStatsFromTimestamps() {
if (!_debugSheetVisible.value) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// prune older than 15m // prune older than 15m
while (true) { while (true) {
@@ -103,18 +160,84 @@ class DebugSettingsManager private constructor() {
relayTimestamps.poll() relayTimestamps.poll()
} else break } 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 last1s = relayTimestamps.count { now - it <= 1_000L }
val last10s = relayTimestamps.count { now - it <= 10_000L } val last10s = relayTimestamps.count { now - it <= 10_000L }
val last1m = relayTimestamps.count { now - it <= 60_000L } val last1m = relayTimestamps.count { now - it <= 60_000L }
val last15m = relayTimestamps.size 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( _relayStats.value = PacketRelayStats(
totalRelaysCount = total, totalRelaysCount = totalIncoming + totalOutgoing,
lastSecondRelays = last1s, lastSecondRelays = last1s,
last10SecondRelays = last10s, last10SecondRelays = last10s,
lastMinuteRelays = last1m, lastMinuteRelays = last1m,
last15MinuteRelays = last15m, 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 // Do not update counters here; this path is for readable logs only.
if (isRelay) {
relayTimestamps.offer(System.currentTimeMillis())
updateRelayStatsFromTimestamps()
} }
// 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 // MARK: - Clear Data
@@ -452,5 +625,15 @@ data class PacketRelayStats(
val last10SecondRelays: Int = 0, val last10SecondRelays: Int = 0,
val lastMinuteRelays: Int = 0, val lastMinuteRelays: Int = 0,
val last15MinuteRelays: 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Wifi 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.material.icons.filled.SettingsEthernet
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -29,8 +32,13 @@ import com.bitchat.android.mesh.BluetoothMeshService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.bitchat.android.R 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 @Composable
fun DebugSettingsSheet( fun DebugSettingsSheet(
isPresented: Boolean, isPresented: Boolean,
@@ -44,7 +52,7 @@ fun DebugSettingsSheet(
val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
val gattServerEnabled by manager.gattServerEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState()
val gattClientEnabled by manager.gattClientEnabled.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 maxOverall by manager.maxConnectionsOverall.collectAsState()
val maxServer by manager.maxServerConnections.collectAsState() val maxServer by manager.maxServerConnections.collectAsState()
val maxClient by manager.maxClientConnections.collectAsState() val maxClient by manager.maxClientConnections.collectAsState()
@@ -55,11 +63,14 @@ fun DebugSettingsSheet(
val seenCapacity by manager.seenPacketCapacity.collectAsState() val seenCapacity by manager.seenPacketCapacity.collectAsState()
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
val bleEnabled by manager.bleEnabled.collectAsState() val bleEnabled by manager.bleEnabled.collectAsState()
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState() val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState() val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState() val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
val wifiAwareConnected by manager.wifiAwareConnected.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 // Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) { LaunchedEffect(isPresented) {
@@ -105,6 +116,11 @@ fun DebugSettingsSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState 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( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -258,88 +274,256 @@ fun DebugSettingsSheet(
item { item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) 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) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f)) 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) // Removed aggregate labels; we will show per-direction compact labels below titles
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp) // Toggle: overall vs per-connection vs per-peer
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
var series by remember { mutableStateOf(List(60) { 0f }) } val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
LaunchedEffect(isPresented) { 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) { while (isPresented) {
val s = relayStats.lastSecondRelays.toFloat() try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
val last = series.lastOrNull() ?: 0f kotlinx.coroutines.delay(1000)
// Faster decay and smoothing
val v = last * 0.5f + s * 0.5f
series = (series + v).takeLast(60)
kotlinx.coroutines.delay(400)
} }
} }
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val leftGutter = 40.dp // Mode selector
Box(Modifier.fillMaxWidth().height(56.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Graph canvas FilterChip(
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { selected = graphMode == GraphMode.OVERALL,
val axisPx = leftGutter.toPx() // reserved left gutter for labels onClick = { graphMode = GraphMode.OVERALL },
val barCount = series.size label = { Text("Overall") }
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
) )
// Bars from bottom-up; skip zeros entirely FilterChip(
series.forEachIndexed { i, value -> selected = graphMode == GraphMode.PER_DEVICE,
if (value > 0f && maxVal > 0f) { onClick = { graphMode = GraphMode.PER_DEVICE },
val ratio = (value / maxVal).coerceIn(0f, 1f) label = { Text("Per Device") },
val barHeight = (h * ratio).coerceAtLeast(0f) leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
if (barHeight > 0.5f) { )
drawRect( FilterChip(
color = Color(0xFF00C851), selected = graphMode == GraphMode.PER_PEER,
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), onClick = { graphMode = GraphMode.PER_PEER },
size = androidx.compose.ui.geometry.Size(w, barHeight) 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)
} }
// Left gutter layout: unit + ticks neatly aligned GraphMode.PER_DEVICE -> {
Row(Modifier.fillMaxSize()) { val snapshotIn = perDeviceIncoming
Box(Modifier.width(leftGutter).fillMaxHeight()) { val snapshotOut = perDeviceOutgoing
// Unit label on the far left, centered vertically 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)
}
}
// 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( Text(
"p/s", "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
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)
) )
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
} }
Spacer(Modifier.weight(1f)) 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 -> ""
}
}
)
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_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2 const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
} }
object Nostr { object Nostr {
@@ -115,6 +115,8 @@ object AppConstants {
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_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 { object Media {
@@ -106,6 +106,11 @@ class WifiAwareMeshService(private val context: Context) {
setupDelegates() setupDelegates()
messageHandler.packetProcessor = packetProcessor messageHandler.packetProcessor = packetProcessor
// Use shared GossipSyncManager from MeshServiceHolder if available (minimal refactor)
val shared = com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager
if (shared != null) {
gossipSyncManager = shared
} else {
gossipSyncManager = GossipSyncManager( gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID, myPeerID = myPeerID,
scope = serviceScope, scope = serviceScope,
@@ -127,6 +132,7 @@ class WifiAwareMeshService(private val context: Context) {
} }
} }
} }
}
/** /**
* Helper method hexToBa. * Helper method hexToBa.
@@ -162,10 +168,10 @@ class WifiAwareMeshService(private val context: Context) {
sock.getOutputStream().write(bytes) sock.getOutputStream().write(bytes)
sent++ sent++
} catch (e: IOException) { } 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) { private fun broadcastPacket(routed: RoutedPacket) {
Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})") 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 val data = routed.packet.toBinaryData() ?: return
serviceScope.launch { broadcastRaw(data) } 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) { fun broadcastRoutedPacket(routed: RoutedPacket) {
broadcastPacket(routed) broadcastPacket(routed)
} }
@@ -187,19 +193,19 @@ class WifiAwareMeshService(private val context: Context) {
* Send packet to connected peer. * Send packet to connected peer.
*/ */
private fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { 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 val data = packet.toBinaryData() ?: return
serviceScope.launch { serviceScope.launch {
val sock = peerSockets[peerID] val sock = peerSockets[peerID]
if (sock == null) { 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 return@launch
} }
try { try {
sock.getOutputStream().write(data) 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) { } 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() { fun startServices() {
if (isActive) return if (isActive) return
isActive = true 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() { awareManager?.attach(object : AttachCallback() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@@ -419,7 +425,7 @@ class WifiAwareMeshService(private val context: Context) {
]) ])
override fun onAttached(session: WifiAwareSession) { override fun onAttached(session: WifiAwareSession) {
wifiAwareSession = session 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) // PUBLISH (server role)
session.publish( session.publish(
@@ -440,7 +446,7 @@ class WifiAwareMeshService(private val context: Context) {
val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" } val peerId = try { String(serviceSpecificInfo) } catch (_: Exception) { "" }
handleToPeerId[peerHandle] = peerId handleToPeerId[peerHandle] = peerId
if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() 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) @RequiresApi(Build.VERSION_CODES.Q)
@@ -481,7 +487,7 @@ class WifiAwareMeshService(private val context: Context) {
val msgId = (System.nanoTime() and 0x7fffffff).toInt() val msgId = (System.nanoTime() and 0x7fffffff).toInt()
subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray()) subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray())
if (peerId.isNotBlank()) discoveredTimestamps[peerId] = System.currentTimeMillis() 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) @RequiresApi(Build.VERSION_CODES.Q)
@@ -493,7 +499,7 @@ class WifiAwareMeshService(private val context: Context) {
val peerId = handleToPeerId[peerHandle] ?: return val peerId = handleToPeerId[peerHandle] ?: return
if (peerId == myPeerID) 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) handleServerReady(peerHandle, message)
} }
}, },
@@ -536,8 +542,6 @@ class WifiAwareMeshService(private val context: Context) {
serverSockets.clear() serverSockets.clear()
peerSockets.clear() peerSockets.clear()
cm.bindProcessToNetwork(null)
peerManager.shutdown() peerManager.shutdown()
fragmentManager.shutdown() fragmentManager.shutdown()
securityManager.shutdown() securityManager.shutdown()
@@ -583,13 +587,20 @@ class WifiAwareMeshService(private val context: Context) {
val ss = ServerSocket(0) val ss = ServerSocket(0)
serverSockets[peerId] = ss serverSockets[peerId] = ss
val port = ss.localPort 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) val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle)
.setPskPassphrase(PSK) .setPskPassphrase(PSK)
.setPort(port) .setPort(port)
.build() .build()
// Default capabilities include NET_CAPABILITY_NOT_VPN.
// Keeping defaults for hardware interface handle acquisition compatibility with global VPNs.
val req = NetworkRequest.Builder() val req = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
.setNetworkSpecifier(spec) .setNetworkSpecifier(spec)
@@ -597,10 +608,11 @@ class WifiAwareMeshService(private val context: Context) {
val cb = object : ConnectivityManager.NetworkCallback() { val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
cm.bindProcessToNetwork(network)
try { try {
val client = ss.accept().apply { keepAlive = true } val client = ss.accept()
Log.d(TAG, "SERVER: accepted TCP from ${peerId.take(8)}… addr=${client.inetAddress?.hostAddress}") 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 peerSockets[peerId] = client
try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { peerManager.addOrUpdatePeer(peerId, peerId) } 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 // Kick off Noise handshake for this logical peer
if (myPeerID < peerId) { if (myPeerID < peerId) {
messageHandler.delegate?.initiateNoiseHandshake(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 // Ensure fast presence even before handshake settles
serviceScope.launch { delay(150); sendBroadcastAnnounce() } serviceScope.launch { delay(150); sendBroadcastAnnounce() }
} catch (ioe: IOException) { } 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) { override fun onLost(network: Network) {
cm.bindProcessToNetwork(null)
networkCallbacks.remove(peerId) 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 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) cm.requestNetwork(req, cb)
val readyId = (System.nanoTime() and 0x7fffffff).toInt() val readyId = (System.nanoTime() and 0x7fffffff).toInt()
@@ -657,11 +668,13 @@ class WifiAwareMeshService(private val context: Context) {
) { ) {
// TCP keep-alive pings // TCP keep-alive pings
serviceScope.launch { serviceScope.launch {
try {
val os = client.getOutputStream() val os = client.getOutputStream()
while (peerSockets.containsKey(peerId)) { while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break } try { os.write(0) } catch (_: IOException) { break }
delay(2_000) delay(2_000)
} }
} catch (_: Exception) {}
} }
// Discovery keep-alive // Discovery keep-alive
serviceScope.launch { serviceScope.launch {
@@ -694,7 +707,7 @@ class WifiAwareMeshService(private val context: Context) {
} }
val port = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN).int 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) val spec = WifiAwareNetworkSpecifier.Builder(subscribeSession!!, peerHandle)
.setPskPassphrase(PSK) .setPskPassphrase(PSK)
@@ -711,16 +724,31 @@ class WifiAwareMeshService(private val context: Context) {
override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
if (peerSockets.containsKey(peerId)) return if (peerSockets.containsKey(peerId)) return
val info = (nc.transportInfo as? WifiAwareNetworkInfo) ?: 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 { try {
// Some devices deny bindSocket() (EPERM). Rely on scoped IPv6 to route correctly.
val sock = Socket() val sock = Socket()
try { network.bindSocket(sock) } catch (e: Exception) { Log.w(TAG, "Client bindSocket EPERM: ${e.message}") }
sock.tcpNoDelay = true sock.tcpNoDelay = true
sock.keepAlive = 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 peerSockets[peerId] = sock
try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {} try { peerManager.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { peerManager.addOrUpdatePeer(peerId, peerId) } 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 // Kick off Noise handshake for this logical peer
if (myPeerID < peerId) { if (myPeerID < peerId) {
messageHandler.delegate?.initiateNoiseHandshake(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 // Ensure fast presence even before handshake settles
serviceScope.launch { delay(150); sendBroadcastAnnounce() } serviceScope.launch { delay(150); sendBroadcastAnnounce() }
} catch (ioe: IOException) { } 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) { override fun onLost(network: Network) {
networkCallbacks.remove(peerId) 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 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) cm.requestNetwork(req, cb)
} }
@@ -758,11 +786,13 @@ class WifiAwareMeshService(private val context: Context) {
) { ) {
// TCP keep-alive // TCP keep-alive
serviceScope.launch { serviceScope.launch {
try {
val os = sock.getOutputStream() val os = sock.getOutputStream()
while (peerSockets.containsKey(peerId)) { while (peerSockets.containsKey(peerId)) {
try { os.write(0) } catch (_: IOException) { break } try { os.write(0) } catch (_: IOException) { break }
delay(2_000) delay(2_000)
} }
} catch (_: Exception) {}
} }
// Discovery keep-alive // Discovery keep-alive
serviceScope.launch { serviceScope.launch {
@@ -811,20 +841,39 @@ class WifiAwareMeshService(private val context: Context) {
peerSockets[routedPeerId] = socket 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)) 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() 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. * Sends a broadcast message to all peers.
*
* @param content Text content of the message * @param content Text content of the message
* @param mentions Optional list of mentioned peer IDs * @param mentions Optional list of mentioned peer IDs
* @param channel Optional channel name * @param channel Optional channel name
@@ -1029,9 +1078,7 @@ class WifiAwareMeshService(private val context: Context) {
senderID = myPeerID, senderID = myPeerID,
payload = tlvPayload payload = tlvPayload
) )
val signed = encryptionService.signData(announcePacket.toBinaryDataForSigning()!!)?.let { val signed = signPacketBeforeBroadcast(announcePacket)
announcePacket.copy(signature = it)
} ?: announcePacket
broadcastPacket(RoutedPacket(signed)) broadcastPacket(RoutedPacket(signed))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { } try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
@@ -1056,9 +1103,7 @@ class WifiAwareMeshService(private val context: Context) {
senderID = myPeerID, senderID = myPeerID,
payload = tlvPayload payload = tlvPayload
) )
val signed = encryptionService.signData(packet.toBinaryDataForSigning()!!)?.let { val signed = signPacketBeforeBroadcast(packet)
packet.copy(signature = it)
} ?: packet
broadcastPacket(RoutedPacket(signed)) broadcastPacket(RoutedPacket(signed))
peerManager.markPeerAsAnnouncedTo(peerID) 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. * @return the current IPv4/IPv6 address of a connected peer, if any.
* Prefers the scoped IPv6 address format.
*/ */
fun getDeviceAddressForPeer(peerID: String): String? = 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. * @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> = fun getDeviceAddressToPeerMapping(): Map<String, String> {
peerSockets.mapValues { it.value.inetAddress.hostAddress } 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). */ /** Returns recently discovered peer IDs via Aware discovery (may not be connected). */
fun getDiscoveredPeerIds(): Set<String> = 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_more_locations">and %1$d more locations</string>
<string name="notification_and_more">and %1$d more</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 --> <!-- Favorites accessibility -->
<string name="cd_add_favorite">Add to favorites</string> <string name="cd_add_favorite">Add to favorites</string>
<string name="cd_remove_favorite">Remove from 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_remove_bookmark">Remove bookmark</string>
<string name="cd_teleport">Teleport</string> <string name="cd_teleport">Teleport</string>
<string name="cd_selected">Selected</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_leave_channel">Leave channel</string>
<string name="cd_reachable_via_nostr">Reachable via Nostr</string> <string name="cd_reachable_via_nostr">Reachable via Nostr</string>
<string name="cd_offline_favorite">Offline favorite</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" kotlin = "2.2.0"
compileSdk = "35" compileSdk = "35"
minSdk = "26" # API 26 for proper BLE support minSdk = "26" # API 26 for proper BLE support
targetSdk = "34" targetSdk = "35"
# AndroidX Core # AndroidX Core
core-ktx = "1.16.0" core-ktx = "1.16.0"