mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 14:05:19 +00:00
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:
co-authored by
GitHub Action
aidenvalue <>
parent
ecc7e93930
commit
463600a6c3
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 Wi‑Fi operations (including Aware) -->
|
<!-- Android 13+ runtime permission for Wi‑Fi 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 Wi‑Fi Aware (optional) -->
|
<!-- Device support hint for Wi‑Fi 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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 Wi‑Fi Aware controller for cross-transport relays
|
// Expose BLE mesh to Wi‑Fi 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 Wi‑Fi Aware to display names for all peers
|
// Merge nicknames from BLE and Wi‑Fi 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 {
|
||||||
|
|||||||
+121
-48
@@ -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 Wi‑Fi 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})")
|
||||||
// Wi‑Fi 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 Wi‑Fi 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) {
|
||||||
// Wi‑Fi 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 Wi‑Fi 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, "Wi‑Fi 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> =
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user