Merge branch 'main' into wifi-aware

This commit is contained in:
callebtc
2025-12-13 16:15:38 +07:00
53 changed files with 2750 additions and 956 deletions
+18 -60
View File
@@ -7,9 +7,10 @@ on:
branches: [ "main", "develop" ] branches: [ "main", "develop" ]
jobs: jobs:
test: verify:
name: Test & Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,7 +40,10 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Upload Test Reports (xml+html) - name: Run lint
run: ./gradlew lintDebug
- name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -48,47 +52,6 @@ jobs:
**/build/test-results/ **/build/test-results/
**/build/reports/tests/ **/build/reports/tests/
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
**/build/test-results/
**/build/reports/tests/
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results - name: Upload lint results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
@@ -97,9 +60,13 @@ jobs:
path: '**/build/reports/lint-results-*.html' path: '**/build/reports/lint-results-*.html'
build: build:
name: Build ${{ matrix.variant }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test, lint] needs: verify
strategy:
matrix:
variant: [Debug, Release]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -126,20 +93,11 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Build debug APK - name: Build ${{ matrix.variant }} APK
run: ./gradlew assembleDebug run: ./gradlew assemble${{ matrix.variant }}
- name: Build release APK - name: Upload ${{ matrix.variant }} APK
run: ./gradlew assembleRelease
- name: Upload debug APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: debug-apk name: ${{ matrix.variant }}-apk
path: app/build/outputs/apk/debug/*.apk path: app/build/outputs/apk/**/*.apk
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/*.apk
+21 -15
View File
@@ -19,7 +19,7 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
with: with:
@@ -38,30 +38,27 @@ jobs:
- name: Grant execute permission for Gradlew - name: Grant execute permission for Gradlew
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build APK - name: Build Release APK (with Tor)
run: ./gradlew assembleRelease --no-daemon --stacktrace run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files - name: List APK files
run: | run: |
echo "APK files built:" echo "APK files built:"
find app/build/outputs/apk/release -name "*.apk" -type f find app/build/outputs/apk -name "*.apk" -type f
- name: Rename APK - name: Rename APK
run: | run: |
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk
- name: DEBUG - name: DEBUG
run: | run: |
set -x set -x
pwd pwd
ls -all ls -all
cd app/build/outputs/ cd app/build/outputs/
ls -all ls -all
tree tree || ls -R
# Optional: Sign APK (requires secrets) # Optional: Sign APK (requires secrets)
# - name: Sign APK # - name: Sign APK
# uses: r0adkll/sign-android-release@v1 # uses: r0adkll/sign-android-release@v1
@@ -75,7 +72,7 @@ jobs:
- name: Upload APK as artifact - name: Upload APK as artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: bitchat-release-apk-${{ github.ref_name }} name: bitchat-android-apk-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk path: app/build/outputs/apk/release/*.apk
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
@@ -88,13 +85,22 @@ jobs:
- name: Download APK artifact - name: Download APK artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: bitchat-release-apk-${{ github.ref_name }} name: bitchat-android-apk-${{ github.ref_name }}
path: . path: release
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: bitchat.apk files: |
release/bitchat-android.apk
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
body: |
## bitchat Android Release
**bitchat-android.apk** (~15MB)
- Secure P2P messaging over Bluetooth mesh and Nostr
- Built-in Tor support (custom Arti build with 16KB page size)
- Compatible with Google Play requirements (Nov 2025+)
- Cross-platform compatible with iOS version
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -1
View File
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
# Other # Other
*.log *.log
.cxx/ .cxx/
*build/ # Gradle/Android build directories (but not tools/arti-build/)
**/build/
!tools/arti-build/
out/ out/
gen/ gen/
*~ *~
@@ -57,3 +59,7 @@ google-services.json
# Keystore files # Keystore files
*.jks *.jks
*.keystore *.keystore
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
+1
View File
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality - **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android - **Location**: Required for BLE scanning on Android
- **Network**: Expand your mesh through public internet relays
- **Notifications**: Message alerts and background updates - **Notifications**: Message alerts and background updates
### Hardware Requirements ### Hardware Requirements
+16 -2
View File
@@ -30,6 +30,12 @@ android {
} }
buildTypes { buildTypes {
debug {
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -37,6 +43,11 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
ndk {
// ARM64-only to minimize APK size (~5.8MB savings)
// Excludes x86_64 as emulator not needed for production builds
abiFilters += listOf("arm64-v8a")
}
} }
} }
compileOptions { compileOptions {
@@ -95,8 +106,11 @@ dependencies {
// WebSocket // WebSocket
implementation(libs.okhttp) implementation(libs.okhttp)
// Arti (Tor in Rust) Android bridge - use published AAR with native libs // Arti (Tor in Rust) Android bridge - custom build from latest source
implementation("info.guardianproject:arti-mobile-ex:1.2.3") // Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
// Only included in tor flavor to reduce APK size for standard builds
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
// Google Play Services Location // Google Play Services Location
implementation(libs.gms.location) implementation(libs.gms.location)
+7 -4
View File
@@ -17,9 +17,12 @@
-keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; } -keep class com.bitchat.android.identity.** { *; }
# Arti (Tor) ProGuard rules # Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }
# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; } -keep class info.guardianproject.arti.** { *; }
-keep class org.torproject.jni.** { *; } -keep class org.torproject.arti.** { *; }
-keepnames class org.torproject.jni.** -keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.** -dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.jni.** -dontwarn org.torproject.arti.**
+266 -267
View File
@@ -1,271 +1,270 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
slick.mjex.me,39.048,-77.4817
relay.guggero.org,47.3769,8.54169
nostr-relay.online,40.7357,-74.1724
relay.bullishbounty.com,40.7357,-74.1724
nostr.kalf.org,52.3676,4.90414
relay.hook.cafe,40.7357,-74.1724
nostrcheck.me,43.6532,-79.3832
relay.vrtmrz.net,40.7357,-74.1724
nostr.notribe.net,40.8302,-74.1299
nos.lol,50.4754,12.3683
relay.notoshi.win,13.4166,101.335
nostrelites.org,41.8781,-87.6298
nostr-02.czas.top,53.471,9.88208
relay.2nix.de,60.1699,24.9384
nostr.faultables.net,43.6532,-79.3832
nostr.noones.com,50.1109,8.68213
relay.nostromo.social,49.4543,11.0746
keys.nostr1.com,40.7057,-74.0136
relay.toastr.net,40.8054,-74.0241
nostr.spicyz.io,40.7357,-74.1724
shu04.shugur.net,25.2604,55.2989
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.freeplace.nl,52.3676,4.90414
relay.wolfcoil.com,35.6092,139.73
relay2.ngengine.org,43.6532,-79.3832
nostr.davidebtc.me,50.1109,8.68213
inbox.azzamo.net,52.2633,21.0283
relay.arx-ccn.com,50.4754,12.3683
nostr2.girino.org,43.6532,-79.3832
nostr.camalolo.com,24.1469,120.684
nostr.mom,50.4754,12.3683
relayone.soundhsa.com,33.1384,-95.6011
relay.zone667.com,60.1699,24.9384
nr.yay.so,46.2126,6.1154
r.lostr.net,52.3676,4.90414
zap.watch,45.5029,-73.5723
relay.evanverma.com,40.8302,-74.1299
nostr.sagaciousd.com,49.2827,-123.121
dev-nostr.bityacht.io,25.0797,121.234
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostr.night7.space,50.4754,12.3683
wot.basspistol.org,49.4521,11.0767
nostr.hekster.org,37.3986,-121.964
relay.lumina.rocks,49.0291,8.35695
offchain.pub,36.1809,-115.241
satsage.xyz,37.3986,-121.964
nostr.openhoofd.nl,51.9229,4.40833
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
yabu.me,35.6092,139.73
relay.magiccity.live,25.8128,-80.2377
wot.soundhsa.com,33.1384,-95.6011
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.upleb.uk,52.2297,21.0122
soloco.nl,40.7357,-74.1724
nostr.plantroon.com,50.1013,8.62643
relay.angor.io,48.1046,11.6002
relay.sigit.io,50.4754,12.3683
khatru.nostrver.se,51.8933,4.42083
nostrelay.circum.space,51.2217,6.77616
nproxy.kristapsk.lv,60.1699,24.9384
nostr.chaima.info,51.223,6.78245
nostr.bilthon.dev,25.8128,-80.2377
nostr.azzamo.net,52.2633,21.0283
relay.fr13nd5.com,52.5233,13.3426
nostr.agentcampfire.com,50.8933,6.05805
relay.uid.ovh,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
wot.sovbit.host,64.1466,-21.9426
nostr-relay.cbrx.io,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
relay.nostr.wirednet.jp,34.706,135.493
wot.nostr.place,30.2672,-97.7431
relay.libernet.app,43.6532,-79.3832
purpura.cloud,43.6532,-79.3832
adre.su,59.9311,30.3609
strfry.felixzieger.de,50.1013,8.62643
nostr-verified.wellorder.net,45.5201,-122.99
relay.islandbitcoin.com,12.8498,77.6545
gnostr.com,42.6978,23.3246
relay.davidebtc.me,50.1109,8.68213
freelay.sovbit.host,64.1476,-21.9392
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
relay.basspistol.org,46.2044,6.14316
relay.ditto.pub,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.stream.labs.h3.se,59.4016,17.9455
a.nos.lol,50.4754,12.3683
relay.nostraddress.com,40.7357,-74.1724
dev-relay.lnfi.network,39.0997,-94.5786
relay.cosmicbolt.net,37.3986,-121.964
relay03.lnfi.network,39.0997,-94.5786
relay04.lnfi.network,39.0997,-94.5786
nostr.snowbla.de,60.1699,24.9384
santo.iguanatech.net,40.8302,-74.1299
nostr.myshosholoza.co.za,52.3676,4.90414
nostr-01.yakihonne.com,1.32123,103.695
relay.usefusion.ai,38.7134,-78.1591
relay.nostr.place,32.7767,-96.797
relay.bitcoinartclock.com,50.4754,12.3683
nostr.coincards.com,53.5501,-113.469
relay.coinos.io,40.7357,-74.1724
relay01.lnfi.network,39.0997,-94.5786
no.str.cr,9.92857,-84.0528
relay.holzeis.me,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
theoutpost.life,64.1476,-21.9392
articles.layer3.news,37.3387,-121.885
relay.primal.net,40.7357,-74.1724
nostr-2.21crypto.ch,47.4988,8.72369
relay.puresignal.news,40.7357,-74.1724
pyramid.fiatjaf.com,51.5072,-0.127586
relay.wavlake.com,41.2619,-95.8608
nostr.0x7e.xyz,47.4988,8.72369
relay.21e6.cz,50.1682,14.0546
nostr-relay.zimage.com,34.282,-118.439
relay.fundstr.me,42.3601,-71.0589
temp.iris.to,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.agorist.space,52.3734,4.89406
relay.mwaters.net,50.9871,2.12554
wot.utxo.one,40.7128,-74.006
relay.utxo.farm,35.6916,139.768
relayrs.notoshi.win,43.6532,-79.3832
r.bitcoinhold.net,43.6532,-79.3832
nostr.huszonegy.world,47.4979,19.0402
relay.nosto.re,51.8933,4.42083
relay.0xchat.com,1.35208,103.82
relay.nostrhub.tech,49.0291,8.35696
purplerelay.com,50.1109,8.68213
nostr.namek.link,40.7357,-74.1724
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.psfoundation.info,39.0438,-77.4874
wot.dergigi.com,64.1476,-21.9392
nostr.carroarmato0.be,50.9928,3.26317
nostr.21crypto.ch,47.4988,8.72369
relay.nostr.net,50.4754,12.3683
cyberspace.nostr1.com,40.7128,-74.006
srtrelay.c-stellar.net,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
wot.sebastix.social,51.8933,4.42083
nostr.satstralia.com,64.1476,-21.9392
relay.illuminodes.com,47.6061,-122.333
relay.letsfo.com,51.098,17.0321
relay-dev.satlantis.io,40.8302,-74.1299
mhp258zrpiiwn.clorecloud.net,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
nostr.simplex.icu,50.8198,-1.08798
wot.nostr.party,36.1627,-86.7816
shu02.shugur.net,21.4902,39.2246
nostr.casa21.space,40.7357,-74.1724
schnorr.me,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
nostr.lostr.space,43.6532,-79.3832
relay.barine.co,40.7357,-74.1724
nostr-relay.xbytez.io,50.6924,3.20113
relay.javi.space,43.4633,11.8796
nostr.smartflowsocial.com,40.7357,-74.1724
bcast.seutoba.com.br,40.7357,-74.1724
nostr.oxtr.dev,50.4754,12.3683
nostr.n7ekb.net,47.4941,-122.294
fanfares.nostr1.com,40.7057,-74.0136
bcast.girino.org,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
shu05.shugur.net,48.8566,2.35222
nostr-03.dorafactory.org,1.35208,103.82
relay.artx.market,43.652,-79.3633
relay.chorus.community,50.1109,8.68213
nostream.breadslice.com,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.olas.app,50.4754,12.3683
strfry.elswa-dev.online,48.8566,2.35222
nostr-relay.amethyst.name,39.0438,-77.4874
relay.nostr.vet,52.6467,4.7395
relay02.lnfi.network,39.0997,-94.5786 relay02.lnfi.network,39.0997,-94.5786
nos.xmark.cc,50.6924,3.20113 srtrelay.c-stellar.net,43.6532,-79.3832
nostr-01.uid.ovh,40.7357,-74.1724
nostr.liberty.fans,36.9104,-89.5875
nostr.4rs.nl,49.0291,8.35696
wheat.happytavern.co,40.7357,-74.1724
fenrir-s.notoshi.win,40.7357,-74.1724
relay.btcforplebs.com,43.6532,-79.3832
nostr.now,36.55,139.733
orangepiller.org,60.1699,24.9384
relay.satlantis.io,32.8769,-80.0114
relay.cypherflow.ai,48.8566,2.35222
relay.orangepill.ovh,49.1689,-0.358841
wot.brightbolt.net,47.6735,-116.781
wot.tealeaf.dev,33.7488,-84.3877
relay.wellorder.net,45.5201,-122.99
ynostr.yael.at,60.1699,24.9384
ribo.af.nostria.app,-26.2041,28.0473
nostr.tadryanom.me,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
nostr.data.haus,50.4754,12.3683
relay.varke.eu,52.6921,6.19372
relay.artiostr.ch,40.7357,-74.1724
relay.moinsen.com,50.4754,12.3683
nostr.makibisskey.work,40.7357,-74.1724
relay.snort.social,40.7357,-74.1724
premium.primal.net,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.lifpay.me,1.35208,103.82
strfry.openhoofd.nl,51.9229,4.40833
nostrcheck.tnsor.network,43.6532,-79.3832
noxir.kpherox.dev,34.8587,135.509
relay5.bitransfer.org,43.6532,-79.3832
ribo.us.nostria.app,41.5868,-93.625
nostr-pub.wellorder.net,45.5201,-122.99
relay.mostro.network,40.8302,-74.1299
wot.nostr.net,40.7357,-74.1724
relay.nostriot.com,41.5695,-83.9786
nostr.mikoshi.de,50.1109,8.68213
relay.mattybs.lol,40.7357,-74.1724
relay.nostr.band,60.1699,24.9384
strfry.shock.network,41.8959,-88.2169
x.kojira.io,43.6532,-79.3832
relay.etch.social,41.2619,-95.8608
relay.lightning.pub,41.8959,-88.2169
relay.damus.io,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.nostrcheck.me,43.6532,-79.3832
relay.credenso.cafe,43.3601,-80.3127
shu01.shugur.net,21.4902,39.2246
relay.ru.ac.th,13.7584,100.622
relay.goodmorningbitcoin.com,40.7357,-74.1724
relay.bitcoindistrict.org,43.6532,-79.3832
black.nostrcity.club,41.8781,-87.6298
nostr.jerrynya.fun,31.2304,121.474
orangesync.tech,50.1109,8.68213
nostr.blankfors.se,60.1699,24.9384
relay.chatbett.de,28.0199,-82.5248
relay.siamdev.cc,13.8434,100.363
relay.seq1.net,40.7128,-74.006
dizzyspells.nostr1.com,40.7057,-74.0136
strfry.bonsai.com,37.8715,-122.273
nostr.stakey.net,52.3676,4.90414
ithurtswhenip.ee,51.223,6.78245
relay.mccormick.cx,52.3563,4.95714
alien.macneilmediagroup.com,43.6532,-79.3832
relay-rpi.edufeed.org,49.4543,11.0746
relay.aloftus.io,34.0881,-118.379
nostr.girino.org,40.7357,-74.1724
nostr.einundzwanzig.space,50.1109,8.68213
nostr.rikmeijer.nl,50.4754,12.3683
relay2.angor.io,48.1046,11.6002
nostr.zenon.network,43.5009,-70.4428
relay.internationalright-wing.org,-22.5022,-48.7114
relayb.uid.ovh,40.7357,-74.1724
prl.plus,38.9072,-77.0369
relay.ngengine.org,40.7357,-74.1724
nostr.tac.lol,47.4748,-122.273
nostr.88mph.life,43.6532,-79.3832
relay.13room.space,40.7357,-74.1724
relay.hasenpfeffr.com,39.0438,-77.4874
nostr.zoracle.org,45.6018,-121.185
nostr.luisschwab.net,40.7357,-74.1724
relay.electriclifestyle.com,26.2897,-80.1293
vitor.nostr1.com,40.7128,-74.006
relay.origin.land,35.6673,139.751
alienos.libretechsystems.xyz,55.4724,9.87335 alienos.libretechsystems.xyz,55.4724,9.87335
relay.fountain.fm,39.0997,-94.5786 relay.uid.ovh,43.6532,-79.3832
wot.dtonon.com,43.6532,-79.3832 relay.cypherflow.ai,48.8566,2.35222
nostr.hifish.org,47.4043,8.57398 wot.tealeaf.dev,33.7488,-84.3877
relay.divine.video,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
nostr.camalolo.com,24.1469,120.684
wot.sudocarlos.com,51.5072,-0.127586
nostr.vulpem.com,49.4543,11.0746
relay2.angor.io,48.1046,11.6002
relay.nostr.nisshiee.org,37.3387,-121.885
wot.nostr.party,36.1627,-86.7816
nostr.luisschwab.net,43.6532,-79.3832
cyberspace.nostr1.com,40.7128,-74.006
strfry.felixzieger.de,50.1013,8.62643
relay.agora.social,50.7383,15.0648
nostr.n7ekb.net,47.4941,-122.294
relay.bitcoinveneto.org,64.1466,-21.9426
relay.stream.labs.h3.se,59.4016,17.9455
nostr.azzamo.net,52.2633,21.0283
relay.nostrhub.tech,49.0291,8.35696
relay.21e6.cz,50.7383,15.0648
nostr-02.czas.top,51.2277,6.77346
nostr.czas.top,50.1109,8.68213
wot.nostr.place,32.7767,-96.797
strfry.shock.network,39.0438,-77.4874
relay.orangepill.ovh,49.1689,-0.358841
relay.illuminodes.com,47.6061,-122.333
bcast.girino.org,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
nostr.lkjsxc.com,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
relay.damus.io,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3676,4.90414
nostr.88mph.life,60.1699,24.9384
relay.sharegap.net,43.6532,-79.3832
purplerelay.com,50.1109,8.68213
ribo.us.nostria.app,41.5868,-93.625
nostr.snowbla.de,60.1699,24.9384
relay.internationalright-wing.org,-22.5986,-48.8003
relay.btcforplebs.com,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
orangepiller.org,60.1699,24.9384
relay.nosto.re,51.1792,5.89444
relay04.lnfi.network,39.0997,-94.5786
nostr.now,36.55,139.733
relay.binaryrobot.com,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
relay.davidebtc.me,51.5072,-0.127586
purpura.cloud,43.6532,-79.3832
nostr.mikoshi.de,50.1109,8.68213
nostr.simplex.icu,51.5121,-0.0005238
articles.layer3.news,37.3387,-121.885
nostr.spicyz.io,43.6532,-79.3832
nostrcheck.me,43.6532,-79.3832
relay.holzeis.me,43.6532,-79.3832
nostr-01.uid.ovh,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
relay.nostr-check.me,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay-freeharmonypeople.space,38.7223,-9.13934
nostrelay.memory-art.xyz,43.6532,-79.3832
orangesync.tech,50.1109,8.68213
nostr.data.haus,50.4754,12.3683
vitor.nostr1.com,40.7057,-74.0136
nostr.overmind.lol,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.bitcoindistrict.org,43.6532,-79.3832
relay.javi.space,43.4633,11.8796
fanfares.nostr1.com,40.7057,-74.0136
temp.iris.to,43.6532,-79.3832
relay.guggero.org,47.3769,8.54169
relay.degmods.com,50.4754,12.3683
relay.nostr.net,43.6532,-79.3832
relay.wolfcoil.com,35.6092,139.73
relay.sigit.io,50.4754,12.3683
shu04.shugur.net,25.2604,55.2989
nostr.rtvslawenia.com,49.4543,11.0746
nostr.kalf.org,52.3676,4.90414
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.rblb.it,43.7094,10.6582
nostr.ovia.to,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.agorist.space,52.3734,4.89406
relay.jeffg.fyi,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
nostr.huszonegy.world,47.4979,19.0402
nostr.coincards.com,53.5501,-113.469
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
nostream.breadslice.com,1.35208,103.82
ribo.eu.nostria.app,52.3676,4.90414 ribo.eu.nostria.app,52.3676,4.90414
nostr.oxtr.dev,50.4754,12.3683
nostr.jerrynya.fun,31.2304,121.474
black.nostrcity.club,48.8575,2.35138
wheat.happytavern.co,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.zone667.com,60.1699,24.9384
theoutpost.life,64.1476,-21.9392
nostrelay.circum.space,52.3676,4.90414
relay.origin.land,35.6673,139.751
relay.lumina.rocks,49.0291,8.35695
relay.chorus.community,50.1109,8.68213
kotukonostr.onrender.com,37.7775,-122.397
nostrcheck.tnsor.network,43.6532,-79.3832
relay01.lnfi.network,39.0997,-94.5786
bitsat.molonlabe.holdings,51.4012,-1.3147
relay.nsnip.io,60.1699,24.9384
nostr.mehdibekhtaoui.com,49.4939,-1.54813
relay.ru.ac.th,13.7607,100.627
relay.thibautduchene.fr,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
wot.dtonon.com,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
wot.brightbolt.net,47.6735,-116.781
nostr.bond,50.1109,8.68213
relay.vrtmrz.net,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786
wot.sebastix.social,51.1792,5.89444
nostr.hifish.org,47.4043,8.57398
divine.diy,43.6532,-79.3832
bitcoiner.social,39.1585,-94.5728
relay.islandbitcoin.com,12.8498,77.6545
nostr-2.21crypto.ch,47.5356,8.73209
relay.snort.social,53.3498,-6.26031
shu05.shugur.net,48.8566,2.35222
relay.nostrcheck.me,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.getsafebox.app,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
relay.primal.net,43.6532,-79.3832
relay.npubhaus.com,43.6532,-79.3832
nostr.tuckerbradford.com,43.663,-70.2569
nostr.rikmeijer.nl,50.4754,12.3683
nostr.agentcampfire.com,52.3676,4.90414
shu02.shugur.net,21.4902,39.2246
librerelay.aaroniumii.com,43.6532,-79.3832
fenrir-s.notoshi.win,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
ribo.af.nostria.app,-26.2056,28.0337
relay.bitcoinartclock.com,50.4754,12.3683
relay.nostrdice.com,-33.8688,151.209
strfry.elswa-dev.online,50.1109,8.68213
nostr.21crypto.ch,47.5356,8.73209
wot.shaving.kiwi,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.lightning.pub,39.0438,-77.4874
nostr-02.yakihonne.com,1.32123,103.695
relay.mccormick.cx,52.3563,4.95714
ynostr.yael.at,60.1699,24.9384
nostr.faultables.net,43.6532,-79.3832
prl.plus,56.9677,24.1056
santo.iguanatech.net,40.8302,-74.1299
nostr.bitcoiner.social,39.1585,-94.5728
nostr-relay.corb.net,38.8353,-104.822
relay.letsfo.com,51.098,17.0321
nostr.calitabby.net,39.9268,-75.0246
strfry.bonsai.com,37.8715,-122.273
wot.nostr.net,43.6532,-79.3832
bcast.seutoba.com.br,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
relay2.ngengine.org,43.6532,-79.3832
r.bitcoinhold.net,43.6532,-79.3832
ithurtswhenip.ee,51.223,6.78245
freeben666.fr,43.7221,7.15296
relay.notoshi.win,13.311,101.112
yabu.me,35.6092,139.73
dev-nostr.bityacht.io,25.0797,121.234
nostr.commonshub.brussels,49.4543,11.0746
relay.nostx.io,43.6532,-79.3832
relay.nostr.band,60.1699,24.9384
relay.barine.co,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
satsage.xyz,37.3986,-121.964
relay.nostr.wirednet.jp,34.706,135.493
relay.artx.market,43.652,-79.3633
nostr2.girino.org,43.6532,-79.3832
nostr.tadryanom.me,43.6532,-79.3832
relay.satlantis.io,32.8769,-80.0114
nostr.zoracle.org,45.6018,-121.185
nostr.bilthon.dev,25.8128,-80.2377
relay.minibolt.info,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
offchain.pub,47.6743,-117.112
shu01.shugur.net,21.4902,39.2246
nostr-relay.xbytez.io,50.6924,3.20113
nostr.noones.com,50.1109,8.68213
relay.nostriot.com,41.5695,-83.9786
nostr-relay.zimage.com,34.0549,-118.243
relay.siamdev.cc,13.9178,100.424
nostr.4rs.nl,49.0291,8.35696
relay.coinos.io,43.6532,-79.3832
khatru.nostrver.se,51.1792,5.89444
x.kojira.io,43.6532,-79.3832
wot.yesnostr.net,50.9871,2.12554
nostr.casa21.space,43.6532,-79.3832
nos.lol,50.4754,12.3683
relay.mattybs.lol,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
nostr.tac.lol,47.4748,-122.273
relay.nuts.cash,34.0362,-118.443
relay.cosmicbolt.net,37.3986,-121.964
nostr.diakod.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
relay.fr13nd5.com,52.5233,13.3426
relay.trustroots.org,43.6532,-79.3832
relay.thebluepulse.com,49.4521,11.0767
relay.wavefunc.live,34.0362,-118.443
relay.wavlake.com,41.2619,-95.8608
relay.libernet.app,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
relay.fountain.fm,39.0997,-94.5786
relay.hook.cafe,43.6532,-79.3832
notemine.io,52.2026,20.9397
relay.electriclifestyle.com,26.2897,-80.1293
inbox.azzamo.net,52.2633,21.0283
nostr.hekster.org,37.3986,-121.964
relay.routstr.com,43.6532,-79.3832
relay.puresignal.news,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
nostr-03.dorafactory.org,1.35208,103.82
relay.fundstr.me,42.3601,-71.0589
relayone.soundhsa.com,33.1384,-95.6011
nostr.plantroon.com,50.1013,8.62643
relay.ngengine.org,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.toastr.net,40.8054,-74.0241
relay.nostrhub.fr,48.1045,11.6004
relay-rpi.edufeed.org,49.4521,11.0767
relay.bitesize-media.com,49.4543,11.0746
relay.usefusion.ai,38.7134,-78.1591
chat-relay.zap-work.com,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
nostr.ps1829.com,33.8851,130.883
relay.bullishbounty.com,43.6532,-79.3832
nostr.openhoofd.nl,51.9229,4.40833
nostr.blankfors.se,60.1699,24.9384
strfry.openhoofd.nl,51.9229,4.40833
relay.magiccity.live,25.8128,-80.2377
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
slick.mjex.me,39.048,-77.4817
nostr.davidebtc.me,51.5072,-0.127586
alien.macneilmediagroup.com,43.6532,-79.3832
pyramid.treegaze.com,43.6532,-79.3832
relay03.lnfi.network,39.0997,-94.5786
relay.evanverma.com,40.8302,-74.1299
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr.chaima.info,51.223,6.78245
no.str.cr,9.92857,-84.0528
relay.jabato.space,52.52,13.405
adre.su,59.9311,30.3609
relay.hasenpfeffr.com,39.0438,-77.4874
relay.etch.social,41.2619,-95.8608
schnorr.me,43.6532,-79.3832
relay.endfiat.money,43.6532,-79.3832
relay.nostr.place,32.7767,-96.797
nostr.sathoarder.com,48.5734,7.75211
orly.ft.hn,50.4754,12.3683
relay.samt.st,40.8302,-74.1299
nostr-relay.amethyst.name,39.0438,-77.4874
1 Relay URL Latitude Longitude
slick.mjex.me 39.048 -77.4817
relay.guggero.org 47.3769 8.54169
nostr-relay.online 40.7357 -74.1724
relay.bullishbounty.com 40.7357 -74.1724
nostr.kalf.org 52.3676 4.90414
relay.hook.cafe 40.7357 -74.1724
nostrcheck.me 43.6532 -79.3832
relay.vrtmrz.net 40.7357 -74.1724
nostr.notribe.net 40.8302 -74.1299
nos.lol 50.4754 12.3683
relay.notoshi.win 13.4166 101.335
nostrelites.org 41.8781 -87.6298
nostr-02.czas.top 53.471 9.88208
relay.2nix.de 60.1699 24.9384
nostr.faultables.net 43.6532 -79.3832
nostr.noones.com 50.1109 8.68213
relay.nostromo.social 49.4543 11.0746
keys.nostr1.com 40.7057 -74.0136
relay.toastr.net 40.8054 -74.0241
nostr.spicyz.io 40.7357 -74.1724
shu04.shugur.net 25.2604 55.2989
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay.freeplace.nl 52.3676 4.90414
relay.wolfcoil.com 35.6092 139.73
relay2.ngengine.org 43.6532 -79.3832
nostr.davidebtc.me 50.1109 8.68213
inbox.azzamo.net 52.2633 21.0283
relay.arx-ccn.com 50.4754 12.3683
nostr2.girino.org 43.6532 -79.3832
nostr.camalolo.com 24.1469 120.684
nostr.mom 50.4754 12.3683
relayone.soundhsa.com 33.1384 -95.6011
relay.zone667.com 60.1699 24.9384
nr.yay.so 46.2126 6.1154
r.lostr.net 52.3676 4.90414
zap.watch 45.5029 -73.5723
relay.evanverma.com 40.8302 -74.1299
nostr.sagaciousd.com 49.2827 -123.121
dev-nostr.bityacht.io 25.0797 121.234
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.mehdibekhtaoui.com 49.4939 -1.54813
nostr.night7.space 50.4754 12.3683
wot.basspistol.org 49.4521 11.0767
nostr.hekster.org 37.3986 -121.964
relay.lumina.rocks 49.0291 8.35695
offchain.pub 36.1809 -115.241
satsage.xyz 37.3986 -121.964
nostr.openhoofd.nl 51.9229 4.40833
nostrelay.memory-art.xyz 43.6532 -79.3832
nostr-02.yakihonne.com 1.32123 103.695
yabu.me 35.6092 139.73
relay.magiccity.live 25.8128 -80.2377
wot.soundhsa.com 33.1384 -95.6011
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.upleb.uk 52.2297 21.0122
soloco.nl 40.7357 -74.1724
nostr.plantroon.com 50.1013 8.62643
relay.angor.io 48.1046 11.6002
relay.sigit.io 50.4754 12.3683
khatru.nostrver.se 51.8933 4.42083
nostrelay.circum.space 51.2217 6.77616
nproxy.kristapsk.lv 60.1699 24.9384
nostr.chaima.info 51.223 6.78245
nostr.bilthon.dev 25.8128 -80.2377
nostr.azzamo.net 52.2633 21.0283
relay.fr13nd5.com 52.5233 13.3426
nostr.agentcampfire.com 50.8933 6.05805
relay.uid.ovh 43.6532 -79.3832
nostr-rs-relay-ishosta.phamthanh.me 40.7357 -74.1724
wot.sovbit.host 64.1466 -21.9426
nostr-relay.cbrx.io 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
relay.nostr.wirednet.jp 34.706 135.493
wot.nostr.place 30.2672 -97.7431
relay.libernet.app 43.6532 -79.3832
purpura.cloud 43.6532 -79.3832
adre.su 59.9311 30.3609
strfry.felixzieger.de 50.1013 8.62643
nostr-verified.wellorder.net 45.5201 -122.99
relay.islandbitcoin.com 12.8498 77.6545
gnostr.com 42.6978 23.3246
relay.davidebtc.me 50.1109 8.68213
freelay.sovbit.host 64.1476 -21.9392
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.red5d.dev 43.6532 -79.3832
relay.nostriches.club 43.6532 -79.3832
relay.basspistol.org 46.2044 6.14316
relay.ditto.pub 43.6532 -79.3832
nostr.rtvslawenia.com 49.4543 11.0746
relay.stream.labs.h3.se 59.4016 17.9455
a.nos.lol 50.4754 12.3683
relay.nostraddress.com 40.7357 -74.1724
dev-relay.lnfi.network 39.0997 -94.5786
relay.cosmicbolt.net 37.3986 -121.964
relay03.lnfi.network 39.0997 -94.5786
relay04.lnfi.network 39.0997 -94.5786
nostr.snowbla.de 60.1699 24.9384
santo.iguanatech.net 40.8302 -74.1299
nostr.myshosholoza.co.za 52.3676 4.90414
nostr-01.yakihonne.com 1.32123 103.695
relay.usefusion.ai 38.7134 -78.1591
relay.nostr.place 32.7767 -96.797
relay.bitcoinartclock.com 50.4754 12.3683
nostr.coincards.com 53.5501 -113.469
relay.coinos.io 40.7357 -74.1724
relay01.lnfi.network 39.0997 -94.5786
no.str.cr 9.92857 -84.0528
relay.holzeis.me 43.6532 -79.3832
relay.agora.social 50.7383 15.0648
theoutpost.life 64.1476 -21.9392
articles.layer3.news 37.3387 -121.885
relay.primal.net 40.7357 -74.1724
nostr-2.21crypto.ch 47.4988 8.72369
relay.puresignal.news 40.7357 -74.1724
pyramid.fiatjaf.com 51.5072 -0.127586
relay.wavlake.com 41.2619 -95.8608
nostr.0x7e.xyz 47.4988 8.72369
relay.21e6.cz 50.1682 14.0546
nostr-relay.zimage.com 34.282 -118.439
relay.fundstr.me 42.3601 -71.0589
temp.iris.to 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
relay.agorist.space 52.3734 4.89406
relay.mwaters.net 50.9871 2.12554
wot.utxo.one 40.7128 -74.006
relay.utxo.farm 35.6916 139.768
relayrs.notoshi.win 43.6532 -79.3832
r.bitcoinhold.net 43.6532 -79.3832
nostr.huszonegy.world 47.4979 19.0402
relay.nosto.re 51.8933 4.42083
relay.0xchat.com 1.35208 103.82
relay.nostrhub.tech 49.0291 8.35696
purplerelay.com 50.1109 8.68213
nostr.namek.link 40.7357 -74.1724
nostr.thebiglake.org 32.71 -96.6745
nostr-relay.psfoundation.info 39.0438 -77.4874
wot.dergigi.com 64.1476 -21.9392
nostr.carroarmato0.be 50.9928 3.26317
nostr.21crypto.ch 47.4988 8.72369
relay.nostr.net 50.4754 12.3683
cyberspace.nostr1.com 40.7128 -74.006
srtrelay.c-stellar.net 43.6532 -79.3832
relay.nostrdice.com -33.8688 151.209
wot.sebastix.social 51.8933 4.42083
nostr.satstralia.com 64.1476 -21.9392
relay.illuminodes.com 47.6061 -122.333
relay.letsfo.com 51.098 17.0321
relay-dev.satlantis.io 40.8302 -74.1299
mhp258zrpiiwn.clorecloud.net 43.6532 -79.3832
relay.nostrhub.fr 48.1046 11.6002
nostr.simplex.icu 50.8198 -1.08798
wot.nostr.party 36.1627 -86.7816
shu02.shugur.net 21.4902 39.2246
nostr.casa21.space 40.7357 -74.1724
schnorr.me 43.6532 -79.3832
nostr.sathoarder.com 48.5734 7.75211
nostr.lostr.space 43.6532 -79.3832
relay.barine.co 40.7357 -74.1724
nostr-relay.xbytez.io 50.6924 3.20113
relay.javi.space 43.4633 11.8796
nostr.smartflowsocial.com 40.7357 -74.1724
bcast.seutoba.com.br 40.7357 -74.1724
nostr.oxtr.dev 50.4754 12.3683
nostr.n7ekb.net 47.4941 -122.294
fanfares.nostr1.com 40.7057 -74.0136
bcast.girino.org 43.6532 -79.3832
nostr.vulpem.com 49.4543 11.0746
shu05.shugur.net 48.8566 2.35222
nostr-03.dorafactory.org 1.35208 103.82
relay.artx.market 43.652 -79.3633
relay.chorus.community 50.1109 8.68213
nostream.breadslice.com 43.6532 -79.3832
nostr-relay.nextblockvending.com 47.2343 -119.853
relay.olas.app 50.4754 12.3683
strfry.elswa-dev.online 48.8566 2.35222
nostr-relay.amethyst.name 39.0438 -77.4874
relay.nostr.vet 52.6467 4.7395
2 relay02.lnfi.network 39.0997 -94.5786
3 nos.xmark.cc srtrelay.c-stellar.net 50.6924 43.6532 3.20113 -79.3832
nostr-01.uid.ovh 40.7357 -74.1724
nostr.liberty.fans 36.9104 -89.5875
nostr.4rs.nl 49.0291 8.35696
wheat.happytavern.co 40.7357 -74.1724
fenrir-s.notoshi.win 40.7357 -74.1724
relay.btcforplebs.com 43.6532 -79.3832
nostr.now 36.55 139.733
orangepiller.org 60.1699 24.9384
relay.satlantis.io 32.8769 -80.0114
relay.cypherflow.ai 48.8566 2.35222
relay.orangepill.ovh 49.1689 -0.358841
wot.brightbolt.net 47.6735 -116.781
wot.tealeaf.dev 33.7488 -84.3877
relay.wellorder.net 45.5201 -122.99
ynostr.yael.at 60.1699 24.9384
ribo.af.nostria.app -26.2041 28.0473
nostr.tadryanom.me 43.6532 -79.3832
relay.jeffg.fyi 43.6532 -79.3832
relay.bitcoinveneto.org 64.1466 -21.9426
nostr.data.haus 50.4754 12.3683
relay.varke.eu 52.6921 6.19372
relay.artiostr.ch 40.7357 -74.1724
relay.moinsen.com 50.4754 12.3683
nostr.makibisskey.work 40.7357 -74.1724
relay.snort.social 40.7357 -74.1724
premium.primal.net 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.lifpay.me 1.35208 103.82
strfry.openhoofd.nl 51.9229 4.40833
nostrcheck.tnsor.network 43.6532 -79.3832
noxir.kpherox.dev 34.8587 135.509
relay5.bitransfer.org 43.6532 -79.3832
ribo.us.nostria.app 41.5868 -93.625
nostr-pub.wellorder.net 45.5201 -122.99
relay.mostro.network 40.8302 -74.1299
wot.nostr.net 40.7357 -74.1724
relay.nostriot.com 41.5695 -83.9786
nostr.mikoshi.de 50.1109 8.68213
relay.mattybs.lol 40.7357 -74.1724
relay.nostr.band 60.1699 24.9384
strfry.shock.network 41.8959 -88.2169
x.kojira.io 43.6532 -79.3832
relay.etch.social 41.2619 -95.8608
relay.lightning.pub 41.8959 -88.2169
relay.damus.io 43.6532 -79.3832
relay.degmods.com 50.4754 12.3683
relay.nostrcheck.me 43.6532 -79.3832
relay.credenso.cafe 43.3601 -80.3127
shu01.shugur.net 21.4902 39.2246
relay.ru.ac.th 13.7584 100.622
relay.goodmorningbitcoin.com 40.7357 -74.1724
relay.bitcoindistrict.org 43.6532 -79.3832
black.nostrcity.club 41.8781 -87.6298
nostr.jerrynya.fun 31.2304 121.474
orangesync.tech 50.1109 8.68213
nostr.blankfors.se 60.1699 24.9384
relay.chatbett.de 28.0199 -82.5248
relay.siamdev.cc 13.8434 100.363
relay.seq1.net 40.7128 -74.006
dizzyspells.nostr1.com 40.7057 -74.0136
strfry.bonsai.com 37.8715 -122.273
nostr.stakey.net 52.3676 4.90414
ithurtswhenip.ee 51.223 6.78245
relay.mccormick.cx 52.3563 4.95714
alien.macneilmediagroup.com 43.6532 -79.3832
relay-rpi.edufeed.org 49.4543 11.0746
relay.aloftus.io 34.0881 -118.379
nostr.girino.org 40.7357 -74.1724
nostr.einundzwanzig.space 50.1109 8.68213
nostr.rikmeijer.nl 50.4754 12.3683
relay2.angor.io 48.1046 11.6002
nostr.zenon.network 43.5009 -70.4428
relay.internationalright-wing.org -22.5022 -48.7114
relayb.uid.ovh 40.7357 -74.1724
prl.plus 38.9072 -77.0369
relay.ngengine.org 40.7357 -74.1724
nostr.tac.lol 47.4748 -122.273
nostr.88mph.life 43.6532 -79.3832
relay.13room.space 40.7357 -74.1724
relay.hasenpfeffr.com 39.0438 -77.4874
nostr.zoracle.org 45.6018 -121.185
nostr.luisschwab.net 40.7357 -74.1724
relay.electriclifestyle.com 26.2897 -80.1293
vitor.nostr1.com 40.7128 -74.006
relay.origin.land 35.6673 139.751
4 alienos.libretechsystems.xyz 55.4724 9.87335
5 relay.fountain.fm relay.uid.ovh 39.0997 43.6532 -94.5786 -79.3832
6 wot.dtonon.com relay.cypherflow.ai 43.6532 48.8566 -79.3832 2.35222
7 nostr.hifish.org wot.tealeaf.dev 47.4043 33.7488 8.57398 -84.3877
8 relay.divine.video 43.6532 -79.3832
9 relay.tagayasu.xyz 43.6715 -79.38
10 nostr.camalolo.com 24.1469 120.684
11 wot.sudocarlos.com 51.5072 -0.127586
12 nostr.vulpem.com 49.4543 11.0746
13 relay2.angor.io 48.1046 11.6002
14 relay.nostr.nisshiee.org 37.3387 -121.885
15 wot.nostr.party 36.1627 -86.7816
16 nostr.luisschwab.net 43.6532 -79.3832
17 cyberspace.nostr1.com 40.7128 -74.006
18 strfry.felixzieger.de 50.1013 8.62643
19 relay.agora.social 50.7383 15.0648
20 nostr.n7ekb.net 47.4941 -122.294
21 relay.bitcoinveneto.org 64.1466 -21.9426
22 relay.stream.labs.h3.se 59.4016 17.9455
23 nostr.azzamo.net 52.2633 21.0283
24 relay.nostrhub.tech 49.0291 8.35696
25 relay.21e6.cz 50.7383 15.0648
26 nostr-02.czas.top 51.2277 6.77346
27 nostr.czas.top 50.1109 8.68213
28 wot.nostr.place 32.7767 -96.797
29 strfry.shock.network 39.0438 -77.4874
30 relay.orangepill.ovh 49.1689 -0.358841
31 relay.illuminodes.com 47.6061 -122.333
32 bcast.girino.org 43.6532 -79.3832
33 nostr.stakey.net 52.3676 4.90414
34 nostr.lkjsxc.com 43.6532 -79.3832
35 nostr.coincrowd.fund 39.0438 -77.4874
36 relay.damus.io 43.6532 -79.3832
37 nostr.myshosholoza.co.za 52.3676 4.90414
38 nostr.88mph.life 60.1699 24.9384
39 relay.sharegap.net 43.6532 -79.3832
40 purplerelay.com 50.1109 8.68213
41 ribo.us.nostria.app 41.5868 -93.625
42 nostr.snowbla.de 60.1699 24.9384
43 relay.internationalright-wing.org -22.5986 -48.8003
44 relay.btcforplebs.com 43.6532 -79.3832
45 nostr.red5d.dev 43.6532 -79.3832
46 orangepiller.org 60.1699 24.9384
47 relay.nosto.re 51.1792 5.89444
48 relay04.lnfi.network 39.0997 -94.5786
49 nostr.now 36.55 139.733
50 relay.binaryrobot.com 43.6532 -79.3832
51 premium.primal.net 43.6532 -79.3832
52 relay.davidebtc.me 51.5072 -0.127586
53 purpura.cloud 43.6532 -79.3832
54 nostr.mikoshi.de 50.1109 8.68213
55 nostr.simplex.icu 51.5121 -0.0005238
56 articles.layer3.news 37.3387 -121.885
57 nostr.spicyz.io 43.6532 -79.3832
58 nostrcheck.me 43.6532 -79.3832
59 relay.holzeis.me 43.6532 -79.3832
60 nostr-01.uid.ovh 43.6532 -79.3832
61 relay.nostar.org 43.6532 -79.3832
62 soloco.nl 43.6532 -79.3832
63 nostr.thebiglake.org 32.71 -96.6745
64 relay.nostr-check.me 43.6532 -79.3832
65 nproxy.kristapsk.lv 60.1699 24.9384
66 relay-freeharmonypeople.space 38.7223 -9.13934
67 nostrelay.memory-art.xyz 43.6532 -79.3832
68 orangesync.tech 50.1109 8.68213
69 nostr.data.haus 50.4754 12.3683
70 vitor.nostr1.com 40.7057 -74.0136
71 nostr.overmind.lol 43.6532 -79.3832
72 nostr-relay.online 43.6532 -79.3832
73 relay.bitcoindistrict.org 43.6532 -79.3832
74 relay.javi.space 43.4633 11.8796
75 fanfares.nostr1.com 40.7057 -74.0136
76 temp.iris.to 43.6532 -79.3832
77 relay.guggero.org 47.3769 8.54169
78 relay.degmods.com 50.4754 12.3683
79 relay.nostr.net 43.6532 -79.3832
80 relay.wolfcoil.com 35.6092 139.73
81 relay.sigit.io 50.4754 12.3683
82 shu04.shugur.net 25.2604 55.2989
83 nostr.rtvslawenia.com 49.4543 11.0746
84 nostr.kalf.org 52.3676 4.90414
85 relay-testnet.k8s.layer3.news 37.3387 -121.885
86 nostr.rblb.it 43.7094 10.6582
87 nostr.ovia.to 43.6532 -79.3832
88 nostr-relay.nextblockvending.com 47.2343 -119.853
89 relay.agorist.space 52.3734 4.89406
90 relay.jeffg.fyi 43.6532 -79.3832
91 relay.ditto.pub 43.6532 -79.3832
92 nostr.huszonegy.world 47.4979 19.0402
93 nostr.coincards.com 53.5501 -113.469
94 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
95 nostream.breadslice.com 1.35208 103.82
96 ribo.eu.nostria.app 52.3676 4.90414
97 nostr.oxtr.dev 50.4754 12.3683
98 nostr.jerrynya.fun 31.2304 121.474
99 black.nostrcity.club 48.8575 2.35138
100 wheat.happytavern.co 43.6532 -79.3832
101 nostr-relay.psfoundation.info 39.0438 -77.4874
102 relay.zone667.com 60.1699 24.9384
103 theoutpost.life 64.1476 -21.9392
104 nostrelay.circum.space 52.3676 4.90414
105 relay.origin.land 35.6673 139.751
106 relay.lumina.rocks 49.0291 8.35695
107 relay.chorus.community 50.1109 8.68213
108 kotukonostr.onrender.com 37.7775 -122.397
109 nostrcheck.tnsor.network 43.6532 -79.3832
110 relay01.lnfi.network 39.0997 -94.5786
111 bitsat.molonlabe.holdings 51.4012 -1.3147
112 relay.nsnip.io 60.1699 24.9384
113 nostr.mehdibekhtaoui.com 49.4939 -1.54813
114 relay.ru.ac.th 13.7607 100.627
115 relay.thibautduchene.fr 43.6532 -79.3832
116 nos.xmark.cc 50.6924 3.20113
117 wot.dtonon.com 43.6532 -79.3832
118 relay.satmaxt.xyz 43.6532 -79.3832
119 wot.brightbolt.net 47.6735 -116.781
120 nostr.bond 50.1109 8.68213
121 relay.vrtmrz.net 43.6532 -79.3832
122 dev-relay.lnfi.network 39.0997 -94.5786
123 wot.sebastix.social 51.1792 5.89444
124 nostr.hifish.org 47.4043 8.57398
125 divine.diy 43.6532 -79.3832
126 bitcoiner.social 39.1585 -94.5728
127 relay.islandbitcoin.com 12.8498 77.6545
128 nostr-2.21crypto.ch 47.5356 8.73209
129 relay.snort.social 53.3498 -6.26031
130 shu05.shugur.net 48.8566 2.35222
131 relay.nostrcheck.me 43.6532 -79.3832
132 relay.0xchat.com 1.35208 103.82
133 relay.dwadziesciajeden.pl 52.2297 21.0122
134 relay.getsafebox.app 43.6532 -79.3832
135 relay.moinsen.com 50.4754 12.3683
136 relay.primal.net 43.6532 -79.3832
137 relay.npubhaus.com 43.6532 -79.3832
138 nostr.tuckerbradford.com 43.663 -70.2569
139 nostr.rikmeijer.nl 50.4754 12.3683
140 nostr.agentcampfire.com 52.3676 4.90414
141 shu02.shugur.net 21.4902 39.2246
142 librerelay.aaroniumii.com 43.6532 -79.3832
143 fenrir-s.notoshi.win 43.6532 -79.3832
144 relayrs.notoshi.win 43.6532 -79.3832
145 nostr.girino.org 43.6532 -79.3832
146 ribo.af.nostria.app -26.2056 28.0337
147 relay.bitcoinartclock.com 50.4754 12.3683
148 relay.nostrdice.com -33.8688 151.209
149 strfry.elswa-dev.online 50.1109 8.68213
150 nostr.21crypto.ch 47.5356 8.73209
151 wot.shaving.kiwi 43.6532 -79.3832
152 nostr.mom 50.4754 12.3683
153 relay.lightning.pub 39.0438 -77.4874
154 nostr-02.yakihonne.com 1.32123 103.695
155 relay.mccormick.cx 52.3563 4.95714
156 ynostr.yael.at 60.1699 24.9384
157 nostr.faultables.net 43.6532 -79.3832
158 prl.plus 56.9677 24.1056
159 santo.iguanatech.net 40.8302 -74.1299
160 nostr.bitcoiner.social 39.1585 -94.5728
161 nostr-relay.corb.net 38.8353 -104.822
162 relay.letsfo.com 51.098 17.0321
163 nostr.calitabby.net 39.9268 -75.0246
164 strfry.bonsai.com 37.8715 -122.273
165 wot.nostr.net 43.6532 -79.3832
166 bcast.seutoba.com.br 43.6532 -79.3832
167 wot.sovbit.host 64.1466 -21.9426
168 relay2.ngengine.org 43.6532 -79.3832
169 r.bitcoinhold.net 43.6532 -79.3832
170 ithurtswhenip.ee 51.223 6.78245
171 freeben666.fr 43.7221 7.15296
172 relay.notoshi.win 13.311 101.112
173 yabu.me 35.6092 139.73
174 dev-nostr.bityacht.io 25.0797 121.234
175 nostr.commonshub.brussels 49.4543 11.0746
176 relay.nostx.io 43.6532 -79.3832
177 relay.nostr.band 60.1699 24.9384
178 relay.barine.co 43.6532 -79.3832
179 relay.arx-ccn.com 50.4754 12.3683
180 satsage.xyz 37.3986 -121.964
181 relay.nostr.wirednet.jp 34.706 135.493
182 relay.artx.market 43.652 -79.3633
183 nostr2.girino.org 43.6532 -79.3832
184 nostr.tadryanom.me 43.6532 -79.3832
185 relay.satlantis.io 32.8769 -80.0114
186 nostr.zoracle.org 45.6018 -121.185
187 nostr.bilthon.dev 25.8128 -80.2377
188 relay.minibolt.info 43.6532 -79.3832
189 relay.chakany.systems 43.6532 -79.3832
190 nostr.spaceshell.xyz 43.6532 -79.3832
191 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
192 offchain.pub 47.6743 -117.112
193 shu01.shugur.net 21.4902 39.2246
194 nostr-relay.xbytez.io 50.6924 3.20113
195 nostr.noones.com 50.1109 8.68213
196 relay.nostriot.com 41.5695 -83.9786
197 nostr-relay.zimage.com 34.0549 -118.243
198 relay.siamdev.cc 13.9178 100.424
199 nostr.4rs.nl 49.0291 8.35696
200 relay.coinos.io 43.6532 -79.3832
201 khatru.nostrver.se 51.1792 5.89444
202 x.kojira.io 43.6532 -79.3832
203 wot.yesnostr.net 50.9871 2.12554
204 nostr.casa21.space 43.6532 -79.3832
205 nos.lol 50.4754 12.3683
206 relay.mattybs.lol 43.6532 -79.3832
207 nostr-01.yakihonne.com 1.32123 103.695
208 nostr.tac.lol 47.4748 -122.273
209 relay.nuts.cash 34.0362 -118.443
210 relay.cosmicbolt.net 37.3986 -121.964
211 nostr.diakod.com 43.6532 -79.3832
212 relay.mostro.network 40.8302 -74.1299
213 relay.fr13nd5.com 52.5233 13.3426
214 relay.trustroots.org 43.6532 -79.3832
215 relay.thebluepulse.com 49.4521 11.0767
216 relay.wavefunc.live 34.0362 -118.443
217 relay.wavlake.com 41.2619 -95.8608
218 relay.libernet.app 43.6532 -79.3832
219 nostr-relay.cbrx.io 43.6532 -79.3832
220 relay.fountain.fm 39.0997 -94.5786
221 relay.hook.cafe 43.6532 -79.3832
222 notemine.io 52.2026 20.9397
223 relay.electriclifestyle.com 26.2897 -80.1293
224 inbox.azzamo.net 52.2633 21.0283
225 nostr.hekster.org 37.3986 -121.964
226 relay.routstr.com 43.6532 -79.3832
227 relay.puresignal.news 43.6532 -79.3832
228 wot.dergigi.com 64.1476 -21.9392
229 nostr-03.dorafactory.org 1.35208 103.82
230 relay.fundstr.me 42.3601 -71.0589
231 relayone.soundhsa.com 33.1384 -95.6011
232 nostr.plantroon.com 50.1013 8.62643
233 relay.ngengine.org 43.6532 -79.3832
234 relay-dev.satlantis.io 40.8302 -74.1299
235 relay.angor.io 48.1046 11.6002
236 relay.toastr.net 40.8054 -74.0241
237 relay.nostrhub.fr 48.1045 11.6004
238 relay-rpi.edufeed.org 49.4521 11.0767
239 relay.bitesize-media.com 49.4543 11.0746
240 relay.usefusion.ai 38.7134 -78.1591
241 chat-relay.zap-work.com 43.6532 -79.3832
242 nostr.0x7e.xyz 47.4988 8.72369
243 nostr.ps1829.com 33.8851 130.883
244 relay.bullishbounty.com 43.6532 -79.3832
245 nostr.openhoofd.nl 51.9229 4.40833
246 nostr.blankfors.se 60.1699 24.9384
247 strfry.openhoofd.nl 51.9229 4.40833
248 relay.magiccity.live 25.8128 -80.2377
249 relay.goodmorningbitcoin.com 43.6532 -79.3832
250 nostr.notribe.net 40.8302 -74.1299
251 slick.mjex.me 39.048 -77.4817
252 nostr.davidebtc.me 51.5072 -0.127586
253 alien.macneilmediagroup.com 43.6532 -79.3832
254 pyramid.treegaze.com 43.6532 -79.3832
255 relay03.lnfi.network 39.0997 -94.5786
256 relay.evanverma.com 40.8302 -74.1299
257 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
258 nostr.chaima.info 51.223 6.78245
259 no.str.cr 9.92857 -84.0528
260 relay.jabato.space 52.52 13.405
261 adre.su 59.9311 30.3609
262 relay.hasenpfeffr.com 39.0438 -77.4874
263 relay.etch.social 41.2619 -95.8608
264 schnorr.me 43.6532 -79.3832
265 relay.endfiat.money 43.6532 -79.3832
266 relay.nostr.place 32.7767 -96.797
267 nostr.sathoarder.com 48.5734 7.75211
268 orly.ft.hn 50.4754 12.3683
269 relay.samt.st 40.8302 -74.1299
270 nostr-relay.amethyst.name 39.0438 -77.4874
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application import android.app.Application
import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager import com.bitchat.android.net.ArtiTorManager
/** /**
* Main application class for bitchat Android * Main application class for bitchat Android
*/ */
class BitchatApplication : Application() { class BitchatApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize Tor first so any early network goes over Tor // Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { } try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv) // Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this) RelayDirectory.initialize(this)
@@ -0,0 +1,34 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier.Companion
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(32.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.Companion.size(18.dp)
)
}
}
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationManager import android.location.LocationManager
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale import java.util.Locale
/** /**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf<String>() private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList()) private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap()) private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups // For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>() private val resolving = mutableSetOf<String>()
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash) val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh) membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value ?: emptyList()) val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.postValue(updated) _bookmarks.value = updated
persist(updated) persist(updated)
// Resolve friendly name asynchronously // Resolve friendly name asynchronously
resolveNameIfNeeded(gh) resolveNameIfNeeded(gh)
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash) val gh = normalize(geohash)
if (!membership.contains(gh)) return if (!membership.contains(gh)) return
membership.remove(gh) membership.remove(gh)
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh } val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.postValue(updated) _bookmarks.value = updated
// Remove stored name to avoid stale cache growth // Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) { if (names.remove(gh) != null) {
_bookmarkNames.postValue(names) _bookmarkNames.value = names
persistNames(names) persistNames(names)
} }
persist(updated) persist(updated)
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
} }
} }
membership.clear(); membership.addAll(seen) membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered) _bookmarks.value = ordered
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}") Log.e(TAG, "Failed to load bookmarks: ${e.message}")
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (!namesJson.isNullOrEmpty()) { if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType) val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict) _bookmarkNames.value = dict
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}") Log.e(TAG, "Failed to load bookmark names: ${e.message}")
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() { private fun persist() {
try { try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>()) val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply() prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun persistNames() { private fun persistNames() {
try { try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>()) val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply() prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
fun clearAll() { fun clearAll() {
try { try {
membership.clear() membership.clear()
_bookmarks.postValue(emptyList()) _bookmarks.value = emptyList()
_bookmarkNames.postValue(emptyMap()) _bookmarkNames.value = emptyMap()
prefs.edit() prefs.edit()
.remove(STORE_KEY) .remove(STORE_KEY)
.remove(NAMES_STORE_KEY) .remove(NAMES_STORE_KEY)
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
} }
if (!name.isNullOrEmpty()) { if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() val current = _bookmarkNames.value.toMutableMap()
current[gh] = name current[gh] = name
_bookmarkNames.postValue(current) _bookmarkNames.value = current
persistNames(current) persistNames(current)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -10,12 +10,12 @@ import android.location.LocationManager
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.* import java.util.*
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** /**
* Manages location permissions, one-shot location retrieval, and computing geohash channels. * Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
private var dataManager: com.bitchat.android.ui.DataManager? = null private var dataManager: com.bitchat.android.ui.DataManager? = null
// Published state for UI bindings (matching iOS @Published properties) // Published state for UI bindings (matching iOS @Published properties)
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
val permissionState: LiveData<PermissionState> = _permissionState val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList()) private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh) private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: LiveData<ChannelID> = _selectedChannel val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableLiveData(false) private val _teleported = MutableStateFlow(false)
val teleported: LiveData<Boolean> = _teleported val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap()) private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
// Add a new LiveData property to indicate when location is being fetched private val _isLoadingLocation = MutableStateFlow(false)
private val _isLoadingLocation = MutableLiveData(false) val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
// Add a new LiveData property to track if location services are enabled by user private val _locationServicesEnabled = MutableStateFlow(false)
private val _locationServicesEnabled = MutableLiveData(false) val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
init { init {
updatePermissionState() updatePermissionState()
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
when (getCurrentPermissionStatus()) { when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> { PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings") Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.postValue(PermissionState.NOT_DETERMINED) _permissionState.value = PermissionState.NOT_DETERMINED
} }
PermissionState.DENIED, PermissionState.RESTRICTED -> { PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted") Log.d(TAG, "Permission denied or restricted")
_permissionState.postValue(PermissionState.DENIED) _permissionState.value = PermissionState.DENIED
} }
PermissionState.AUTHORIZED -> { PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location") Log.d(TAG, "Permission authorized - requesting location")
_permissionState.postValue(PermissionState.AUTHORIZED) _permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation() requestOneShotLocation()
} }
} }
@@ -180,7 +178,7 @@ class LocationChannelManager private constructor(private val context: Context) {
lastLocation?.let { location -> lastLocation?.let { location ->
when (channel) { when (channel) {
is ChannelID.Mesh -> { is ChannelID.Mesh -> {
_teleported.postValue(false) _teleported.value = false
} }
is ChannelID.Location -> { is ChannelID.Location -> {
val currentGeohash = Geohash.encode( val currentGeohash = Geohash.encode(
@@ -189,7 +187,7 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = channel.channel.level.precision precision = channel.channel.level.precision
) )
val isTeleportedNow = currentGeohash != channel.channel.geohash val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.postValue(isTeleportedNow) _teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})") Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
} }
} }
@@ -201,7 +199,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun setTeleported(teleported: Boolean) { fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported") Log.d(TAG, "Setting teleported status: $teleported")
_teleported.postValue(teleported) _teleported.value = teleported
} }
/** /**
@@ -209,7 +207,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun enableLocationServices() { fun enableLocationServices() {
Log.d(TAG, "enableLocationServices() called by user") Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.postValue(true) _locationServicesEnabled.value = true
saveLocationServicesState(true) saveLocationServicesState(true)
// If we have permission, start location operations // If we have permission, start location operations
@@ -223,15 +221,15 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun disableLocationServices() { fun disableLocationServices() {
Log.d(TAG, "disableLocationServices() called by user") Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.postValue(false) _locationServicesEnabled.value = false
saveLocationServicesState(false) saveLocationServicesState(false)
// Stop any ongoing location operations // Stop any ongoing location operations
endLiveRefresh() endLiveRefresh()
// Clear available channels when location is disabled // Clear available channels when location is disabled
_availableChannels.postValue(emptyList()) _availableChannels.value = emptyList()
_locationNames.postValue(emptyMap()) _locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh // If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) { if (_selectedChannel.value is ChannelID.Location) {
@@ -243,7 +241,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if location services are enabled by the user * Check if location services are enabled by the user
*/ */
fun isLocationServicesEnabled(): Boolean { fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value ?: false return _locationServicesEnabled.value
} }
// MARK: - Location Operations // MARK: - Location Operations
@@ -280,13 +278,13 @@ class LocationChannelManager private constructor(private val context: Context) {
if (lastKnownLocation != null) { if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}") Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation lastLocation = lastKnownLocation
_isLoadingLocation.postValue(false) // Make sure loading state is off _isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation) computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation) reverseGeocodeIfNeeded(lastKnownLocation)
} else { } else {
Log.d(TAG, "No last known location available") Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner // Set loading state to true so UI can show a spinner
_isLoadingLocation.postValue(true) _isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location // Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...") Log.d(TAG, "Requesting fresh location...")
@@ -294,7 +292,7 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}") Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState() updatePermissionState()
} }
} }
@@ -308,7 +306,7 @@ class LocationChannelManager private constructor(private val context: Context) {
reverseGeocodeIfNeeded(location) reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now // Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
// Remove this listener after getting the update // Remove this listener after getting the update
try { try {
@@ -322,13 +320,13 @@ class LocationChannelManager private constructor(private val context: Context) {
// Request a fresh location update using getCurrentLocation instead of continuous updates // Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() { private fun requestFreshLocation() {
if (!hasLocationPermission()) { if (!hasLocationPermission()) {
_isLoadingLocation.postValue(false) // Turn off loading state if no permission _isLoadingLocation.value = false // Turn off loading state if no permission
return return
} }
try { try {
// Set loading state to true to indicate we're actively trying to get a location // Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.postValue(true) _isLoadingLocation.value = true
// Try common providers in order of preference // Try common providers in order of preference
val providers = listOf( val providers = listOf(
@@ -358,7 +356,7 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.w(TAG, "Received null location from getCurrentLocation") Log.w(TAG, "Received null location from getCurrentLocation")
} }
// Update loading state to indicate we have a location now // Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
} }
) )
} else { } else {
@@ -378,14 +376,14 @@ class LocationChannelManager private constructor(private val context: Context) {
// If no provider was available, turn off loading state // If no provider was available, turn off loading state
if (!providerFound) { if (!providerFound) {
Log.w(TAG, "No location providers available") Log.w(TAG, "No location providers available")
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}") Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}") Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
} }
} }
@@ -408,7 +406,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun updatePermissionState() { private fun updatePermissionState() {
val newState = getCurrentPermissionStatus() val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState") Log.d(TAG, "Permission state updated to: $newState")
_permissionState.postValue(newState) _permissionState.value = newState
} }
private fun hasLocationPermission(): Boolean { private fun hasLocationPermission(): Boolean {
@@ -433,13 +431,13 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.v(TAG, "Generated ${level.displayName}: $geohash") Log.v(TAG, "Generated ${level.displayName}: $geohash")
} }
_availableChannels.postValue(result) _availableChannels.value = result
// Recompute teleported status based on current location vs selected channel // Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) { when (selectedChannelValue) {
is ChannelID.Mesh -> { is ChannelID.Mesh -> {
_teleported.postValue(false) _teleported.value = false
} }
is ChannelID.Location -> { is ChannelID.Location -> {
val currentGeohash = Geohash.encode( val currentGeohash = Geohash.encode(
@@ -448,12 +446,9 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = selectedChannelValue.channel.level.precision precision = selectedChannelValue.channel.level.precision
) )
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.postValue(isTeleported) _teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})") Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
} }
null -> {
_teleported.postValue(false)
}
} }
} }
@@ -482,7 +477,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val names = namesByLevel(address) val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names") Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.postValue(names) _locationNames.value = names
} else { } else {
Log.w(TAG, "No reverse geocoding results") Log.w(TAG, "No reverse geocoding results")
} }
@@ -601,26 +596,26 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
if (channel != null) { if (channel != null) {
_selectedChannel.postValue(channel) _selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}") Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else { } else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} else { } else {
Log.w(TAG, "Invalid channel data format in persistence") Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} else { } else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh") Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}") Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} }
@@ -629,7 +624,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun clearPersistedChannel() { fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel() dataManager?.clearLastGeohashChannel()
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
Log.d(TAG, "Cleared persisted channel selection") Log.d(TAG, "Cleared persisted channel selection")
} }
@@ -653,11 +648,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() { private fun loadLocationServicesState() {
try { try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.postValue(enabled) _locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled") Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}") Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.postValue(false) _locationServicesEnabled.value = false
} }
} }
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping // Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init { init {
powerManager.delegate = this powerManager.delegate = this
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>() private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>() private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>() val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers) // RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>() private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress) pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
} }
/** /**
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
} }
// Update connection attempt atomically // Update connection attempt atomically
val attempts = (currentAttempt?.attempts ?: 0) + 1 // If the previous attempt window expired, reset backoff to 1; otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts) pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true return true
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress } subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress) addressPeerMap.remove(deviceAddress)
} }
pendingConnections.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress") Log.d(TAG, "Cleaned up device connection for $deviceAddress")
} }
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear() addressPeerMap.clear()
pendingConnections.clear() pendingConnections.clear()
scanRSSI.clear() scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
} }
/** /**
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -31,13 +32,6 @@ class BluetoothGattClientManager(
companion object { companion object {
private const val TAG = "BluetoothGattClientManager" private const val TAG = "BluetoothGattClientManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// RSSI monitoring constants
private const val RSSI_UPDATE_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS // 5 seconds
} }
// Core Bluetooth components // Core Bluetooth components
@@ -182,10 +176,10 @@ class BluetoothGattClientManager(
Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
} }
} }
delay(RSSI_UPDATE_INTERVAL) delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error in RSSI monitoring: ${e.message}") Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
delay(RSSI_UPDATE_INTERVAL) delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
} }
} }
} }
@@ -231,12 +225,12 @@ class BluetoothGattClientManager(
} }
val scanFilter = ScanFilter.Builder() val scanFilter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID)) .setServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.build() .build()
val scanFilters = listOf(scanFilter) val scanFilters = listOf(scanFilter)
Log.d(TAG, "Starting BLE scan with target service UUID: $SERVICE_UUID") Log.d(TAG, "Starting BLE scan with target service UUID: ${AppConstants.Mesh.Gatt.SERVICE_UUID}")
scanCallback = object : ScanCallback() { scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -321,7 +315,7 @@ class BluetoothGattClientManager(
val scanRecord = result.scanRecord val scanRecord = result.scanRecord
// CRITICAL: Only process devices that have our service UUID // CRITICAL: Only process devices that have our service UUID
val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == AppConstants.Mesh.Gatt.SERVICE_UUID } == true
if (!hasOurService) { if (!hasOurService) {
return return
} }
@@ -456,9 +450,9 @@ class BluetoothGattClientManager(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(SERVICE_UUID) val service = gatt.getService(AppConstants.Mesh.Gatt.SERVICE_UUID)
if (service != null) { if (service != null) {
val characteristic = service.getCharacteristic(CHARACTERISTIC_UUID) val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID)
if (characteristic != null) { if (characteristic != null) {
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn -> connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(characteristic = characteristic) val updatedConn = deviceConn.copy(characteristic = characteristic)
@@ -467,7 +461,7 @@ class BluetoothGattClientManager(
} }
gatt.setCharacteristicNotification(characteristic, true) gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(DESCRIPTOR_UUID) val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
if (descriptor != null) { if (descriptor != null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor) gatt.writeDescriptor(descriptor)
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -28,10 +29,6 @@ class BluetoothGattServerManager(
companion object { companion object {
private const val TAG = "BluetoothGattServerManager" private const val TAG = "BluetoothGattServerManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
} }
// Core Bluetooth components // Core Bluetooth components
@@ -223,7 +220,7 @@ class BluetoothGattServerManager(
return return
} }
if (characteristic.uuid == CHARACTERISTIC_UUID) { if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes") Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value) val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) { if (packet != null) {
@@ -297,7 +294,7 @@ class BluetoothGattServerManager(
// Create characteristic with notification support // Create characteristic with notification support
characteristic = BluetoothGattCharacteristic( characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID, AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
@@ -307,12 +304,12 @@ class BluetoothGattServerManager(
) )
val descriptor = BluetoothGattDescriptor( val descriptor = BluetoothGattDescriptor(
DESCRIPTOR_UUID, AppConstants.Mesh.Gatt.DESCRIPTOR_UUID,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
) )
characteristic?.addDescriptor(descriptor) characteristic?.addDescriptor(descriptor)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic) service.addCharacteristic(characteristic)
gattServer?.addService(service) gattServer?.addService(service)
@@ -357,7 +354,7 @@ class BluetoothGattServerManager(
val settings = powerManager.getAdvertiseSettings() val settings = powerManager.getAdvertiseSettings()
val data = AdvertiseData.Builder() val data = AdvertiseData.Builder()
.addServiceUuid(ParcelUuid(SERVICE_UUID)) .addServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.setIncludeTxPowerLevel(false) .setIncludeTxPowerLevel(false)
.setIncludeDeviceName(false) .setIncludeDeviceName(false)
.build() .build()
@@ -409,25 +409,17 @@ class BluetoothMeshService(private val context: Context) {
val deviceAddress = routed.relayAddress val deviceAddress = routed.relayAddress
val pid = routed.peerID val pid = routed.peerID
if (deviceAddress != null && pid != null) { if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped // First ANNOUNCE over a device connection defines a direct neighbor.
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) { if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE") connectionManager.noteAnnounceReceived(deviceAddress)
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark this peer as directly connected for UI // Mark as directly connected (upgrades from routed if needed)
try { try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Schedule initial sync for this new directly connected peer only // Initial sync for this newly direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
} }
} }
@@ -958,12 +950,11 @@ class BluetoothMeshService(private val context: Context) {
* Send leave announcement * Send leave announcement
*/ */
private fun sendLeaveAnnouncement() { private fun sendLeaveAnnouncement() {
val nickname = delegate?.getNickname() ?: myPeerID
val packet = BitchatPacket( val packet = BitchatPacket(
type = MessageType.LEAVE.value, type = MessageType.LEAVE.value,
ttl = MAX_TTL, ttl = MAX_TTL,
senderID = myPeerID, senderID = myPeerID,
payload = nickname.toByteArray() payload = byteArrayOf()
) )
// Sign the packet before broadcasting // Sign the packet before broadcasting
@@ -180,8 +180,12 @@ class FragmentManager {
incomingFragments.remove(fragmentIDString) incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString) fragmentMetadata.remove(fragmentIDString)
Log.d(TAG, "Successfully reassembled and decoded original packet of ${reassembledData.size} bytes") // Suppress re-broadcast of the reassembled packet by zeroing TTL.
return originalPacket // We already relayed the incoming fragments; setting TTL=0 ensures
// PacketRelayManager will skip relaying this reconstructed packet.
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
return suppressedTtlPacket
} else { } else {
val metadata = fragmentMetadata[fragmentIDString] val metadata = fragmentMetadata[fragmentIDString]
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})") Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
@@ -50,33 +50,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false return false
} }
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS) // Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong() val messageType = MessageType.fromValue(packet.type)
val timeDiff = kotlin.math.abs(currentTime - packetTime)
// if (timeDiff > MESSAGE_TIMEOUT) {
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
// return false
// }
// Duplicate detection // Duplicate detection
val messageID = generateMessageID(packet, peerID) val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) { if (messageType != MessageType.ANNOUNCE) {
Log.d(TAG, "Dropping duplicate packet: $messageID") if (processedMessages.contains(messageID)) {
return false Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
} else {
// Do not deduplicate ANNOUNCE at the security layer.
// They are signed/idempotent and we need to ensure first-announce per-connection can bind.
} }
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// NEW: Signature verification logging (not rejecting yet) // NEW: Signature verification logging (not rejecting yet)
verifyPacketSignatureWithLogging(packet, peerID) verifyPacketSignatureWithLogging(packet, peerID)
@@ -2,6 +2,7 @@ package com.bitchat.android.net
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.bitchat.android.util.AppConstants
import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** /**
* Manages embedded Tor lifecycle & provides SOCKS proxy address. * Tor provider implementation using custom-built Arti (Tor-in-Rust).
* Uses Arti (Tor in Rust) for improved security and reliability. *
* This singleton provides Tor anonymity features using a custom Arti build
* compiled with 16KB page size support for Google Play compliance.
*
* Based on the original TorManager implementation.
*/ */
object TorManager { class ArtiTorManager private constructor() {
private const val TAG = "TorManager" enum class TorState {
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT OFF,
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start STARTING,
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart BOOTSTRAPPING,
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS RUNNING,
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS STOPPING,
ERROR
}
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = "",
val state: TorState = TorState.OFF
)
companion object {
private const val TAG = "ArtiTorManager"
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
@Volatile
private var INSTANCE: ArtiTorManager? = null
fun getInstance(): ArtiTorManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
}
}
}
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false @Volatile
@Volatile private var socksAddr: InetSocketAddress? = null private var initialized = false
private val artiProxyRef = AtomicReference<ArtiProxy?>(null) @Volatile
@Volatile private var lastMode: TorMode = TorMode.OFF private var socksAddr: InetSocketAddress? = null
@Volatile
private var artiProxy: ArtiProxy? = null
@Volatile
private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex() private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF @Volatile
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT private var desiredMode: TorMode = TorMode.OFF
@Volatile private var lastLogTime = AtomicLong(0L) @Volatile
@Volatile private var retryAttempts = 0 private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var bindRetryAttempts = 0 @Volatile
private var lastLogTime = AtomicLong(0L)
@Volatile
private var retryAttempts = 0
@Volatile
private var bindRetryAttempts = 0
private var inactivityJob: Job? = null private var inactivityJob: Job? = null
private var retryJob: Job? = null private var retryJob: Job? = null
private var currentApplication: Application? = null private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR } @Volatile
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus( private val _statusFlow = MutableStateFlow(
val mode: TorMode = TorMode.OFF, TorStatus(
val running: Boolean = false, mode = TorMode.OFF,
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only running = false,
val lastLogLine: String = "", bootstrapPercent = 0,
val state: TorState = TorState.OFF lastLogLine = "",
state = TorState.OFF
)
) )
private val _status = MutableStateFlow(TorStatus()) val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null) private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
fun isProxyEnabled(): Boolean { fun isProxyEnabled(): Boolean {
val s = _status.value val s = _statusFlow.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
socksAddr != null && s.state == TorState.RUNNING
} }
fun init(application: Application) { fun init(application: Application) {
@@ -82,7 +127,21 @@ object TorManager {
currentApplication = application currentApplication = application
TorPreferenceManager.init(application) TorPreferenceManager.init(application)
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak. val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_statusFlow.update { it.copy(lastLogLine = s) }
handleArtiLogLine(s)
}
artiProxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
val savedMode = TorPreferenceManager.get(application) val savedMode = TorPreferenceManager.get(application)
if (savedMode == TorMode.ON) { if (savedMode == TorMode.ON) {
if (currentSocksPort < DEFAULT_SOCKS_PORT) { if (currentSocksPort < DEFAULT_SOCKS_PORT) {
@@ -90,13 +149,15 @@ object TorManager {
} }
desiredMode = savedMode desiredMode = savedMode
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } try {
OkHttpProvider.reset()
} catch (_: Throwable) {
} // Only reset OkHttp during init
} }
appScope.launch { appScope.launch {
applyMode(application, savedMode) applyMode(application, savedMode)
} }
// Observe changes
appScope.launch { appScope.launch {
TorPreferenceManager.modeFlow.collect { mode -> TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode) applyMode(application, mode)
@@ -112,53 +173,63 @@ object TorManager {
try { try {
desiredMode = mode desiredMode = mode
lastMode = mode lastMode = mode
val s = _status.value val s = _statusFlow.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) { if (mode == s.mode && mode != TorMode.OFF &&
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip") (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
) {
Log.i(
TAG,
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
)
return return
} }
when (mode) { when (mode) {
TorMode.OFF -> { TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor") Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING lifecycleState = LifecycleState.STOPPING
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING) _statusFlow.value = _statusFlow.value.copy(
stopArti() // non-suspending immediate request mode = TorMode.OFF,
// Best-effort wait for STOPPED before we declare OFF running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
stopArti()
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
socksAddr = null socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF) _statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.OFF
)
currentSocksPort = DEFAULT_SOCKS_PORT currentSocksPort = DEFAULT_SOCKS_PORT
bindRetryAttempts = 0 bindRetryAttempts = 0
lifecycleState = LifecycleState.STOPPED lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays resetNetworkConnections()
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
} }
TorMode.ON -> { TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti") Log.i(TAG, "applyMode: ON -> starting arti")
// Reset port to default unless we're already using a higher port
if (currentSocksPort < DEFAULT_SOCKS_PORT) { if (currentSocksPort < DEFAULT_SOCKS_PORT) {
currentSocksPort = DEFAULT_SOCKS_PORT currentSocksPort = DEFAULT_SOCKS_PORT
} }
bindRetryAttempts = 0 bindRetryAttempts = 0
lifecycleState = LifecycleState.STARTING lifecycleState = LifecycleState.STARTING
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING) _statusFlow.value = _statusFlow.value.copy(
// Immediately set the planned SOCKS address so all traffic is forced through it, mode = TorMode.ON,
// even before Tor is fully bootstrapped. This prevents any direct connections. running = false,
bootstrapPercent = 0,
state = TorState.STARTING
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
startArti(application, useDelay = false) startArti(application, useDelay = false)
// Defer enabling proxy until bootstrap completes
appScope.launch { appScope.launch {
waitUntilBootstrapped() waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) { if (_statusFlow.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset() resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
} }
} }
} }
@@ -171,84 +242,95 @@ object TorManager {
private suspend fun startArti(application: Application, useDelay: Boolean = false) { private suspend fun startArti(application: Application, useDelay: Boolean = false) {
try { try {
// Ensure any previous instance is fully stopped before starting a new one
stopArtiAndWait() stopArtiAndWait()
Log.i(TAG, "Starting Arti on port $currentSocksPort") Log.i(TAG, "Starting Arti on port $currentSocksPort")
if (useDelay) { if (useDelay) {
delay(RESTART_DELAY_MS) delay(RESTART_DELAY_MS)
} }
val logListener = ArtiLogListener { logLine -> val proxy = artiProxy ?: run {
val text = logLine ?: return@ArtiLogListener Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
val s = text.toString() _statusFlow.update { it.copy(state = TorState.ERROR) }
Log.i(TAG, "arti: $s") return
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
handleArtiLogLine(s)
} }
val proxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start() proxy.start()
lastLogTime.set(System.currentTimeMillis()) lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING) _statusFlow.update {
it.copy(
running = true,
bootstrapPercent = 0,
state = TorState.STARTING
)
}
lifecycleState = LifecycleState.RUNNING lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring() startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR) _statusFlow.update { it.copy(state = TorState.ERROR) }
// Check if this is a bind error
val isBindError = isBindError(e) val isBindError = isBindError(e)
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
bindRetryAttempts++ bindRetryAttempts++
currentSocksPort++ currentSocksPort++
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") Log.w(
// Update planned SOCKS address immediately so all new connections target the new port TAG,
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
// Immediate retry with incremented port, no exponential backoff for bind errors
startArti(application, useDelay = false) startArti(application, useDelay = false)
} else if (isBindError) { } else if (isBindError) {
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
lifecycleState = LifecycleState.STOPPED lifecycleState = LifecycleState.STOPPED
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR) _statusFlow.update {
it.copy(
running = false,
bootstrapPercent = 0,
state = TorState.ERROR
)
}
} else { } else {
// For non-bind errors, use the existing retry mechanism
scheduleRetry(application) scheduleRetry(application)
} }
} }
} }
/**
* Checks if the exception indicates a port binding failure
*/
private fun isBindError(exception: Exception): Boolean { private fun isBindError(exception: Exception): Boolean {
val message = exception.message?.lowercase() ?: "" val message = exception.message?.lowercase() ?: ""
return message.contains("bind") || return message.contains("bind") ||
message.contains("address already in use") || message.contains("address already in use") ||
message.contains("port") && message.contains("use") || message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") || message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind") message.contains("could not bind")
}
/**
* Reset network connections after Tor state changes.
* Rebuilds OkHttp clients and reconnects Nostr relays.
*/
private fun resetNetworkConnections() {
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
}
try {
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) {
}
} }
private fun stopArtiInternal() { private fun stopArtiInternal() {
try { try {
val proxy = artiProxyRef.getAndSet(null) val proxy = artiProxy
if (proxy != null) { if (proxy != null) {
Log.i(TAG, "Stopping Arti…") Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {} try {
proxy.stop()
} catch (_: Throwable) {
}
} }
stopInactivityMonitoring() stopInactivityMonitoring()
stopRetryMonitoring() stopRetryMonitoring()
@@ -260,15 +342,16 @@ object TorManager {
private fun stopArti() { private fun stopArti() {
stopArtiInternal() stopArtiInternal()
socksAddr = null socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING) _statusFlow.value = _statusFlow.value.copy(
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
} }
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
// Request stop
stopArtiInternal() stopArtiInternal()
// Wait for confirmation via logs (Stopped) or timeout
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
// Small grace period before relaunch to let file locks clear
delay(200) delay(200)
} }
@@ -276,7 +359,7 @@ object TorManager {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiAndWait() stopArtiAndWait()
delay(RESTART_DELAY_MS) delay(RESTART_DELAY_MS)
startArti(application, useDelay = false) // Already delayed above startArti(application, useDelay = false)
} }
private fun startInactivityMonitoring() { private fun startInactivityMonitoring() {
@@ -287,13 +370,16 @@ object TorManager {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get() val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) { if (currentMode == TorMode.ON) {
val bootstrapPercent = _status.value.bootstrapPercent val bootstrapPercent = _statusFlow.value.bootstrapPercent
if (bootstrapPercent < 100) { if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") Log.w(
TAG,
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
)
currentApplication?.let { app -> currentApplication?.let { app ->
appScope.launch { appScope.launch {
restartArti(app) restartArti(app)
@@ -316,11 +402,11 @@ object TorManager {
retryJob?.cancel() retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) { if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++ retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch { retryJob = appScope.launch {
delay(delayMs) delay(delayMs)
val currentMode = _status.value.mode val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) { if (currentMode == TorMode.ON) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application) restartArti(application)
@@ -337,50 +423,110 @@ object TorManager {
} }
private suspend fun waitUntilBootstrapped() { private suspend fun waitUntilBootstrapped() {
val current = _status.value val current = _statusFlow.value
if (!current.running) return if (!current.running) return
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
// Suspend until we observe RUNNING at least once
while (true) { while (true) {
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR } val s = statusFlow.first {
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
!it.running ||
it.state == TorState.ERROR
}
if (!s.running || s.state == TorState.ERROR) return if (!s.running || s.state == TorState.ERROR) return
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
} }
} }
private fun handleArtiLogLine(s: String) { private fun handleArtiLogLine(s: String) {
val currentState = _statusFlow.value.state
val currentLifecycle = lifecycleState
when { when {
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING) if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING) completeWaitersIf(TorState.STARTING)
} }
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING) if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING) completeWaitersIf(TorState.STARTING)
} }
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING) s.contains(
"Sufficiently bootstrapped; system SOCKS now functional",
ignoreCase = true
) -> {
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
bootstrapPercent = 75,
state = TorState.BOOTSTRAPPING
)
}
retryAttempts = 0 retryAttempts = 0
bindRetryAttempts = 0 bindRetryAttempts = 0
startInactivityMonitoring() startInactivityMonitoring()
} }
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. if (currentLifecycle != LifecycleState.RUNNING) {
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) return
}
_statusFlow.update {
it.copy(
state = TorState.RUNNING,
bootstrapPercent = 100,
running = true
)
}
completeWaitersIf(TorState.RUNNING) completeWaitersIf(TorState.RUNNING)
} }
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STOPPING, running = false) if (currentLifecycle != LifecycleState.STOPPING) {
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.STOPPING,
running = false
)
}
} }
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0) if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
Log.w(
TAG,
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
)
return
}
_statusFlow.update {
it.copy(
state = TorState.OFF,
running = false,
bootstrapPercent = 0
)
}
completeWaitersIf(TorState.OFF) completeWaitersIf(TorState.OFF)
} }
s.contains("Another process has the lock on our state files", ignoreCase = true) -> { s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
// Signal error; we'll likely need to wait longer before restart _statusFlow.update { it.copy(state = TorState.ERROR) }
_status.value = _status.value.copy(state = TorState.ERROR)
} }
} }
} }
@@ -395,13 +541,11 @@ object TorManager {
val def = CompletableDeferred<TorState>() val def = CompletableDeferred<TorState>()
stateChangeDeferred.getAndSet(def)?.cancel() stateChangeDeferred.getAndSet(def)?.cancel()
return withTimeoutOrNull(timeoutMs) { return withTimeoutOrNull(timeoutMs) {
// Fast-path: if we're already there val cur = _statusFlow.value.state
val cur = _status.value.state
if (cur == target) return@withTimeoutOrNull cur if (cur == target) return@withTimeoutOrNull cur
def.await() def.await()
} }
} }
// Visible for instrumentation tests to validate installation fun isTorAvailable(): Boolean = true
fun installResourcesForTest(application: Application): Boolean { return true }
} }
@@ -42,8 +42,9 @@ object OkHttpProvider {
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress() val torProvider = ArtiTorManager.getInstance()
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON, val socks: InetSocketAddress? = torProvider.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
// even before bootstrap, to prevent any direct connections from occurring. // even before bootstrap, to prevent any direct connections from occurring.
if (socks != null) { if (socks != null) {
val proxy = Proxy(Proxy.Type.SOCKS, socks) val proxy = Proxy(Proxy.Type.SOCKS, socks)
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson import com.bitchat.android.ui.GeoPerson
import java.util.Date import java.util.Date
@@ -112,7 +111,7 @@ class GeohashRepository(
val geohash = currentGeohash val geohash = currentGeohash
if (geohash == null) { if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList()) state.setGeohashPeople(emptyList())
return return
} }
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
@@ -143,7 +142,7 @@ class GeohashRepository(
) )
}.sortedByDescending { it.lastSeen } }.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people) state.setGeohashPeople(people)
} }
fun updateReactiveParticipantCounts() { fun updateReactiveParticipantCounts() {
@@ -155,7 +154,7 @@ class GeohashRepository(
counts[gh] = active counts[gh] = active
} }
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts) state.setGeohashParticipantCounts(counts)
} }
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
import android.util.Log import android.util.Log
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** /**
* Manages location notes (kind=1 text notes with geohash tags) * Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding * iOS-compatible implementation with StateFlow for Android UI binding
*/ */
@MainThread @MainThread
class LocationNotesManager private constructor() { class LocationNotesManager private constructor() {
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
NO_RELAYS NO_RELAYS
} }
// Published state (LiveData for Android) // Published state (StateFlow for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList()) private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _geohash = MutableLiveData<String?>(null) private val _geohash = MutableStateFlow<String?>(null)
val geohash: LiveData<String?> = _geohash val geohash: StateFlow<String?> = _geohash.asStateFlow()
private val _initialLoadComplete = MutableLiveData(false) private val _initialLoadComplete = MutableStateFlow(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
private val _state = MutableLiveData(State.IDLE) private val _state = MutableStateFlow(State.IDLE)
val state: LiveData<State> = _state val state: StateFlow<State> = _state.asStateFlow()
private val _errorMessage = MutableLiveData<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Private state // Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf() private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** /**
* High-level Nostr client that manages identity, connections, and messaging * High-level Nostr client that manages identity, connections, and messaging
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
private var currentIdentity: NostrIdentity? = null private var currentIdentity: NostrIdentity? = null
// Client state // Client state
private val _isInitialized = MutableLiveData<Boolean>() private val _isInitialized = MutableStateFlow(false)
val isInitialized: LiveData<Boolean> = _isInitialized val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableLiveData<String>() private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: LiveData<String> = _currentNpub val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing // Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) { if (currentIdentity != null) {
_currentNpub.postValue(currentIdentity!!.npub) _currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}") Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays // Connect to relays
relayManager.connect() relayManager.connect()
_isInitialized.postValue(true) _isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully") Log.i(TAG, "✅ Nostr client initialized successfully")
} else { } else {
Log.e(TAG, "❌ Failed to load/create Nostr identity") Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.postValue(false) _isInitialized.value = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}") Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.postValue(false) _isInitialized.value = false
} }
} }
} }
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
fun shutdown() { fun shutdown() {
Log.d(TAG, "Shutting down Nostr client") Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect() relayManager.disconnect()
_isInitialized.postValue(false) _isInitialized.value = false
} }
/** /**
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
/** /**
* Get relay connection status * Get relay connection status
*/ */
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/** /**
* Get relay information * Get relay information
*/ */
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods // MARK: - Private Methods
@@ -1,9 +1,10 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
) )
// Published state // Published state
private val _relays = MutableLiveData<List<Relay>>() private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: LiveData<List<Relay>> = _relays val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>() private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state // Internal state
private val relaysList = mutableListOf<Relay>() private val relaysList = mutableListOf<Relay>()
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com" "wss://nostr21.com"
) )
relaysList.addAll(defaultRelayUrls.map { Relay(it) }) relaysList.addAll(defaultRelayUrls.map { Relay(it) })
_relays.postValue(relaysList.toList()) _relays.value = relaysList.toList()
updateConnectionStatus() updateConnectionStatus()
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback // Initialize with empty list as fallback
_relays.postValue(emptyList()) _relays.value = emptyList()
_isConnected.postValue(false) _isConnected.value = false
} }
} }
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
} }
private fun updateRelaysList() { private fun updateRelaysList() {
_relays.postValue(relaysList.toList()) _relays.value = relaysList.toList()
} }
private fun updateConnectionStatus() { private fun updateConnectionStatus() {
val connected = relaysList.any { it.isConnected } val connected = relaysList.any { it.isConnected }
_isConnected.postValue(connected) _isConnected.value = connected
} }
private fun generateSubscriptionId(): String { private fun generateSubscriptionId(): String {
@@ -11,14 +11,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.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.Security
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
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
@@ -28,9 +28,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.debug.DebugSettingsSheet
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
/** /**
* About Sheet for bitchat app information * About Sheet for bitchat app information
* Matches the design language of LocationChannelsSheet * Matches the design language of LocationChannelsSheet
@@ -240,7 +245,7 @@ fun AboutSheet(
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 8.dp) .padding(top = 24.dp, bottom = 8.dp)
) )
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle()
Row( Row(
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -276,8 +281,8 @@ fun AboutSheet(
PoWPreferenceManager.init(context) PoWPreferenceManager.init(context)
} }
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
Column( Column(
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
@@ -383,7 +388,9 @@ fun AboutSheet(
// Network (Tor) section // Network (Tor) section
item(key = "network_section") { item(key = "network_section") {
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) } val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Text( Text(
text = stringResource(R.string.about_network), text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
@@ -398,19 +405,22 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
FilterChip( FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.OFF, selected = torMode.value == TorMode.OFF,
onClick = { onClick = {
torMode.value = com.bitchat.android.net.TorMode.OFF torMode.value = TorMode.OFF
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) TorPreferenceManager.set(context, torMode.value)
}, },
label = { Text("tor off", fontFamily = FontFamily.Monospace) } label = { Text("tor off", fontFamily = FontFamily.Monospace) }
) )
FilterChip( FilterChip(
selected = torMode.value == com.bitchat.android.net.TorMode.ON, selected = torMode.value == TorMode.ON,
onClick = { onClick = {
torMode.value = com.bitchat.android.net.TorMode.ON if (torAvailable) {
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) torMode.value = TorMode.ON
TorPreferenceManager.set(context, torMode.value)
}
}, },
enabled = torAvailable,
label = { label = {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -428,13 +438,49 @@ fun AboutSheet(
} }
} }
) )
if (!torAvailable) {
val tooltipState = rememberTooltipState()
val scope = rememberCoroutineScope()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
},
state = tooltipState
) {
IconButton(
onClick = {
scope.launch {
tooltipState.show()
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.6f
),
modifier = Modifier.size(18.dp)
)
}
}
}
} }
Text( Text(
text = stringResource(R.string.about_tor_route), text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
) )
if (torMode.value == com.bitchat.android.net.TorMode.ON) { if (torMode.value == TorMode.ON) {
val statusText = if (torStatus.running) "Running" else "Stopped" val statusText = if (torStatus.running) "Running" else "Stopped"
// Debug status (temporary) // Debug status (temporary)
Surface( Surface(
@@ -551,18 +597,12 @@ fun AboutSheet(
.height(64.dp) .height(64.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) { ) {
TextButton( CloseButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp),
) { )
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
} }
} }
} }
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/** /**
* Header components for ChatScreen * Header components for ChatScreen
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
fun TorStatusDot( fun TorStatusDot(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when { val dotColor = when {
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
when { when {
selectedPrivatePeer != null -> { selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking // Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups! // Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive( val isFavorite = isFavoriteReactive(
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting // Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader( PrivateChatHeader(
peerID = selectedPrivatePeer, peerID = selectedPrivatePeer,
@@ -523,18 +523,18 @@ private fun MainHeader(
viewModel: ChatViewModel viewModel: ChatViewModel
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.observeAsState(false) val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
// Bookmarks store for current geohash toggle (iOS parity) // Bookmarks store for current geohash toggle (iOS parity)
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -653,8 +653,8 @@ private fun LocationChannelsButton(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
// Get current channel selection from location manager // Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.observeAsState() val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.observeAsState(false) val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) { val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> { is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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.Alignment import androidx.compose.ui.Alignment
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer import com.bitchat.android.ui.media.FullScreenImageViewer
@@ -41,22 +41,22 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable @Composable
fun ChatScreen(viewModel: ChatViewModel) { fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList()) val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.observeAsState() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.observeAsState(emptyMap()) val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.observeAsState(false) val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.observeAsState(false) val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) } var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -78,11 +78,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
showPasswordDialog = showPasswordPrompt showPasswordDialog = showPasswordPrompt
} }
val isConnected by viewModel.isConnected.observeAsState(false) val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching // Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines) // Determine what messages to show based on current context (unified timelines)
val displayMessages = when { val displayMessages = when {
@@ -1,10 +1,17 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** /**
* Centralized state definitions and data classes for the chat system * Centralized state definitions and data classes for the chat system
@@ -21,171 +28,162 @@ data class CommandSuggestion(
/** /**
* Contains all the observable state for the chat system * Contains all the observable state for the chat system
*/ */
class ChatState { class ChatState(
scope: CoroutineScope
) {
// Core messages and peer state // Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList()) private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
private val _connectedPeers = MutableLiveData<List<String>>(emptyList()) private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
private val _nickname = MutableLiveData<String>() private val _nickname = MutableStateFlow<String>("")
val nickname: LiveData<String> = _nickname val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>(false) private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Private chats // Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap()) private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null) private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet()) private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
// Channels // Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet()) private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
private val _currentChannel = MutableLiveData<String?>(null) private val _currentChannel = MutableStateFlow<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap()) private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap()) private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet()) private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
private val _showPasswordPrompt = MutableLiveData<Boolean>(false) private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
private val _passwordPromptChannel = MutableLiveData<String?>(null) private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state // Sidebar state
private val _showSidebar = MutableLiveData(false) private val _showSidebar = MutableStateFlow(false)
val showSidebar: LiveData<Boolean> = _showSidebar val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete // Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false) private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList()) private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
// Mention autocomplete // Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false) private val _showMentionSuggestions = MutableStateFlow(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList()) private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
// Favorites // Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet()) private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Noise session states for peers (for reactive UI updates) // Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
// Peer fingerprint state for reactive favorites (for reactive UI updates) // Peer fingerprint state for reactive favorites (for reactive UI updates)
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap()) private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
// Direct connection status per peer (for live UI updates) // Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap()) private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state // Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false) private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
// Location channels state (for Nostr geohash features) // Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh) private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
private val _isTeleported = MutableLiveData<Boolean>(false) private val _isTeleported = MutableStateFlow<Boolean>(false)
val isTeleported: LiveData<Boolean> = _isTeleported val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
// Geohash people state (iOS-compatible) // Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList()) private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet()) val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
// Geohash participant counts reactive state (for real-time location channel counts) // Geohash participant counts reactive state (for real-time location channel counts)
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap()) private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>() val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>() .map { unreadMap -> unreadMap.values.any { it > 0 } }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
init { val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
// Initialize unread state mediators .map { unreadSet -> unreadSet.isNotEmpty() }
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> .stateIn(
hasUnreadChannels.value = unreadMap.values.any { it > 0 } scope = scope,
} started = WhileSubscribed(5_000),
initialValue = false
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> )
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
// Getters for internal state access // Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList() fun getMessagesValue() = _messages.value
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList() fun getConnectedPeersValue() = _connectedPeers.value
fun getNicknameValue() = _nickname.value fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap() fun getPrivateChatsValue() = _privateChats.value
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() fun getJoinedChannelsValue() = _joinedChannels.value
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getCurrentChannelValue() = _currentChannel.value fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() fun getMentionSuggestionsValue() = _mentionSuggestions.value
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() fun getFavoritePeersValue() = _favoritePeers.value
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value ?: false fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList() fun getGeohashPeopleValue() = _geohashPeople.value
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet() fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap() fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
// Setters for state updates // Setters for state updates
fun setMessages(messages: List<BitchatMessage>) { fun setMessages(messages: List<BitchatMessage>) {
@@ -197,7 +195,7 @@ class ChatState {
} }
fun postTeleportedGeo(teleported: Set<String>) { fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported) _teleportedGeo.value = teleported
} }
fun setNickname(nickname: String) { fun setNickname(nickname: String) {
@@ -269,7 +267,7 @@ class ChatState {
} }
fun setFavoritePeers(favorites: Set<String>) { fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet() val currentValue = _favoritePeers.value
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue") Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}") Log.d("ChatState", "Values equal: ${currentValue == favorites}")
@@ -278,8 +276,7 @@ class ChatState {
// Always set the value - even if equal, this ensures observers are triggered // Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites _favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}") Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
} }
fun setPeerSessionStates(states: Map<String, String>) { fun setPeerSessionStates(states: Map<String, String>) {
@@ -4,8 +4,8 @@ import android.app.Application
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
@@ -47,7 +47,9 @@ class ChatViewModel(
} }
// MARK: - State management // MARK: - State management
private val state = ChatState() private val state = ChatState(
scope = viewModelScope,
)
// Transfer progress tracking // Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>() private val transferMessageMap = mutableMapOf<String, String>()
@@ -103,40 +105,40 @@ class ChatViewModel(
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages val messages: StateFlow<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: LiveData<String> = state.nickname val nickname: StateFlow<String> = state.nickname
val isConnected: LiveData<Boolean> = state.isConnected val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: LiveData<Set<String>> = state.joinedChannels val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: LiveData<String?> = state.currentChannel val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: LiveData<Boolean> = state.showSidebar val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: LiveData<Boolean> = state.isTeleported val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
init { init {
// Note: Mesh service delegate is now set by MainActivity // Note: Mesh service delegate is now set by MainActivity
@@ -570,7 +572,7 @@ class ChatViewModel(
private fun logCurrentFavoriteState() { private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================") Log.i("ChatViewModel", "==============================")
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.* import java.util.*
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
/** /**
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel // Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.observeAsState(false) val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column { Column {
// Header matching iOS style // Header matching iOS style
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.GeohashRepository
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
@@ -55,9 +55,9 @@ class GeohashViewModel(
private var geoTimer: Job? = null private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() { fun initialize() {
subscriptionManager.connect() subscriptionManager.connect()
@@ -73,12 +73,16 @@ class GeohashViewModel(
} }
try { try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel -> viewModelScope.launch {
state.setSelectedLocationChannel(channel) locationChannelManager?.selectedChannel?.collect { channel ->
switchLocationChannel(channel) state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
} }
locationChannelManager?.teleported?.observeForever { teleported -> viewModelScope.launch {
state.setIsTeleported(teleported) locationChannelManager?.teleported?.collect { teleported ->
state.setIsTeleported(teleported)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}") Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
@@ -120,7 +124,7 @@ class GeohashViewModel(
} }
try { try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication()) val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
@@ -231,7 +235,7 @@ class GeohashViewModel(
try { try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex) if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
/** /**
* Location Channels Sheet for selecting geohash-based location channels * Location Channels Sheet for selecting geohash-based location channels
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
// Observe location manager state // Observe location manager state
val permissionState by locationManager.permissionState.observeAsState() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.observeAsState() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state // Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap()) val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
// Observe reactive participant counts // Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// UI state // UI state
var customGeohash by remember { mutableStateOf("") } var customGeohash by remember { mutableStateOf("") }
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
.height(56.dp) .height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) { ) {
TextButton( CloseButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp),
) { )
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
} }
} }
} }
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -35,10 +35,10 @@ fun LocationNotesButton(
val context = LocalContext.current val context = LocalContext.current
// Get channel and permission state // Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled // Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -46,7 +46,7 @@ fun LocationNotesButton(
// Get notes count from LocationNotesManager // Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() } val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList()) val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern) // Only show in mesh mode when location is authorized (iOS pattern)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -25,6 +24,7 @@ import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.LocationNotesManager
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
// State // State
val notes by notesManager.notes.observeAsState(emptyList()) val notes by notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState() val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed) // SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size val count = notes.size
// Get location name (building or block) - matches iOS locationNames lookup // Get location name (building or block) - matches iOS locationNames lookup
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) { if (buildingGeohash != null) {
// Get location name from locationManager // Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationName = locationNames[GeohashChannelLevel.BUILDING] val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK] ?: locationNames[GeohashChannelLevel.BLOCK]
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
*/ */
@Composable @Composable
fun shouldAnimateMessage(messageId: String): Boolean { fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState() val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId) return miningMessages.contains(messageId)
} }
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) { ) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsState() val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
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
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
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
@@ -22,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@@ -39,14 +39,14 @@ fun SidebarOverlay(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.observeAsState() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box( Box(
modifier = modifier modifier = modifier
@@ -110,7 +110,7 @@ fun SidebarOverlay(
// People section - switch between mesh and geohash lists (iOS-compatible) // People section - switch between mesh and geohash lists (iOS-compatible)
item { item {
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) { when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> { is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -291,10 +291,10 @@ fun PeopleSection(
} }
// Observe reactive state for favorites and fingerprints // Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.observeAsState(emptyMap()) val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers // Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
@@ -384,7 +384,7 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1 val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap()) val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem( PeerItem(
peerID = peerID, peerID = peerID,
@@ -1,5 +1,7 @@
package com.bitchat.android.util package com.bitchat.android.util
import java.util.UUID
/** /**
* Centralized application-wide constants. * Centralized application-wide constants.
*/ */
@@ -22,6 +24,12 @@ object AppConstants {
// GATT client RSSI updates // GATT client RSSI updates
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
object Gatt {
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
} }
object Sync { object Sync {
@@ -0,0 +1,9 @@
package info.guardianproject.arti
/**
* Exception thrown by Arti operations.
*
* This exception is thrown when the native Arti library encounters
* an error during initialization, startup, or other operations.
*/
class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -0,0 +1,17 @@
package info.guardianproject.arti
/**
* Listener interface for Arti log messages.
*
* This interface is called from the native Arti implementation whenever
* a log line is produced. It allows the application to monitor Tor's
* bootstrap progress and connection status.
*/
fun interface ArtiLogListener {
/**
* Called when Arti produces a log line.
*
* @param logLine The log message from Arti, or null if no message
*/
fun onLogLine(logLine: String?)
}
@@ -0,0 +1,183 @@
package info.guardianproject.arti
import android.app.Application
import android.util.Log
import org.torproject.arti.ArtiNative
import java.io.File
/**
* Arti Tor proxy implementation compatible with Guardian Project API.
*
* This class provides a wrapper around the custom-built Arti native library,
* maintaining API compatibility with Guardian Project's arti-mobile-ex library.
*
* Usage:
* ```
* val proxy = ArtiProxy.Builder(application)
* .setSocksPort(9050)
* .setDnsPort(9051)
* .setLogListener { logLine -> Log.i("Arti", logLine ?: "") }
* .build()
*
* proxy.start()
* // ... use proxy ...
* proxy.stop()
* ```
*/
class ArtiProxy private constructor(
private val application: Application,
private val socksPort: Int,
private val dnsPort: Int,
private val logListener: ArtiLogListener?
) {
companion object {
private const val TAG = "ArtiProxy"
}
@Volatile
private var isRunning = false
/**
* Start the Arti Tor proxy.
*
* This method:
* 1. Registers log callback
* 2. Initializes Arti runtime with data directory
* 3. Starts SOCKS proxy on configured port
*
* @throws ArtiException if initialization or startup fails
*/
fun start() {
if (isRunning) {
Log.w(TAG, "Arti already running")
return
}
try {
logListener?.let { listener ->
Log.d(TAG, "Registering log callback")
ArtiNative.setLogCallback(listener)
}
val dataDir = getDataDirectory()
Log.i(TAG, "Initializing Arti with data directory: $dataDir")
val initResult = ArtiNative.initialize(dataDir.absolutePath)
if (initResult != 0) {
throw ArtiException("Failed to initialize Arti: error code $initResult")
}
Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)")
val startResult = ArtiNative.startSocksProxy(socksPort)
when (startResult) {
0 -> {
isRunning = true
Log.i(TAG, "Arti started successfully")
}
-1 -> throw ArtiException("Arti client not initialized")
-2 -> throw ArtiException("Tokio runtime not initialized")
-3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)")
else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start Arti", e)
if (e is ArtiException) {
throw e
} else {
throw ArtiException("Failed to start Arti: ${e.message}", e)
}
}
}
/**
* Stop the Arti Tor proxy.
*
* This method gracefully shuts down the Tor client and SOCKS proxy.
*/
fun stop() {
if (!isRunning) {
Log.w(TAG, "Arti not running")
return
}
try {
Log.i(TAG, "Stopping Arti...")
val stopResult = ArtiNative.stop()
if (stopResult != 0) {
Log.w(TAG, "Stop returned error code: $stopResult")
}
isRunning = false
Log.i(TAG, "Arti stopped successfully")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Arti", e)
}
}
/**
* Get or create Arti data directory.
*
* Directory structure:
* - {app_data}/arti/cache - Tor directory cache
* - {app_data}/arti/state - Tor persistent state
*/
private fun getDataDirectory(): File {
val artiDir = File(application.filesDir, "arti")
if (!artiDir.exists()) {
artiDir.mkdirs()
}
File(artiDir, "cache").apply { if (!exists()) mkdirs() }
File(artiDir, "state").apply { if (!exists()) mkdirs() }
return artiDir
}
/**
* Builder for ArtiProxy instances.
*
* Provides fluent API for configuring proxy settings before creation.
*/
class Builder(private val application: Application) {
private var socksPort: Int = 9050
private var dnsPort: Int = 9051
private var logListener: ArtiLogListener? = null
/**
* Set SOCKS proxy port.
* @param port Port number (default: 9050)
* @return this Builder for chaining
*/
fun setSocksPort(port: Int) = apply {
this.socksPort = port
}
/**
* Set DNS port.
* @param port Port number (default: 9051)
* @return this Builder for chaining
*/
fun setDnsPort(port: Int) = apply {
this.dnsPort = port
}
/**
* Set log listener for Arti log messages.
* @param listener Callback for log lines
* @return this Builder for chaining
*/
fun setLogListener(listener: ArtiLogListener) = apply {
this.logListener = listener
}
/**
* Build and return the configured ArtiProxy instance.
* @return Configured ArtiProxy (not yet started)
*/
fun build(): ArtiProxy {
return ArtiProxy(application, socksPort, dnsPort, logListener)
}
}
}
@@ -0,0 +1,54 @@
package org.torproject.arti
import info.guardianproject.arti.ArtiLogListener
/**
* JNI wrapper for custom-built Arti (Tor implementation in Rust)
*
* This class provides native bindings to libarti_android.so compiled from
* the latest Arti source with rustls (no OpenSSL dependency).
*
* Features:
* - Latest Arti v1.7.0 code
* - 16KB page size support (Google Play Nov 2025 ready)
* - Onion service client support
* - Pure Rust TLS (rustls)
*/
object ArtiNative {
init {
System.loadLibrary("arti_android")
}
/**
* Get Arti version string
* @return Version string from native library
*/
external fun getVersion(): String
/**
* Set log callback for Arti logs
* @param callback Callback object with onLogLine(String?) method
*/
external fun setLogCallback(callback: ArtiLogListener)
/**
* Initialize Arti runtime
* @param dataDir Directory for Arti state/cache
* @return 0 on success, error code otherwise
*/
external fun initialize(dataDir: String): Int
/**
* Start SOCKS proxy on specified port
* @param port Port number for SOCKS proxy (e.g., 9050)
* @return 0 on success, error code otherwise
*/
external fun startSocksProxy(port: Int): Int
/**
* Stop Arti and cleanup
* @return 0 on success, error code otherwise
*/
external fun stop(): Int
}
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -378,8 +378,9 @@
<string name="pwd_label">Password</string> <string name="pwd_label">Password</string>
<string name="join">Join</string> <string name="join">Join</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
<!-- Plurals --> <!-- Plurals -->
<plurals name="notification_and_more"> <plurals name="notification_and_more">
<item quantity="one">and %d more</item> <item quantity="one">and %d more</item>
<item quantity="other">and %d more</item> <item quantity="other">and %d more</item>
@@ -5,7 +5,9 @@ import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Before import org.junit.Before
import org.junit.Ignore import org.junit.Ignore
import org.junit.Test import org.junit.Test
@@ -18,7 +20,10 @@ import java.util.Date
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class CommandProcessorTest() { class CommandProcessorTest() {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
private val chatState = ChatState() @OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val chatState = ChatState(scope = testScope)
private lateinit var commandProcessor: CommandProcessor private lateinit var commandProcessor: CommandProcessor
val messageManager: MessageManager = MessageManager(state = chatState) val messageManager: MessageManager = MessageManager(state = chatState)
@@ -26,7 +31,7 @@ class CommandProcessorTest() {
state = chatState, state = chatState,
messageManager = messageManager, messageManager = messageManager,
dataManager = DataManager(context = context), dataManager = DataManager(context = context),
coroutineScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main.immediate) coroutineScope = testScope
) )
private val meshService: BluetoothMeshService = mock() private val meshService: BluetoothMeshService = mock()
-4
View File
@@ -68,12 +68,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# Lifecycle # Lifecycle
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" }
# Navigation # Navigation
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
@@ -130,14 +128,12 @@ compose = [
"androidx-compose-ui-graphics", "androidx-compose-ui-graphics",
"androidx-compose-ui-tooling-preview", "androidx-compose-ui-tooling-preview",
"androidx-compose-material3", "androidx-compose-material3",
"androidx-compose-runtime-livedata",
"androidx-compose-material-icons-extended" "androidx-compose-material-icons-extended"
] ]
lifecycle = [ lifecycle = [
"androidx-lifecycle-runtime-ktx", "androidx-lifecycle-runtime-ktx",
"androidx-lifecycle-viewmodel-compose", "androidx-lifecycle-viewmodel-compose",
"androidx-lifecycle-livedata-ktx"
] ]
cryptography = [ cryptography = [
+1
View File
@@ -0,0 +1 @@
arti-v1.7.0
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "arti-android-wrapper"
version = "1.7.0"
edition = "2021"
[workspace]
# Empty workspace table to exclude from parent workspace
[lib]
crate-type = ["cdylib"]
name = "arti_android"
[dependencies]
arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static-sqlite"] }
tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] }
jni = "0.21"
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Link-time optimization
codegen-units = 1 # Better optimization
strip = true # Strip symbols
panic = "abort" # Smaller panic handler
+223
View File
@@ -0,0 +1,223 @@
# Arti Android Build Tools
This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android.
## Overview
bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides:
- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction)
- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls)
- **16KB page size support**: Required for Google Play (Nov 2025)
- **Full transparency**: Build from official Arti source + our JNI wrapper
## Quick Start
The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to:
1. **Verify the binaries** match the source
2. **Update to a new Arti version**
3. **Modify the JNI wrapper**
## Directory Structure
```
tools/arti-build/
├── README.md # This file
├── build-arti.sh # Main build script (clones Arti, builds .so files)
├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0)
├── Cargo.toml # Rust package configuration
├── src/
│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge)
└── .arti-source/ # [GITIGNORED] Cloned official Arti repo
app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries
├── arm64-v8a/
│ └── libarti_android.so (~5.3MB)
└── x86_64/
└── libarti_android.so (~6.2MB)
```
## Rebuilding from Source
### Prerequisites
1. **Rust toolchain** with Android targets:
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add aarch64-linux-android x86_64-linux-android
```
2. **cargo-ndk** for Android cross-compilation:
```bash
cargo install cargo-ndk
```
3. **Android NDK 25+** (for 16KB page size support):
```bash
# Via Android Studio SDK Manager, or:
$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973"
# Set environment variable
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
```
### Build Commands
```bash
cd tools/arti-build
# Build both architectures (arm64 + x86_64 for emulator)
./build-arti.sh
# Build ARM64 only (smaller, for production releases)
./build-arti.sh --release
# Clean rebuild (re-clone Arti source)
./build-arti.sh --clean
```
The script will:
1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti
2. Checkout the version specified in `ARTI_VERSION`
3. Copy our JNI wrapper into the cloned repo
4. Build with `cargo ndk`
5. Copy `.so` files to `app/src/main/jniLibs/`
### Verification
After building, verify the libraries:
```bash
# Check file sizes
ls -lh ../../app/src/main/jniLibs/*/libarti_android.so
# Verify JNI symbols are exported
nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject
# Verify 16KB page alignment
readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD
# Look for: Align 0x4000 (16KB)
```
## Updating Arti Version
1. **Check available versions**:
```bash
git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v
```
2. **Update the version file**:
```bash
echo "arti-v1.8.0" > ARTI_VERSION
```
3. **Rebuild from scratch**:
```bash
./build-arti.sh --clean
```
4. **Test the build**:
```bash
cd ../..
./gradlew clean assembleDebug
./gradlew installDebug
# Enable Tor in app and verify it works
```
5. **Commit the new libraries**:
```bash
git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION
git commit -m "chore: update Arti to v1.8.0"
```
## JNI Wrapper Architecture
The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust:
```
Kotlin (ArtiNative.kt)
↓ JNI
Rust (lib.rs)
Arti Client (TorClient)
SOCKS5 Proxy (localhost:9060)
```
**Exported JNI Functions**:
- `getVersion()` - Returns Arti version string
- `setLogCallback(callback)` - Registers log listener for bootstrap progress
- `initialize(dataDir)` - Creates Tokio runtime and TorClient
- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port
- `stop()` - Stops SOCKS proxy (TorClient is reused)
**Key Design Decisions**:
- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug)
- Tokio runtime created once and never destroyed
- Log messages bridged to Java via `GlobalRef` callback
## Feature Configuration
Edit `Cargo.toml` to customize Arti features:
```toml
[dependencies]
arti-client = {
path = "../crates/arti-client",
default-features = false,
features = [
"tokio", # Required: async runtime
"rustls", # Required: pure Rust TLS (no OpenSSL)
"compression", # Optional: directory compression
"bridge-client", # Optional: Tor bridge support
"onion-service-client", # Optional: .onion site support
"static-sqlite" # Required: bundled SQLite
]
}
```
## Size Comparison
| Configuration | arm64-v8a | x86_64 | Total | APK Size |
|---------------|-----------|--------|-------|----------|
| Guardian Project AAR | - | - | ~140 MB | ~150 MB |
| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB |
| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB |
**28x size reduction** vs Guardian Project implementation.
## Troubleshooting
### "cargo-ndk not found"
```bash
cargo install cargo-ndk
```
### "Android NDK not found"
```bash
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
```
### "Rust target not installed"
```bash
rustup target add aarch64-linux-android x86_64-linux-android
```
### "Version not found"
Check available versions:
```bash
git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10
```
### Library too large
1. Ensure building with `--release` flag
2. Verify `strip = true` in `Cargo.toml` `[profile.release]`
3. Consider removing optional features
## References
- [Arti Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md)
- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk)
- [Android NDK Guide](https://developer.android.com/ndk/guides)
- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes)
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env bash
#
# Rebuild Arti native libraries from official source
#
# This script clones the official Arti repository, applies our custom JNI wrapper,
# and builds the native libraries for Android. Use this to:
# - Verify the pre-built .so files match the source
# - Update to a new Arti version
# - Debug or modify the wrapper code
#
# Requirements:
# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash)
# - Rust toolchain with Android targets:
# rustup target add aarch64-linux-android x86_64-linux-android
# - cargo-ndk: cargo install cargo-ndk
# - Android NDK 25+ (for 16KB page size support)
#
# Usage:
# ./build-arti.sh # Build both architectures (debug/emulator)
# ./build-arti.sh --release # Build ARM64 only (production)
# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild
set -euo pipefail
# ==============================================================================
# Configuration
# ==============================================================================
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script and project directories
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source"
JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs"
detect_default_ndk_home() {
local candidates=(
"$HOME/Library/Android/sdk/ndk/27.0.12077973"
"$HOME/Library/Android/sdk/ndk"
"$HOME/Library/Android/sdk/ndk-bundle"
"$HOME/Android/Sdk/ndk/27.0.12077973"
"$HOME/Android/Sdk/ndk"
"$HOME/Android/Sdk/ndk-bundle"
)
for candidate in "${candidates[@]}"; do
if [ -d "$candidate" ]; then
echo "$candidate"
return
fi
done
local base
for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do
if [ -d "$base" ]; then
local latest
latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)"
if [ -n "$latest" ]; then
echo "$latest"
return
fi
fi
done
echo ""
}
# Read pinned version
if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then
echo -e "${RED}Error: ARTI_VERSION file not found${NC}"
exit 1
fi
VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")"
# Android NDK path
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
AUTO_NDK_HOME="$(detect_default_ndk_home)"
if [ -n "$AUTO_NDK_HOME" ]; then
ANDROID_NDK_HOME="$AUTO_NDK_HOME"
fi
fi
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}"
echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/<version>)."
exit 1
fi
export ANDROID_NDK_HOME
# Min SDK version (must match bitchat-android minSdk)
MIN_SDK_VERSION=26
# Parse arguments
RELEASE_ONLY=false
CLEAN_BUILD=false
while [[ $# -gt 0 ]]; do
case "$1" in
--release)
RELEASE_ONLY=true
shift
;;
--clean)
CLEAN_BUILD=true
shift
;;
--help|-h)
echo "Usage: $0 [--release] [--clean]"
echo ""
echo "Options:"
echo " --release Build ARM64 only (smaller, for production)"
echo " --clean Remove cached Arti source and rebuild from scratch"
echo ""
exit 0
;;
*)
echo -e "${RED}Error: Unknown argument: $1${NC}"
echo "Run: $0 --help"
exit 1
;;
esac
done
# Architectures to build
if [ "$RELEASE_ONLY" = true ]; then
TARGETS=("aarch64-linux-android")
else
TARGETS=("aarch64-linux-android" "x86_64-linux-android")
fi
# Map Rust targets to Android ABI names
declare -A ABI_MAP=(
["aarch64-linux-android"]="arm64-v8a"
["x86_64-linux-android"]="x86_64"
)
# Toolchain placeholders (set in detect_ndk_host)
NDK_HOST=""
NDK_LLVM_BIN=""
LLVM_STRIP=""
LLVM_NM=""
LLVM_READELF=""
# ==============================================================================
# Functions
# ==============================================================================
print_header() {
echo -e "${BLUE}=========================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}=========================================${NC}"
}
print_success() { echo -e "${GREEN}$1${NC}"; }
print_error() { echo -e "${RED}$1${NC}"; }
print_info() { echo -e "${YELLOW}$1${NC}"; }
detect_ndk_host() {
local uname_s
uname_s="$(uname -s)"
local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt"
local HOST_CANDIDATES=()
case "$uname_s" in
Darwin)
HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64")
;;
Linux)
HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64")
;;
*)
print_error "Unsupported host OS: $uname_s"
exit 1
;;
esac
for candidate in "${HOST_CANDIDATES[@]}"; do
if [ -d "$PREBUILT_DIR/$candidate" ]; then
NDK_HOST="$candidate"
NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin"
LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip"
LLVM_NM="$NDK_LLVM_BIN/llvm-nm"
LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf"
return 0
fi
done
print_error "No compatible NDK toolchain found under $PREBUILT_DIR"
print_info "Searched for: ${HOST_CANDIDATES[*]}"
exit 1
}
check_prerequisites() {
print_header "Checking Prerequisites"
# Bash version
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
print_error "Bash 4+ is required. macOS ships bash 3.2 by default."
print_info "macOS: brew install bash"
print_info "Linux: use your distro package manager (e.g., sudo apt install bash)"
print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)"
exit 1
fi
print_success "Bash found: $BASH_VERSION"
# Git
if ! command -v git >/dev/null 2>&1; then
print_error "git is not installed."
exit 1
fi
print_success "git found: $(git --version)"
# Rust
if ! command -v rustc >/dev/null 2>&1; then
print_error "Rust is not installed. Install from https://rustup.rs/"
exit 1
fi
print_success "Rust found: $(rustc --version)"
# rustup
if ! command -v rustup >/dev/null 2>&1; then
print_error "rustup is required (for managing targets). Install from https://rustup.rs/"
exit 1
fi
print_success "rustup found: $(rustup --version | head -1)"
# cargo-ndk
if ! command -v cargo-ndk >/dev/null 2>&1; then
print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk"
exit 1
fi
print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')"
# NDK
if [ ! -d "$ANDROID_NDK_HOME" ]; then
print_error "Android NDK not found at: $ANDROID_NDK_HOME"
print_info "Set ANDROID_NDK_HOME environment variable to your NDK location"
exit 1
fi
print_success "Android NDK found: $ANDROID_NDK_HOME"
# NDK version (should be 25+)
NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)"
if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then
print_error "Could not parse NDK version from ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
exit 1
fi
if [ "$NDK_VERSION" -lt 25 ]; then
print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support"
exit 1
fi
print_success "NDK version: $NDK_VERSION (supports 16KB page size)"
detect_ndk_host
if [ ! -d "$NDK_LLVM_BIN" ]; then
print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN"
exit 1
fi
print_success "NDK host tag: $NDK_HOST"
if [ ! -x "$LLVM_STRIP" ]; then
print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)"
else
print_success "llvm-strip found"
fi
if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then
print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols."
exit 1
fi
if [ -x "$LLVM_NM" ]; then
print_success "llvm-nm found"
else
print_info "llvm-nm not found, will fall back to system nm"
fi
if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then
print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped."
else
print_success "readelf capability available"
fi
# Android targets
for TARGET in "${TARGETS[@]}"; do
if ! rustup target list --installed | grep -qx "$TARGET"; then
print_error "Rust target $TARGET not installed"
print_info "Run: rustup target add $TARGET"
exit 1
fi
done
print_success "All Rust Android targets installed"
echo ""
}
clone_or_update_arti() {
print_header "Setting up Arti Source (version: $VERSION)"
if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then
print_info "Cleaning existing Arti source..."
rm -rf "$ARTI_SOURCE_DIR"
fi
if [ ! -d "$ARTI_SOURCE_DIR" ]; then
print_info "Cloning official Arti repository..."
git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR"
else
print_info "Using cached Arti source at $ARTI_SOURCE_DIR"
fi
cd "$ARTI_SOURCE_DIR"
print_info "Fetching tags..."
git fetch --tags --quiet
print_info "Checking out version: $VERSION"
git checkout "$VERSION" --quiet 2>/dev/null || {
print_error "Version $VERSION not found. Available versions:"
git tag | grep "^arti-v" | tail -10
exit 1
}
# Ensure clean working tree to avoid cached modifications influencing builds
print_info "Resetting repository state (hard) and cleaning untracked files..."
git reset --hard --quiet
git clean -ffdqx --quiet
print_success "Arti source ready at version $VERSION"
echo ""
}
setup_wrapper() {
print_header "Setting up JNI Wrapper"
WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper"
# Recreate wrapper directory to avoid stale files
rm -rf "$WRAPPER_DIR"
mkdir -p "$WRAPPER_DIR/src"
cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/"
cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/"
print_success "Wrapper files copied to $WRAPPER_DIR"
echo ""
}
build_for_target() {
local TARGET="$1"
local ABI="${ABI_MAP[$TARGET]}"
local OUTPUT_PATH="$JNILIBS_DIR/$ABI"
print_header "Building for $ABI ($TARGET)"
mkdir -p "$OUTPUT_PATH"
print_info "Building Arti Android wrapper..."
cargo ndk \
-t "$TARGET" \
--platform "$MIN_SDK_VERSION" \
-o "$OUTPUT_PATH" \
build --release \
--locked \
--manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml"
local LIB_NAME="libarti_android.so"
local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME"
if [ -f "$NESTED_PATH" ]; then
mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME"
rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true
fi
if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then
print_success "Built: $OUTPUT_PATH/$LIB_NAME"
# Strip debug symbols safely
print_info "Stripping debug symbols..."
if [ -x "$LLVM_STRIP" ]; then
"$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true
print_success "Stripped debug symbols"
else
print_info "Skipping strip (llvm-strip not available)"
fi
local SIZE
SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)"
print_success "Final size: $SIZE"
# Verify 16KB page size alignment (best-effort)
print_info "Verifying 16KB page alignment..."
local READELF_TOOL=""
if [ -x "$LLVM_READELF" ]; then
READELF_TOOL="$LLVM_READELF"
elif command -v readelf >/dev/null 2>&1; then
READELF_TOOL="$(command -v readelf)"
fi
if [ -n "$READELF_TOOL" ]; then
local ALIGNMENT
ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")"
if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then
print_success "16KB page alignment verified: $ALIGNMENT"
else
print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)"
fi
else
print_info "Skipping alignment check (readelf not available)"
fi
else
print_error "Build failed: $LIB_NAME not found"
return 1
fi
echo ""
}
verify_jni_symbols_for_lib() {
local LIB_PATH="$1"
if [ ! -f "$LIB_PATH" ]; then
print_error "Library not found: $LIB_PATH"
return 1
fi
local EXPECTED_SYMBOLS=(
"Java_org_torproject_arti_ArtiNative_getVersion"
"Java_org_torproject_arti_ArtiNative_setLogCallback"
"Java_org_torproject_arti_ArtiNative_initialize"
"Java_org_torproject_arti_ArtiNative_startSocksProxy"
"Java_org_torproject_arti_ArtiNative_stop"
)
local ALL_FOUND=true
for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do
local FOUND=false
if [ -x "$LLVM_NM" ]; then
if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then
FOUND=true
fi
elif command -v nm >/dev/null 2>&1; then
# Fallback (may be unreliable for ELF on macOS)
if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then
FOUND=true
fi
fi
if [ "$FOUND" = true ]; then
print_success " Found: $SYMBOL"
else
print_error " Missing: $SYMBOL"
ALL_FOUND=false
fi
done
if [ "$ALL_FOUND" = true ]; then
print_success "All JNI symbols verified for: $LIB_PATH"
else
print_error "Some JNI symbols are missing for: $LIB_PATH"
return 1
fi
return 0
}
verify_jni_symbols() {
print_header "Verifying JNI Symbols"
print_info "Checking exported JNI symbols..."
local FAILED=false
for TARGET in "${TARGETS[@]}"; do
local ABI="${ABI_MAP[$TARGET]}"
local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so"
print_info "Verifying $ABI: $LIB_PATH"
if ! verify_jni_symbols_for_lib "$LIB_PATH"; then
FAILED=true
fi
done
if [ "$FAILED" = true ]; then
return 1
fi
echo ""
}
show_summary() {
print_header "Build Complete!"
echo -e "${GREEN}Built libraries:${NC}"
for TARGET in "${TARGETS[@]}"; do
local ABI="${ABI_MAP[$TARGET]}"
local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so"
if [ -f "$LIB_PATH" ]; then
local SIZE
SIZE="$(du -h "$LIB_PATH" | cut -f1)"
echo -e " ${GREEN}$ABI:${NC} $SIZE"
fi
done
echo ""
echo -e "${GREEN}Arti version:${NC} $VERSION"
echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti"
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo " 1. Test the build: ./gradlew assembleDebug"
echo " 2. Commit the .so files: git add app/src/main/jniLibs/"
echo ""
echo -e "${GREEN}To update Arti version:${NC}"
echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)"
echo " 2. Run: ./build-arti.sh --clean"
echo ""
}
# ==============================================================================
# Main
# ==============================================================================
main() {
print_header "Arti Android Build Script"
echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}"
echo -e "${BLUE}Version: $VERSION${NC}"
echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}"
echo ""
check_prerequisites
clone_or_update_arti
setup_wrapper
ensure_wrapper_lockfile
for TARGET in "${TARGETS[@]}"; do
build_for_target "$TARGET"
done
verify_jni_symbols
show_summary
}
ensure_wrapper_lockfile() {
print_header "Ensuring wrapper Cargo.lock exists"
local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper"
local LOCKFILE="$WRAPPER_DIR/Cargo.lock"
if [ -f "$LOCKFILE" ]; then
print_success "Cargo.lock already exists"
echo ""
return 0
fi
print_info "Cargo.lock missing; generating it once (network access may be required)..."
(cd "$WRAPPER_DIR" && cargo generate-lockfile)
if [ ! -f "$LOCKFILE" ]; then
print_error "Failed to generate Cargo.lock at $LOCKFILE"
return 1
fi
print_success "Generated Cargo.lock"
echo ""
}
main
+493
View File
@@ -0,0 +1,493 @@
use jni::JNIEnv;
use jni::objects::{JClass, JString, JObject, GlobalRef};
use jni::sys::{jint, jstring};
use jni::JavaVM;
use arti_client::TorClient;
use arti_client::config::TorClientConfigBuilder;
use tor_rtcompat::PreferredRuntime;
use std::sync::{Arc, Mutex, Once};
use std::path::PathBuf;
use anyhow::Result;
// ============================================================================
// Global State
// ============================================================================
/// Global Arti client instance
static ARTI_CLIENT: Mutex<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
/// Global Tokio runtime (must persist for Arti to work)
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
/// Global JavaVM reference (cached on first JNI call)
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
/// Global log callback reference
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
/// Handle to SOCKS server task (for graceful shutdown)
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
/// Initialization flag
static INIT_ONCE: Once = Once::new();
// ============================================================================
// Logging Integration
// ============================================================================
/// Send log message to Java callback
fn send_log_to_java(message: String) {
let vm_opt = JAVA_VM.lock().unwrap();
let callback_opt = LOG_CALLBACK.lock().unwrap();
if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) {
if let Ok(mut env) = vm.attach_current_thread() {
if let Ok(jmessage) = env.new_string(&message) {
let _ = env.call_method(
callback.as_obj(),
"onLogLine",
"(Ljava/lang/String;)V",
&[(&jmessage).into()]
);
}
}
}
}
/// Macro for logging to both Android logcat and Java callback
macro_rules! log_info {
($($arg:tt)*) => {{
let msg = format!($($arg)*);
android_logger::log(&format!("Arti: {}", msg));
send_log_to_java(msg);
}};
}
macro_rules! log_error {
($($arg:tt)*) => {{
let msg = format!("ERROR: {}", format!($($arg)*));
android_logger::log(&format!("Arti: {}", msg));
send_log_to_java(msg);
}};
}
// ============================================================================
// JNI Functions
// ============================================================================
/// Get Arti version string
#[no_mangle]
pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion(
env: JNIEnv,
_class: JClass,
) -> jstring {
// Cache JavaVM on first call
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION"));
let output = env.new_string(version).expect("Couldn't create java string!");
output.into_raw()
}
/// Set log callback for Arti logs
#[no_mangle]
pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback(
env: JNIEnv,
_class: JClass,
callback: JObject,
) {
// Cache JavaVM if not already cached
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
// Store global reference to callback
if let Ok(global_ref) = env.new_global_ref(callback) {
*LOG_CALLBACK.lock().unwrap() = Some(global_ref);
log_info!("Log callback registered");
}
}
/// Initialize Arti runtime
#[no_mangle]
pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize(
mut env: JNIEnv,
_class: JClass,
data_dir: JString,
) -> jint {
// Cache JavaVM if not already cached
if JAVA_VM.lock().unwrap().is_none() {
if let Ok(vm) = env.get_java_vm() {
*JAVA_VM.lock().unwrap() = Some(vm);
}
}
let data_dir_str: String = match env.get_string(&data_dir) {
Ok(s) => s.into(),
Err(e) => {
log_error!("Failed to convert data_dir: {:?}", e);
return -1;
}
};
log_info!("AMEx: state changed to Initialized");
log_info!("Initializing Arti with data directory: {}", data_dir_str);
// Initialize Tokio runtime (once)
INIT_ONCE.call_once(|| {
match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => {
log_info!("Tokio runtime created successfully");
*TOKIO_RUNTIME.lock().unwrap() = Some(rt);
}
Err(e) => {
log_error!("Failed to create Tokio runtime: {:?}", e);
}
}
});
// Check if runtime exists
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
let runtime = match runtime_guard.as_ref() {
Some(rt) => rt,
None => {
log_error!("Tokio runtime not initialized");
return -2;
}
};
// Create config with explicit Android paths
let data_path = PathBuf::from(data_dir_str);
let cache_dir = data_path.join("cache");
let state_dir = data_path.join("state");
// Create directories if they don't exist
std::fs::create_dir_all(&cache_dir).ok();
std::fs::create_dir_all(&state_dir).ok();
let result: Result<()> = runtime.block_on(async {
log_info!("Creating Arti client...");
log_info!("Cache dir: {:?}", cache_dir);
log_info!("State dir: {:?}", state_dir);
// Create config with Android-specific directories
let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir)
.build()?;
// Create client with Android-specific config
let client = TorClient::create_bootstrapped(config).await?;
log_info!("Arti client created successfully");
// Store client globally
*ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client));
Ok(())
});
match result {
Ok(_) => {
log_info!("Arti initialized successfully");
0
}
Err(e) => {
log_error!("Failed to initialize Arti: {:?}", e);
-3
}
}
}
/// Start SOCKS proxy on specified port
#[no_mangle]
pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy(
_env: JNIEnv,
_class: JClass,
port: jint,
) -> jint {
log_info!("AMEx: state changed to Starting");
log_info!("Starting SOCKS proxy on port {}", port);
// Stop any existing SOCKS server first
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
log_info!("Aborting previous SOCKS server task");
handle.abort();
}
let client_guard = ARTI_CLIENT.lock().unwrap();
let client = match client_guard.as_ref() {
Some(c) => Arc::clone(c),
None => {
log_error!("Arti client not initialized - call initialize() first");
return -1;
}
};
drop(client_guard);
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
let runtime = match runtime_guard.as_ref() {
Some(rt) => rt,
None => {
log_error!("Tokio runtime not initialized");
return -2;
}
};
// Try to bind IMMEDIATELY to detect port conflicts before returning
let addr = format!("127.0.0.1:{}", port);
// Use block_on to synchronously attempt binding
let bind_result = runtime.block_on(async {
tokio::net::TcpListener::bind(&addr).await
});
let listener = match bind_result {
Ok(l) => {
log_info!("SOCKS proxy bound to {}", addr);
l
}
Err(e) => {
log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e);
return -3;
}
};
// Now spawn the background task with the already-bound listener
let handle = runtime.spawn(async move {
log_info!("SOCKS proxy listening on {}", addr);
log_info!("Sufficiently bootstrapped; system SOCKS now functional");
// Signal bootstrap completion to bitchat-android (expected by ArtiTorManager)
// This sets bootstrapPercent to 100% and stops inactivity restarts
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
log_info!("We have found that guard [scrubbed] is usable.");
// Accept connections
loop {
match listener.accept().await {
Ok((stream, peer_addr)) => {
log_info!("SOCKS connection from: {}", peer_addr);
let client_clone = Arc::clone(&client);
tokio::spawn(async move {
if let Err(e) = handle_socks_connection(stream, client_clone).await {
log_error!("SOCKS connection error: {:?}", e);
}
});
}
Err(e) => {
log_error!("Failed to accept SOCKS connection: {:?}", e);
break; // Exit loop on error
}
}
}
log_info!("SOCKS proxy task exiting");
});
// Store handle for cleanup
*SOCKS_TASK.lock().unwrap() = Some(handle);
log_info!("SOCKS proxy started on port {}", port);
0
}
/// Handle a single SOCKS connection
async fn handle_socks_connection(
mut stream: tokio::net::TcpStream,
client: Arc<TorClient<PreferredRuntime>>,
) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Simple SOCKS5 handshake
let mut buf = [0u8; 512];
// Read version + methods
let n = stream.read(&mut buf).await?;
if n < 2 {
return Err(anyhow::anyhow!("Invalid SOCKS handshake"));
}
// Send "no auth required" response
stream.write_all(&[0x05, 0x00]).await?;
// Read request
let n = stream.read(&mut buf).await?;
if n < 10 {
return Err(anyhow::anyhow!("Invalid SOCKS request"));
}
// Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2)
let version = buf[0];
let cmd = buf[1];
let atyp = buf[3];
if version != 0x05 {
return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version));
}
if cmd != 0x01 {
// Only support CONNECT command
stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd));
}
// Parse target address and port
let (target_host, target_port) = match atyp {
0x01 => {
// IPv4: 4 bytes
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
let port = u16::from_be_bytes([buf[8], buf[9]]);
(ip, port)
}
0x03 => {
// Domain name: length byte + domain
let len = buf[4] as usize;
if n < 5 + len + 2 {
return Err(anyhow::anyhow!("Invalid domain name length"));
}
let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string();
let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]);
(domain, port)
}
0x04 => {
// IPv6: 16 bytes + 2 bytes port = 22 bytes total
if n < 22 {
stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Truncated IPv6 request"));
}
let ip = format!(
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}",
buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11],
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]
);
let port = u16::from_be_bytes([buf[20], buf[21]]);
(ip, port)
}
_ => {
stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(anyhow::anyhow!("Unsupported address type: {}", atyp));
}
};
log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port);
// Establish Tor connection
let tor_stream = match client.connect((target_host.as_str(), target_port)).await {
Ok(s) => s,
Err(e) => {
log_error!("Failed to connect through Tor: {:?}", e);
// Send SOCKS5 error: general failure
stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
return Err(e.into());
}
};
log_info!("Tor connection established to {}:{}", target_host, target_port);
// Send SOCKS5 success response
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
// Bidirectional data forwarding
let (mut client_read, mut client_write) = stream.split();
let (mut tor_read, mut tor_write) = tor_stream.split();
let client_to_tor = async {
tokio::io::copy(&mut client_read, &mut tor_write).await
};
let tor_to_client = async {
tokio::io::copy(&mut tor_read, &mut client_write).await
};
// Run both directions concurrently, exit when either completes
tokio::select! {
result = client_to_tor => {
if let Err(ref e) = result {
log_error!("Client->Tor copy error: {:?}", e);
}
}
result = tor_to_client => {
if let Err(ref e) = result {
log_error!("Tor->Client copy error: {:?}", e);
}
}
};
log_info!("SOCKS connection closed for {}:{}", target_host, target_port);
Ok(())
}
/// Stop Arti and cleanup
#[no_mangle]
pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop(
_env: JNIEnv,
_class: JClass,
) -> jint {
log_info!("AMEx: state changed to Stopping");
log_info!("Stopping Arti...");
// Abort SOCKS proxy task (releases the port)
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
log_info!("Aborting SOCKS server task");
handle.abort();
}
// Give the abort a moment to complete and release the port
if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() {
rt.block_on(async {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
});
}
// NOTE: We do NOT clear ARTI_CLIENT here!
// The TorClient can be reused for multiple SOCKS proxy start/stop cycles.
// Only clear it if you want to force full reinitialization.
// Uncomment this line only if you want to force reinitialization on every start:
// *ARTI_CLIENT.lock().unwrap() = None;
log_info!("AMEx: state changed to Stopped");
log_info!("Arti stopped successfully");
0
}
// ============================================================================
// Android Logger (simple implementation)
// ============================================================================
mod android_logger {
use std::ffi::CString;
#[allow(non_camel_case_types)]
type c_int = i32;
#[allow(non_camel_case_types)]
type c_char = i8;
extern "C" {
fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int;
}
const ANDROID_LOG_INFO: c_int = 4;
pub fn log(message: &str) {
unsafe {
let tag = CString::new("ArtiNative").unwrap();
let text = CString::new(message).unwrap();
__android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char);
}
}
}