Merge upstream main into Android NDR branch

This commit is contained in:
Dev 2026-07-27 12:59:12 +03:00
commit 50256acaf6
232 changed files with 23641 additions and 3745 deletions

View File

@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up JDK 17
uses: actions/setup-java@v4
@ -25,6 +27,23 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Set up Rust 1.95.0
uses: dtolnay/rust-toolchain@1.95.0
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android
- name: Set up Android NDK
run: sdkmanager "ndk;28.2.13676358"
- name: Install cargo-ndk
run: cargo install cargo-ndk --version 4.1.2 --locked
- name: Build NDR FFI from pinned source
run: ./app/src/main/ndr-ffi/build-android.sh
- name: Verify generated NDR binding is current
run: git diff --exit-code -- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
- name: Grant execute permission for gradlew
run: chmod +x gradlew
@ -71,6 +90,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up JDK 17
uses: actions/setup-java@v4
@ -81,6 +102,20 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Set up Rust 1.95.0
uses: dtolnay/rust-toolchain@1.95.0
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android
- name: Set up Android NDK
run: sdkmanager "ndk;28.2.13676358"
- name: Install cargo-ndk
run: cargo install cargo-ndk --version 4.1.2 --locked
- name: Build NDR FFI from pinned source
run: ./app/src/main/ndr-ffi/build-android.sh
- name: Grant execute permission for gradlew
run: chmod +x gradlew

View File

@ -13,6 +13,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up JDK
uses: actions/setup-java@v4
@ -25,6 +27,23 @@ jobs:
with:
gradle-version: wrapper
- name: Set up Rust 1.95.0
uses: dtolnay/rust-toolchain@1.95.0
with:
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android
- name: Set up Android NDK
run: sdkmanager "ndk;28.2.13676358"
- name: Install cargo-ndk
run: cargo install cargo-ndk --version 4.1.2 --locked
- name: Build NDR FFI from pinned source
run: ./app/src/main/ndr-ffi/build-android.sh
- name: Verify generated NDR binding is current
run: git diff --exit-code -- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
- name: Cache Gradle files
uses: actions/cache@v3
with:

3
.gitignore vendored
View File

@ -63,3 +63,6 @@ google-services.json
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
# Generated from the pinned iris-chat-rs source submodule.
app/src/main/jniLibs/*/libndr_ffi.so

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "vendor/iris-chat-rs"]
path = vendor/iris-chat-rs
url = https://github.com/irislib/iris-chat-rs.git
shallow = true

View File

@ -1,10 +1,26 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
}
val githubReleaseCertSha256 = providers
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
.orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
.orElse("")
val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
.replace(":", "")
.trim()
.lowercase()
require(
normalizedGithubReleaseCertSha256.isEmpty() ||
normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
) {
"BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
}
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
@ -13,8 +29,16 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 33
versionName = "1.7.2"
versionCode = 36
versionName = "1.7.5"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
// Maintainer-coordinated rollout remains dark until kind-1402 lands.
buildConfigField("boolean", "NDR_ROLLOUT_ENABLED", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@ -64,14 +88,12 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
@ -85,6 +107,12 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
// Core Android dependencies
implementation(libs.androidx.core.ktx)
@ -130,6 +158,12 @@ dependencies {
implementation(libs.okhttp)
implementation("net.java.dev.jna:jna:5.13.0@aar")
// WorkManager for background APK downloads
implementation(libs.androidx.work.runtime.ktx)
// HTTP Server for hotspot APK sharing
implementation(libs.nanohttpd)
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
@ -143,7 +177,7 @@ dependencies {
implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation("androidx.exifinterface:exifinterface:1.3.7")
implementation(libs.androidx.exifinterface)
// Testing
testImplementation(libs.bundles.testing)
@ -151,3 +185,12 @@ dependencies {
androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).
// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403.
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty(
"robolectric.dependency.repo.url",
"https://repo.maven.apache.org/maven2"
)
}

View File

@ -17,6 +17,13 @@
-keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; }
# UniFFI's JNA backend resolves exported functions and Structure fields by
# their generated JVM names at runtime. Preserve both sides of that reflective
# boundary in minified release builds.
-keep class uniffi.ndr_ffi.** { *; }
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }

View File

@ -5,6 +5,7 @@
<!-- Internet permissions for Nostr relay connections -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
@ -20,6 +21,19 @@
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions (also used for hotspot APK sharing) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware and WiFi P2P) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
@ -52,6 +66,8 @@
<!-- Hardware features -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
@ -131,5 +147,20 @@
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Hotspot Activity for offline APK sharing -->
<activity
android:name=".hotspot.HotspotActivity"
android:exported="false"
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

@ -1,299 +1,442 @@
Relay URL,Latitude,Longitude
nostr.simplex.icu,50.1109,8.68213
v-relay.d02.vrtmrz.net,34.6937,135.502
relay.angor.io,48.1046,11.6002
nostr.twinkle.lol,51.902,7.6657
relay.klabo.world,47.2343,-119.853
cache.trustr.ing,45.4473,-73.7533
soloco.nl,43.6532,-79.3832
rusty-uat.siberian-albacore.ts.net:8443,35.6764,139.65
relay.artx.market,43.6548,-79.3885
relay.lacompagniemaximus.com,45.3147,-73.8785
relay.qstr.app,50.1109,8.68213
nostr.islandarea.net,35.4669,-97.6473
relay.homeinhk.xyz,35.694,139.754
nostr.overmind.lol,43.6532,-79.3832
no.str.cr,10.074,-84.2155
relay.nostrcheck.me,43.6532,-79.3832
wot.makenomistakes.ca,43.7064,-79.3986
nostr.computingcache.com,34.0356,-118.442
relay.bebond.net,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
strfry.bitsbytom.com,51.4968,-0.018337
nostr.2b9t.xyz,34.0549,-118.243
wot.dergigi.com,64.1476,-21.9392
offchain.pub,39.1585,-94.5728
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.lab.rytswd.com,49.4543,11.0746
relay.hook.cafe,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.minibolt.info,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
relay.erybody.com,41.4513,-81.7021
relay.malxte.de,52.52,13.405
kasztanowa.bieda.it,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
relay.purplefrog.cloud,35.6916,139.768
nostr.vulpem.com,49.4543,11.0746
relay.arx-ccn.com,50.4754,12.3683
relay.mitchelltribe.com,39.0438,-77.4874
nostr.bond,50.1109,8.68213
bitcoiner.social,47.6743,-117.112
slick.mjex.me,39.0418,-77.4744
relay.libernet.app,43.6532,-79.3832
relay.damus.io,43.6532,-79.3832
strfry.openhoofd.nl,51.9229,4.40833
relay.agorist.space,52.3734,4.89406
relay.trotters.cc,43.6532,-79.3832
relay.illuminodes.com,47.6061,-122.333
testr.nymble.world,40.8054,-74.0241
wot.rejecttheframe.xyz,43.6532,-79.3832
nostr.carroarmato0.be,51.0368,3.21186
nostr.mikoshi.de,51.2821,6.78285
nrs-02.darkcloudarcade.com,39.9526,-75.1652
nostr.n7ekb.net,36.1527,-95.9902
nostr.hekster.org,37.3986,-121.964
nostr.faultables.net,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
nostr.easycryptosend.it,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
r.0kb.io,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
dev.relay.stream,43.6532,-79.3832
relay-freeharmonypeople.space,38.7223,-9.13934
r.bitcoinhold.net,43.6532,-79.3832
nostr.aruku.kro.kr,37.3589,127.115
relay.nostriches.club,43.6532,-79.3832
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nos.lol,50.4754,12.3683
nostr.tagomago.me,3.139,101.687
nostr.plantroon.com,50.1013,8.62643
relay.nostrhub.fr,48.1045,11.6004
relay.fundstr.me,42.3601,-71.0589
ephemeral.snowflare.cc,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
nrs-01.darkcloudarcade.com,39.1008,-94.5811
relay.inforsupports.com,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
seed-options-few-cache.trycloudflare.com,43.6532,-79.3832
relay.nostrzh.org,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.decentnewsroom.com,50.4754,12.3683
myvoiceourstory.org,37.3598,-121.981
nostrja-kari.heguro.com,43.6532,-79.3832
relay.hostr.network,41.2619,-95.8608
nostr.wecsats.io,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
rilo.nostria.app,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
nostr.notribe.net,40.8302,-74.1299
relay.mccormick.cx,52.3563,4.95714
relay.openfarmtools.org,60.1699,24.9384
yabu.me,35.6092,139.73
nostr.girino.org,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
wot.nostr.place,32.7767,-96.797
nostr.tac.lol,47.4748,-122.273
nostr.spacecitynode.com,29.7057,-95.2706
relay.sigit.io,50.4754,12.3683
nostr-verified.wellorder.net,45.5201,-122.99
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr-relay.amethyst.name,39.0067,-77.4291
relay.dreamith.to,43.6532,-79.3832
relay.sloannetworks.com,37.5234,-77.3158
nostr.zoracle.org,45.6018,-121.185
ribo.us.nostria.app,43.6532,-79.3832
us-east.nostr.pikachat.org,39.0438,-77.4874
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.wellorder.net,45.5201,-122.99
speakeasy.cellar.social,49.4543,11.0746
relay.notoshi.win,13.3622,100.983
relay.nostrian-conquest.com,41.223,-111.974
relay.astrolabe.finance,39.0438,-77.4874
rele.speyhard.fi,50.1109,8.68213
eu.nostr.pikachat.org,49.4543,11.0746
bridge.tagomago.me,3.139,101.687
aeon.libretechsystems.xyz,55.486,9.86577
relay.samt.st,40.8302,-74.1299
nostrbtc.com,43.6532,-79.3832
relay.henryxplace.eu.org:9988,31.2304,121.474
relay.islandbitcoin.com,12.8498,77.6545
testrelay.era21.space,43.6532,-79.3832
sandbox.registros.aarpia.net,50.1109,8.68213
relay.flashapp.me,43.6548,-79.3885
librerelay.aaroniumii.com,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384
relay.bnos.space,43.6532,-79.3832
adre.su,59.9311,30.3609
relay.veganostr.com,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay.mostro.network,40.8302,-74.1299
relayone.soundhsa.com,39.1008,-94.5811
relay.threenine.services,51.5222,-0.62916
nostr.blankfors.se,60.1699,24.9384
nostr.nodesmap.com,59.3327,18.0656
nostriches.club,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.npubhaus.com,43.6532,-79.3832
relay.paulstephenborile.com,49.4543,11.0746
nostr.dlcdevkit.com,40.0992,-83.1141
vault.iris.to,43.6532,-79.3832
test.thedude.cloud,50.1109,8.68213
dev-relay.nostreon.com,60.1699,24.9384
relay.nostriot.com,41.5695,-83.9786
relay-dev.gulugulu.moe,43.6532,-79.3832
freelay.sovbit.dev,60.1699,24.9384
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
relay.bikel.ink,60.1699,24.9384
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
relay.olas.app,60.1699,24.9384
relay.degmods.com,50.4754,12.3683
nostr.spicyz.io,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
purpura.cloud,43.6532,-79.3832
ribo.nostria.app,43.6532,-79.3832
wot.shaving.kiwi,43.6532,-79.3832
nostr.azzamo.net,52.2633,21.0283
relay.keykeeper.world,40.7824,-74.0711
nostrelay.circum.space,52.3676,4.90414
nostrcheck.me,43.6532,-79.3832
relay.snotr.nl:49999,52.0195,4.42946
nostr-relay.xbytez.io,50.6924,3.20113
relay.nostrverse.net,43.6532,-79.3832
bcast.girino.org,43.6532,-79.3832
blossom.gnostr.cloud,43.6532,-79.3832
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
nostr.tadryanom.me,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nittom.nostr1.com,40.7057,-74.0136
nostr.88mph.life,52.1941,-2.21905
nostrride.io,37.3986,-121.964
nostr-relay.corb.net,38.8353,-104.822
relay.fizx.uk,51.9194,19.1451
ynostr.yael.at,60.1699,24.9384
relay.liberbitworld.org,43.6532,-79.3832
relay-rpi.edufeed.org,49.4521,11.0767
relay.shadowbip.com,50.1109,8.68213
nostr-pub.wellorder.net,45.5201,-122.99
relay.openresist.com,43.6532,-79.3832
relay.typedcypher.com,51.5072,-0.127586
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.bitmacro.cloud,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
relay02.lnfi.network,35.6764,139.65
nostr-relay.nextblockvending.com,47.2343,-119.853
x.kojira.io,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
relay.ohstr.com,43.6532,-79.3832
wot.nostr.party,36.1627,-86.7816
spookstr2.nostr1.com,40.7057,-74.0136
wot.dtonon.com,43.6532,-79.3832
relay-nl.zombi.cloudrodion.com,50.8943,6.06237
artisanspyramid.libretechsystems.xyz,55.486,9.86577
ribo.eu.nostria.app,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
relay.satlantis.io,40.8054,-74.0241
nostrsgp.notribe.net,1.32123,103.695
nostr.quali.chat,60.1699,24.9384
bitchat.nostr1.com,40.7057,-74.0136
nostr.sathoarder.com,48.5734,7.75211
relay.cosmicbolt.net,37.3986,-121.964
nostr.mom,50.4754,12.3683
nostr.oxtr.dev,50.4754,12.3683
strfry.bonsai.com,37.8716,-122.273
relay.getsafebox.app,43.6532,-79.3832
relay.staging.commonshub.brussels,49.4543,11.0746
relay.orangepill.ovh,49.1689,-0.358841
relay.mostr.pub,43.6532,-79.3832
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.0xchat.com,43.6532,-79.3832
srtrelay.c-stellar.net,43.6532,-79.3832
relay.saturnali.net,46.2044,6.14316
strfry.shock.network,39.0438,-77.4874
fanfares.nostr1.com,40.7057,-74.0136
nostrcity-club.fly.dev,48.8566,2.35222
nostr.ps1829.com,33.8851,130.883
relay.bao.network,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
video.czas.plus,50.1109,8.68213
librepress.libretechsystems.xyz,55.4724,9.87335
relay.laantungir.net,-19.4692,-42.5315
relay5.bitransfer.org,43.6532,-79.3832
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.fountain.fm,43.6532,-79.3832
relay.wisp.talk,49.4543,11.0746
nostr-relay.zeabur.app,25.0797,121.234
relay.nostrmap.net,60.1699,24.9384
relay01.lnfi.network,35.6764,139.65
relay.satnam.pub,43.6532,-79.3832
0x-nostr-relay.fly.dev,48.8566,2.35222
relay.beginningend.com,35.2227,-97.4786
cs-relay.nostrdev.com,50.4754,12.3683
relay.cypherflow.ai,48.8575,2.35138
nostr.spaceshell.xyz,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
nostr.data.haus,50.4754,12.3683
bucket.coracle.social,37.7775,-122.397
nostr.bitcoiner.social,47.6743,-117.112
relay.gorrdy.cz,43.6532,-79.3832
relay.sovbit.dev,60.1699,24.9384
wot.codingarena.top,50.4754,12.3683
relay.directsponsor.net,42.8864,-78.8784
nostr2.thalheim.io,49.4543,11.0746
nostr.pbfs.io,50.4754,12.3683
relay.bullishbounty.com,43.6532,-79.3832
nostr.bitczat.pl,60.1699,24.9384
nostr.rtvslawenia.com,49.4543,11.0746
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
armada.sharegap.net,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
nostr.dpinkerton.com,49.2509,-123.01
nostr.myshosholoza.co.za,52.3913,4.66545
relay.solife.me,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.nostu.be,40.4167,-3.70329
nostr.whitenode45.ddns.net,40.55,-74.4758
nostr.carroarmato0.be:443,50.914,3.21378
cdn.satellite.earth,40.8302,-74.1299
relay2.veganostr.com,60.1699,24.9384
relay.layer.systems:443,49.0291,8.35695
relay0.gfcom.info,13.7653,100.647
relay.mmwaves.de:443,48.8575,2.35138
offchain.pub,39.1585,-94.5728
bcast.girino.org,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
nostr.overpay.com,29.7449,-95.5343
bridge.tagomago.me,42.3601,-71.0589
nostr-01.yakihonne.com,1.32123,103.695
strfry.bonsai.com,39.0438,-77.4874
relay.sharegap.net,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
dm-test-strfry-generic.samt.st,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay-rpi.edufeed.org:443,49.4521,11.0767
vault.iris.to:443,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
nostr.thalheim.io:443,60.1699,24.9384
soloco.nl,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
nostr-relay.zimage.com,34.0549,-118.243
public.crostr.com:443,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
relay.angor.io,48.1046,11.6002
relay.wellorder.net,45.5201,-122.99
relay.mwaters.net,50.9871,2.12554
relay.staging.commonshub.brussels,49.4543,11.0746
nostr-verified.wellorder.net,45.5201,-122.99
nostr-pub.wellorder.net,45.5201,-122.99
nostr-2.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717
relay.libernet.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.manneken.brussels,49.4543,11.0746
nostr.spicyz.io:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
ribo.us.nostria.app:443,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay02.lnfi.network,35.6764,139.65
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.endfiat.money:443,59.3327,18.0656
relay.paulstephenborile.com,49.4543,11.0746
rele.speyhard.fi,51.5072,-0.127586
relay.froth.zone,60.1699,24.9384
relay.nostr.blockhenge.com,39.0438,-77.4874
nrl.ceskar.xyz,50.5145,16.0119
rilo.nostria.app,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
nostrrelay.taylorperron.com,45.5029,-73.5723
chorus.pjv.me,45.5201,-122.99
relay.nostr.place,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
nostr.girino.org:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
nostr.thalheim.io,60.1699,24.9384
ec2.f7z.io,60.1699,24.9384
relay.trotters.cc,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.momostr.pink,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
chat-relay.zap-work.com,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
nostr.chaima.info,50.1109,8.68213
nostr.snowbla.de,60.1699,24.9384
relay.vrtmrz.net,43.6532,-79.3832
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
nexus.libernet.app,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299
inbox.mycelium.social,38.627,-90.1994
relay.trustr.ing,49.8667,-125.133
relay1.nostrchat.io,60.1699,24.9384
dev.relay.edufeed.org,49.4521,11.0767
satsage.xyz,37.3986,-121.964
fenrir-s.notoshi.win,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
relay.ditto.pub,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.minibolt.info:443,43.6532,-79.3832
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
nostr.computingcache.com:443,34.0356,-118.442
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.hekster.org:443,37.3986,-121.964
nostr.88mph.life,52.1941,-2.21905
wot.dergigi.com,64.1476,-21.9392
nostr.planix.org,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostrcity-club.fly.dev:443,37.7648,-122.432
aeon.libretechsystems.xyz,55.486,9.86577
testnet.samt.st,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
wot.sudocarlos.com,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
shu01.shugur.net,21.4902,39.2246
relay.gulugulu.moe:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
relay.libernet.app,43.6532,-79.3832
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relay.zone667.com,60.1699,24.9384
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.nostr.com,50.1109,8.68213
nostr.iskarion.ddns.net,43.3076,-2.95421
relay-dev.satlantis.io,39.0438,-77.4874
relay.sovereignresonance.org,48.9006,2.25929
relay.nostrian-conquest.com,41.223,-111.974
relay.aidatanorge.no,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.klabo.world,47.2343,-119.853
nostr.data.haus:443,50.4754,12.3683
testr.nymble.world,40.8054,-74.0241
relay.inforsupports.com,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473
nostr.rtvslawenia.com,49.4543,11.0746
relay.bowlafterbowl.com,32.9483,-96.7299
nostr.quali.chat:443,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay-rpi.edufeed.org,49.4521,11.0767
r.0kb.io,32.789,-96.7989
nostr.notribe.net:443,40.8302,-74.1299
relay.getsafebox.app:443,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostrelites.org,34.9582,-81.9907
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.thebiglake.org,32.71,-96.6745
nostr.defucc.me,50.1109,8.68213
relay.lanavault.space,60.1699,24.9384
relay.credenso.cafe,43.3601,-80.3127
relay.seq1.net,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
relay.devcsu.fr,48.8575,2.35138
dev-nostr.bityacht.io,43.6532,-79.3832
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.endfiat.money,59.3327,18.0656
relay.tapestry.ninja,40.8054,-74.0241
relay.wavlake.com,41.2619,-95.8608
relay.wavefunc.live,41.8781,-87.6298
nostr.self-determined.de,53.495,10.2542
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.nostriot.com,41.5695,-83.9786
nostr.christiansass.de,51.7634,7.8887
relay.btcforplebs.com,43.6532,-79.3832
nostr.tagomago.me,42.3601,-71.0589
relayone.geektank.ai,39.0997,-94.5786
relay.dreamith.to:443,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
wot.makenomistakes.ca,43.7064,-79.3986
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.paulstephenborile.com:443,49.4543,11.0746
relay.ohstr.com,43.6532,-79.3832
nostr-relay.xbytez.io:443,50.6924,3.20113
nostr.ac,38.958,-77.3592
ribo.us.nostria.app,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
relay.chorus.community:443,48.5333,10.7
relay.cypherflow.ai,48.8575,2.35138
relay.agorist.space:443,52.3734,4.89406
relay.nostrian-conquest.com:443,41.223,-111.974
relay.keykeeper.world,40.7824,-74.0711
relay.getvia.xyz,60.1699,24.9384
relay.nuts.cash,52.3676,4.90414
kotukonostr.onrender.com,37.7775,-122.397
relay.mmwaves.de,48.8575,2.35138
syb.lol,34.0549,-118.243
relay.minibolt.info,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.fountain.fm:443,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.lanavault.space:443,60.1699,24.9384
nostr.carroarmato0.be,50.914,3.21378
nexus.libernet.app:443,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
blossom.gnostr.cloud,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
nostr.hifish.org,47.4244,8.57658
offchain.pub:443,39.1585,-94.5728
relay.bullishbounty.com:443,43.6532,-79.3832
strfry.openhoofd.nl:443,51.5717,3.70417
cs-relay.nostrdev.com:443,50.4754,12.3683
strfry.ymir.cloud,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr2.girino.org,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.getsafebox.app,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
inbox.scuba323.com,40.8218,-74.45
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.tac.lol,47.4748,-122.273
nostr.davenov.com,50.1109,8.68213
relay.trotters.cc:443,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.nostreon.com,60.1699,24.9384
nostr.easycryptosend.it,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.purpura.cloud,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mifen.me,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
relay.wavlake.com:443,41.2619,-95.8608
relay.wisp.talk:443,49.4543,11.0746
relay-dev.satlantis.io:443,39.0438,-77.4874
relay.satlantis.io,39.0438,-77.4874
relay.staging.plebeian.market,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
nostr-relay.xbytez.io,50.6924,3.20113
relay.binaryrobot.com:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
relay.illuminodes.com,43.6532,-79.3832
relay.liberbitworld.org,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
no.str.cr,8.96171,-83.5246
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
wot.rejecttheframe.xyz,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
nostr.plantroon.com,50.1013,8.62643
nostr-01.uid.ovh,50.9871,2.12554
relay.openresist.com:443,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.pbfs.io:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8967,12.4822
nostr.4rs.nl,49.0291,8.35696
relay.lanavault.space,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.agentry.com,42.8864,-78.8784
relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.0418,-77.4744
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.vrtmrz.net:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mitchelltribe.com,39.0438,-77.4874
relay.qstr.app,51.5072,-0.127586
relay.cyberguy.fyi,52.6907,4.8181
strfry.bonsai.com:443,39.0438,-77.4874
relayone.soundhsa.com:443,39.0997,-94.5786
relay.sigit.io,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
0x-nostr-relay.fly.dev,37.7648,-122.432
relay.ohstr.com:443,43.6532,-79.3832
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
purplerelay.com:443,43.6532,-79.3832
nostr-pr02.redscrypt.org,52.3676,4.90414
fanfares.nostr1.com:443,40.7057,-74.0136
kasztanowa.bieda.it,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.typedcypher.com,51.5072,-0.127586
nostr.bond,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
nexus.libernet.app,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
schnorr.me,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.chorus.community,48.5333,10.7
blossom.gnostr.cloud:443,43.6532,-79.3832
syb.lol:443,34.0549,-118.243
relay.dyne.org,49.0291,8.35705
btc.klendazu.com,41.2861,1.24993
wot.nostr.place,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
rilo.nostria.app:443,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.mostr.pub:443,43.6532,-79.3832
relay.edufeed.org:443,49.4521,11.0767
nostr.debate.report,50.1109,8.68213
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.oxtr.dev,50.4754,12.3683
relay.fckstate.net,59.3293,18.0686
relay.vrtmrz.net,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.guggero.org,46.5971,9.59652
relay01.lnfi.network,35.6764,139.65
wot.shaving.kiwi,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.edufeed.org,49.4521,11.0767
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.satmaxt.xyz,43.6532,-79.3832
nostr.hifish.org:443,47.4244,8.57658
relay.cypherflow.ai:443,48.8575,2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.mypathtofire.de,42.8864,-78.8784
public.crostr.com,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.agora.social,50.7383,15.0648
ribo.nostria.app,43.6532,-79.3832
relay.lab.rytswd.com,49.4543,11.0746
relay.ditto.pub:443,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.endfiat.money,59.3327,18.0656
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nearhood.co.uk,51.5134,-0.0890675
relay.degmods.com,50.4754,12.3683
nostr.novacisko.cz,52.2026,20.9397
prl.plus,55.7628,37.5983
bruh.samt.st,43.6532,-79.3832
strfry.openhoofd.nl,51.5717,3.70417
nostr.spicyz.io,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
premium.primal.net,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.scuba323.com,40.8218,-74.45
nostr2.girino.org:443,43.6532,-79.3832
relay.mmwaves.de,48.8575,2.35138
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
strfry.shock.network:443,39.0438,-77.4874
nostr.snowbla.de,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
wot.utxo.one,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
mostro-p2p.tech,50.1109,8.68213
basspistol.org,49.0291,8.35696
ribo.nostria.app:443,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
nostr.oxtr.dev:443,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
offchain.bostr.online,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
relay.dreamith.to,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.nmail.li,50.9871,2.12554
nostr-relay.corb.net,39.6478,-104.988
relay.staging.plebeian.market:443,51.5072,-0.127586
spamspamspamspam.rest,43.6532,-79.3832
relay1.gfcom.info,13.9215,100.538
schnorr.me:443,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
nostr.relay.hedwig.sh,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
relay.wavefunc.live:443,41.8781,-87.6298
nostr.mikoshi.de,52.52,13.405
syb.lol,34.0549,-118.243
relay1.nostrchat.io,60.1699,24.9384
nostr.wecsats.io:443,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.azzamo.net:443,52.2633,21.0283
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.soundhsa.com,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
relay.primal.net,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.kalcafe.xyz,37.3986,-121.964
relay.gulugulu.moe,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nos.lol,50.4754,12.3683
relay.nostr.place:443,43.6532,-79.3832
cache.trustr.ing,43.6548,-79.3885
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.laantungir.net,-19.4692,-42.5315
relay.lightning.pub:443,39.0438,-77.4874
nostr.stakey.net,52.3676,4.90414
articles.layer3.news,37.3387,-121.885
relay.wisp.talk,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.typedcypher.com:443,51.5072,-0.127586
dev.relay.stream,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
nostr.hekster.org,37.3986,-121.964
nostrcity-club.fly.dev,37.7648,-122.432
nostr.vulpem.com,49.4543,11.0746
relay-dev.gulugulu.moe:443,43.6532,-79.3832
weboftrust.libretechsystems.xyz,55.4724,9.87335
nostr-relay.corb.net:443,39.6478,-104.988
wheat.happytavern.co,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
dev.relay.edufeed.org,49.4521,11.0767
myvoiceourstory.org,37.3598,-121.981
relay.stickeroo.is-cool.dev,37.3387,-121.885
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nostr.middling.mydns.jp,35.8099,140.12
cs-relay.nostrdev.com,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
nostrelay.circum.space:443,52.6907,4.8181
nostr.janx.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
hol.is,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.yutakobayashi.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
communities.nos.social,40.8302,-74.1299
relay.solife.me,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.islandbitcoin.com,12.8498,77.6545
nostr.wecsats.io,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273
relay.arx-ccn.com,50.4754,12.3683
nostrride.io,37.3986,-121.964
r.0kb.io:443,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
relay.artx.market,43.6548,-79.3885
vault.iris.to,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
temp.iris.to,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
nostr-relay.nextblockvending.com,47.2343,-119.853
wot.codingarena.top,50.4754,12.3683
relay.sincensura.org,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141

1 Relay URL Latitude Longitude
nostr.simplex.icu 50.1109 8.68213
v-relay.d02.vrtmrz.net 34.6937 135.502
relay.angor.io 48.1046 11.6002
nostr.twinkle.lol 51.902 7.6657
relay.klabo.world 47.2343 -119.853
cache.trustr.ing 45.4473 -73.7533
soloco.nl 43.6532 -79.3832
rusty-uat.siberian-albacore.ts.net:8443 35.6764 139.65
relay.artx.market 43.6548 -79.3885
relay.lacompagniemaximus.com 45.3147 -73.8785
relay.qstr.app 50.1109 8.68213
nostr.islandarea.net 35.4669 -97.6473
relay.homeinhk.xyz 35.694 139.754
nostr.overmind.lol 43.6532 -79.3832
no.str.cr 10.074 -84.2155
relay.nostrcheck.me 43.6532 -79.3832
wot.makenomistakes.ca 43.7064 -79.3986
nostr.computingcache.com 34.0356 -118.442
relay.bebond.net 43.6532 -79.3832
nostr.luisschwab.net 43.6532 -79.3832
strfry.bitsbytom.com 51.4968 -0.018337
nostr.2b9t.xyz 34.0549 -118.243
wot.dergigi.com 64.1476 -21.9392
offchain.pub 39.1585 -94.5728
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
relay.lab.rytswd.com 49.4543 11.0746
relay.hook.cafe 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
testnet-relay.samt.st 40.8302 -74.1299
relay.minibolt.info 43.6532 -79.3832
nostr.na.social 43.6532 -79.3832
relay.erybody.com 41.4513 -81.7021
relay.malxte.de 52.52 13.405
kasztanowa.bieda.it 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
relay.purplefrog.cloud 35.6916 139.768
nostr.vulpem.com 49.4543 11.0746
relay.arx-ccn.com 50.4754 12.3683
relay.mitchelltribe.com 39.0438 -77.4874
nostr.bond 50.1109 8.68213
bitcoiner.social 47.6743 -117.112
slick.mjex.me 39.0418 -77.4744
relay.libernet.app 43.6532 -79.3832
relay.damus.io 43.6532 -79.3832
strfry.openhoofd.nl 51.9229 4.40833
relay.agorist.space 52.3734 4.89406
relay.trotters.cc 43.6532 -79.3832
relay.illuminodes.com 47.6061 -122.333
testr.nymble.world 40.8054 -74.0241
wot.rejecttheframe.xyz 43.6532 -79.3832
nostr.carroarmato0.be 51.0368 3.21186
nostr.mikoshi.de 51.2821 6.78285
nrs-02.darkcloudarcade.com 39.9526 -75.1652
nostr.n7ekb.net 36.1527 -95.9902
nostr.hekster.org 37.3986 -121.964
nostr.faultables.net 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
nostr.easycryptosend.it 43.6532 -79.3832
purplerelay.com 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
r.0kb.io 32.789 -96.7989
herbstmeister.com 34.0549 -118.243
dev.relay.stream 43.6532 -79.3832
relay-freeharmonypeople.space 38.7223 -9.13934
r.bitcoinhold.net 43.6532 -79.3832
nostr.aruku.kro.kr 37.3589 127.115
relay.nostriches.club 43.6532 -79.3832
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
nos.lol 50.4754 12.3683
nostr.tagomago.me 3.139 101.687
nostr.plantroon.com 50.1013 8.62643
relay.nostrhub.fr 48.1045 11.6004
relay.fundstr.me 42.3601 -71.0589
ephemeral.snowflare.cc 43.6532 -79.3832
relay.layer.systems 49.0291 8.35695
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.edufeed.org 49.4521 11.0767
nrs-01.darkcloudarcade.com 39.1008 -94.5811
relay.inforsupports.com 43.6532 -79.3832
relay.nostr.net 43.6532 -79.3832
seed-options-few-cache.trycloudflare.com 43.6532 -79.3832
relay.nostrzh.org 43.6532 -79.3832
top.testrelay.top 43.6532 -79.3832
relay.decentnewsroom.com 50.4754 12.3683
myvoiceourstory.org 37.3598 -121.981
nostrja-kari.heguro.com 43.6532 -79.3832
relay.hostr.network 41.2619 -95.8608
nostr.wecsats.io 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
rilo.nostria.app 43.6532 -79.3832
nostr-01.yakihonne.com 1.32123 103.695
nostr.notribe.net 40.8302 -74.1299
relay.mccormick.cx 52.3563 4.95714
relay.openfarmtools.org 60.1699 24.9384
yabu.me 35.6092 139.73
nostr.girino.org 43.6532 -79.3832
relay.agora.social 50.7383 15.0648
wot.nostr.place 32.7767 -96.797
nostr.tac.lol 47.4748 -122.273
nostr.spacecitynode.com 29.7057 -95.2706
relay.sigit.io 50.4754 12.3683
nostr-verified.wellorder.net 45.5201 -122.99
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
nostr-relay.amethyst.name 39.0067 -77.4291
relay.dreamith.to 43.6532 -79.3832
relay.sloannetworks.com 37.5234 -77.3158
nostr.zoracle.org 45.6018 -121.185
ribo.us.nostria.app 43.6532 -79.3832
us-east.nostr.pikachat.org 39.0438 -77.4874
relay.lanacoin-eternity.com 40.8302 -74.1299
relay.wellorder.net 45.5201 -122.99
speakeasy.cellar.social 49.4543 11.0746
relay.notoshi.win 13.3622 100.983
relay.nostrian-conquest.com 41.223 -111.974
relay.astrolabe.finance 39.0438 -77.4874
rele.speyhard.fi 50.1109 8.68213
eu.nostr.pikachat.org 49.4543 11.0746
bridge.tagomago.me 3.139 101.687
aeon.libretechsystems.xyz 55.486 9.86577
relay.samt.st 40.8302 -74.1299
nostrbtc.com 43.6532 -79.3832
relay.henryxplace.eu.org:9988 31.2304 121.474
relay.islandbitcoin.com 12.8498 77.6545
testrelay.era21.space 43.6532 -79.3832
sandbox.registros.aarpia.net 50.1109 8.68213
relay.flashapp.me 43.6548 -79.3885
librerelay.aaroniumii.com 43.6532 -79.3832
premium.primal.net 43.6532 -79.3832
relay.gulugulu.moe 43.6532 -79.3832
nostr.thalheim.io 60.1699 24.9384
relay.bnos.space 43.6532 -79.3832
adre.su 59.9311 30.3609
relay.veganostr.com 60.1699 24.9384
relay.plebeian.market 50.1109 8.68213
relay.mostro.network 40.8302 -74.1299
relayone.soundhsa.com 39.1008 -94.5811
relay.threenine.services 51.5222 -0.62916
nostr.blankfors.se 60.1699 24.9384
nostr.nodesmap.com 59.3327 18.0656
nostriches.club 43.6532 -79.3832
relay.ditto.pub 43.6532 -79.3832
relay.npubhaus.com 43.6532 -79.3832
relay.paulstephenborile.com 49.4543 11.0746
nostr.dlcdevkit.com 40.0992 -83.1141
vault.iris.to 43.6532 -79.3832
test.thedude.cloud 50.1109 8.68213
dev-relay.nostreon.com 60.1699 24.9384
relay.nostriot.com 41.5695 -83.9786
relay-dev.gulugulu.moe 43.6532 -79.3832
freelay.sovbit.dev 60.1699 24.9384
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
relay.bikel.ink 60.1699 24.9384
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
relay.zone667.com 60.1699 24.9384
relay.olas.app 60.1699 24.9384
relay.degmods.com 50.4754 12.3683
nostr.spicyz.io 43.6532 -79.3832
relay.bornheimer.app 51.5072 -0.127586
purpura.cloud 43.6532 -79.3832
ribo.nostria.app 43.6532 -79.3832
wot.shaving.kiwi 43.6532 -79.3832
nostr.azzamo.net 52.2633 21.0283
relay.keykeeper.world 40.7824 -74.0711
nostrelay.circum.space 52.3676 4.90414
nostrcheck.me 43.6532 -79.3832
relay.snotr.nl:49999 52.0195 4.42946
nostr-relay.xbytez.io 50.6924 3.20113
relay.nostrverse.net 43.6532 -79.3832
bcast.girino.org 43.6532 -79.3832
blossom.gnostr.cloud 43.6532 -79.3832
dm-test-strfry-generic.samt.st 43.6532 -79.3832
relay.nostr-check.me 43.6532 -79.3832
schnorr.me 43.6532 -79.3832
nostr.tadryanom.me 43.6532 -79.3832
relay.satmaxt.xyz 43.6532 -79.3832
relay.binaryrobot.com 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
nittom.nostr1.com 40.7057 -74.0136
nostr.88mph.life 52.1941 -2.21905
nostrride.io 37.3986 -121.964
nostr-relay.corb.net 38.8353 -104.822
relay.fizx.uk 51.9194 19.1451
ynostr.yael.at 60.1699 24.9384
relay.liberbitworld.org 43.6532 -79.3832
relay-rpi.edufeed.org 49.4521 11.0767
relay.shadowbip.com 50.1109 8.68213
nostr-pub.wellorder.net 45.5201 -122.99
relay.openresist.com 43.6532 -79.3832
relay.typedcypher.com 51.5072 -0.127586
bendernostur.duckdns.org:8443 50.1109 8.68213
relay.bitmacro.cloud 43.6532 -79.3832
relay.nostrdice.com -33.8688 151.209
relay02.lnfi.network 35.6764 139.65
nostr-relay.nextblockvending.com 47.2343 -119.853
x.kojira.io 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
relay.ohstr.com 43.6532 -79.3832
wot.nostr.party 36.1627 -86.7816
spookstr2.nostr1.com 40.7057 -74.0136
wot.dtonon.com 43.6532 -79.3832
relay-nl.zombi.cloudrodion.com 50.8943 6.06237
artisanspyramid.libretechsystems.xyz 55.486 9.86577
ribo.eu.nostria.app 43.6532 -79.3832
nostr2.girino.org 43.6532 -79.3832
nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
relay.satlantis.io 40.8054 -74.0241
nostrsgp.notribe.net 1.32123 103.695
nostr.quali.chat 60.1699 24.9384
2 bitchat.nostr1.com 40.7057 -74.0136
3 nostr.sathoarder.com relay.fundstr.me 48.5734 42.3601 7.75211 -71.0589
4 relay.cosmicbolt.net nostr.2b9t.xyz 37.3986 34.0549 -121.964 -118.243
5 nostr.mom armada.sharegap.net 50.4754 43.6532 12.3683 -79.3832
6 nostr.oxtr.dev nostr.chaima.info 50.4754 51.5072 12.3683 -0.127586
7 strfry.bonsai.com nosflare-leefcore.leefcore.workers.dev 37.8716 43.6532 -122.273 -79.3832
8 relay.getsafebox.app ribo.eu.nostria.app:443 43.6532 -79.3832
relay.staging.commonshub.brussels 49.4543 11.0746
relay.orangepill.ovh 49.1689 -0.358841
relay.mostr.pub 43.6532 -79.3832
relay-arg.zombi.cloudrodion.com 1.35208 103.82
relay.0xchat.com 43.6532 -79.3832
srtrelay.c-stellar.net 43.6532 -79.3832
relay.saturnali.net 46.2044 6.14316
strfry.shock.network 39.0438 -77.4874
fanfares.nostr1.com 40.7057 -74.0136
nostrcity-club.fly.dev 48.8566 2.35222
nostr.ps1829.com 33.8851 130.883
relay.bao.network 43.6532 -79.3832
relay.jeffg.fyi 43.6532 -79.3832
video.czas.plus 50.1109 8.68213
librepress.libretechsystems.xyz 55.4724 9.87335
relay.laantungir.net -19.4692 -42.5315
relay5.bitransfer.org 43.6532 -79.3832
nostr-relay.psfoundation.info 39.0438 -77.4874
relay.fountain.fm 43.6532 -79.3832
relay.wisp.talk 49.4543 11.0746
nostr-relay.zeabur.app 25.0797 121.234
relay.nostrmap.net 60.1699 24.9384
relay01.lnfi.network 35.6764 139.65
relay.satnam.pub 43.6532 -79.3832
0x-nostr-relay.fly.dev 48.8566 2.35222
relay.beginningend.com 35.2227 -97.4786
cs-relay.nostrdev.com 50.4754 12.3683
relay.cypherflow.ai 48.8575 2.35138
nostr.spaceshell.xyz 43.6532 -79.3832
relay.ru.ac.th 13.7607 100.627
nostr.data.haus 50.4754 12.3683
bucket.coracle.social 37.7775 -122.397
nostr.bitcoiner.social 47.6743 -117.112
relay.gorrdy.cz 43.6532 -79.3832
relay.sovbit.dev 60.1699 24.9384
wot.codingarena.top 50.4754 12.3683
relay.directsponsor.net 42.8864 -78.8784
nostr2.thalheim.io 49.4543 11.0746
nostr.pbfs.io 50.4754 12.3683
relay.bullishbounty.com 43.6532 -79.3832
nostr.bitczat.pl 60.1699 24.9384
nostr.rtvslawenia.com 49.4543 11.0746
relay-testnet.k8s.layer3.news 37.3387 -121.885
9 relay.lightning.pub 39.0438 -77.4874
10 nostr.dpinkerton.com relay.nostu.be 49.2509 40.4167 -123.01 -3.70329
11 nostr.myshosholoza.co.za nostr.whitenode45.ddns.net 52.3913 40.55 4.66545 -74.4758
12 relay.solife.me nostr.carroarmato0.be:443 43.6532 50.914 -79.3832 3.21378
13 relay.agentry.com cdn.satellite.earth 42.8864 40.8302 -78.8784 -74.1299
14 relay2.veganostr.com 60.1699 24.9384
15 relay.layer.systems:443 49.0291 8.35695
16 relay0.gfcom.info 13.7653 100.647
17 relay.mmwaves.de:443 48.8575 2.35138
18 offchain.pub 39.1585 -94.5728
19 bcast.girino.org 43.6532 -79.3832
20 staging.yabu.me 35.6092 139.73
21 nostr.overpay.com 29.7449 -95.5343
22 bridge.tagomago.me 42.3601 -71.0589
23 nostr-01.yakihonne.com 1.32123 103.695
24 strfry.bonsai.com 39.0438 -77.4874
25 relay.sharegap.net 43.6532 -79.3832
26 nostr.islandarea.net 35.4669 -97.6473
27 dm-test-strfry-generic.samt.st 43.6532 -79.3832
28 treuzkas.branruz.com 48.8575 2.35138
29 relay-rpi.edufeed.org:443 49.4521 11.0767
30 vault.iris.to:443 43.6532 -79.3832
31 node.kommonzenze.de 49.4521 11.0767
32 nostr.thalheim.io:443 60.1699 24.9384
33 soloco.nl 43.6532 -79.3832
34 strfry.shock.network 39.0438 -77.4874
35 nostr-relay.zimage.com 34.0549 -118.243
36 public.crostr.com:443 43.6532 -79.3832
37 nostr.sathoarder.com:443 48.5734 7.75211
38 relay.angor.io 48.1046 11.6002
39 relay.wellorder.net 45.5201 -122.99
40 relay.mwaters.net 50.9871 2.12554
41 relay.staging.commonshub.brussels 49.4543 11.0746
42 nostr-verified.wellorder.net 45.5201 -122.99
43 nostr-pub.wellorder.net 45.5201 -122.99
44 nostr-2.21crypto.ch 47.5356 8.73209
45 relay.kaleidoswap.com 50.8476 4.35717
46 relay.libernet.app:443 43.6532 -79.3832
47 relay.homeinhk.xyz 35.694 139.754
48 relay.manneken.brussels 49.4543 11.0746
49 nostr.spicyz.io:443 43.6532 -79.3832
50 relay.lanacoin-eternity.com:443 40.8302 -74.1299
51 ribo.us.nostria.app:443 43.6532 -79.3832
52 relay.loveisbitcoin.com 43.6532 -79.3832
53 relay.angor.io:443 48.1046 11.6002
54 relay02.lnfi.network 35.6764 139.65
55 relay.cosmicbolt.net:443 37.3986 -121.964
56 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
57 nrs-01.darkcloudarcade.com 39.0997 -94.5786
58 relay.endfiat.money:443 59.3327 18.0656
59 relay.paulstephenborile.com 49.4543 11.0746
60 rele.speyhard.fi 51.5072 -0.127586
61 relay.froth.zone 60.1699 24.9384
62 relay.nostr.blockhenge.com 39.0438 -77.4874
63 nrl.ceskar.xyz 50.5145 16.0119
64 rilo.nostria.app 43.6532 -79.3832
65 nostr.overmind.lol:443 43.6532 -79.3832
66 nostr.snowbla.de:443 50.4754 12.3683
67 nostrrelay.taylorperron.com 45.5029 -73.5723
68 chorus.pjv.me 45.5201 -122.99
69 relay.nostr.place 43.6532 -79.3832
70 bucket.coracle.social 37.7775 -122.397
71 nostr.girino.org:443 43.6532 -79.3832
72 relay.aarpia.com 37.3986 -121.964
73 nostr.thalheim.io 60.1699 24.9384
74 ec2.f7z.io 60.1699 24.9384
75 relay.trotters.cc 43.6532 -79.3832
76 relay.mccormick.cx:443 52.3563 4.95714
77 relay.momostr.pink 43.6532 -79.3832
78 relay.nostr.net 43.6532 -79.3832
79 conduitl2.fly.dev 37.7648 -122.432
80 chat-relay.zap-work.com 43.6532 -79.3832
81 reraw.pbla2fish.cc relay.ditto.pub 43.6532 -79.3832
82 nostr.chaima.info relay.veganostr.com 50.1109 60.1699 8.68213 24.9384
83 nostr.snowbla.de relay.minibolt.info:443 60.1699 43.6532 24.9384 -79.3832
84 relay.vrtmrz.net adre.su 43.6532 59.9311 -79.3832 30.3609
85 relay.goodmorningbitcoin.com bitcoinostr.duckdns.org 43.6532 41.1976 -79.3832 1.11167
86 relay2.angor.io nostr.computingcache.com:443 48.1046 34.0356 11.6002 -118.442
87 nexus.libernet.app relay-fra.zombi.cloudrodion.com 43.6532 48.8566 -79.3832 2.35222
88 relay-dev.satlantis.io nostr.hekster.org:443 40.8302 37.3986 -74.1299 -121.964
89 inbox.mycelium.social nostr.88mph.life 38.627 52.1941 -90.1994 -2.21905
90 relay.trustr.ing wot.dergigi.com 49.8667 64.1476 -125.133 -21.9392
91 relay1.nostrchat.io nostr.planix.org 60.1699 43.6532 24.9384 -79.3832
92 dev.relay.edufeed.org relay.satsmarkt.club 49.4521 52.6907 11.0767 4.8181
93 satsage.xyz nostrcity-club.fly.dev:443 37.3986 37.7648 -121.964 -122.432
94 fenrir-s.notoshi.win aeon.libretechsystems.xyz 43.6532 55.486 -79.3832 9.86577
95 articles.layer3.news testnet.samt.st 37.3387 43.6532 -121.885 -79.3832
96 nostr.data.haus 50.4754 12.3683
97 wot.sudocarlos.com 43.6532 -79.3832
98 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
99 shu01.shugur.net 21.4902 39.2246
100 relay.gulugulu.moe:443 43.6532 -79.3832
101 relay2.angor.io:443 48.1046 11.6002
102 relay.libernet.app 43.6532 -79.3832
103 directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
104 wot.nostr.party 36.1659 -86.7844
105 relay.zone667.com 60.1699 24.9384
106 nostr.wild-vibes.ts.net 48.8566 2.35222
107 relay.nostr.com 50.1109 8.68213
108 nostr.iskarion.ddns.net 43.3076 -2.95421
109 relay-dev.satlantis.io 39.0438 -77.4874
110 relay.sovereignresonance.org 48.9006 2.25929
111 relay.nostrian-conquest.com 41.223 -111.974
112 relay.aidatanorge.no 43.6532 -79.3832
113 strfry.apps3.slidestr.net 40.4167 -3.70329
114 relay.klabo.world 47.2343 -119.853
115 nostr.data.haus:443 50.4754 12.3683
116 testr.nymble.world 40.8054 -74.0241
117 relay.inforsupports.com 43.6532 -79.3832
118 relay.nostrmap.net:443 60.1699 24.9384
119 nostr.stakey.net:443 52.3676 4.90414
120 dev-relay.nostreon.com 60.1699 24.9384
121 nostr.islandarea.net:443 35.4669 -97.6473
122 nostr.rtvslawenia.com 49.4543 11.0746
123 relay.bowlafterbowl.com 32.9483 -96.7299
124 nostr.quali.chat:443 60.1699 24.9384
125 relay.plebeian.market 50.1109 8.68213
126 relay-rpi.edufeed.org 49.4521 11.0767
127 r.0kb.io 32.789 -96.7989
128 nostr.notribe.net:443 40.8302 -74.1299
129 relay.getsafebox.app:443 43.6532 -79.3832
130 nostr.dlcdevkit.com:443 40.0992 -83.1141
131 nostrelites.org 34.9582 -81.9907
132 nostr.hoppe-relay.it.com 42.8864 -78.8784
133 nostr.thebiglake.org 32.71 -96.6745
134 nostr.defucc.me nostr-kyomu-haskell.onrender.com 50.1109 37.7775 8.68213 -122.397
135 relay.lanavault.space relay.nostriot.com 60.1699 41.5695 24.9384 -83.9786
136 relay.credenso.cafe nostr.christiansass.de 43.3601 51.7634 -80.3127 7.8887
137 relay.seq1.net relay.btcforplebs.com 43.6532 -79.3832
138 social.amanah.eblessing.co nostr.tagomago.me 48.1046 42.3601 11.6002 -71.0589
139 relay.devcsu.fr relayone.geektank.ai 48.8575 39.0997 2.35138 -94.5786
140 dev-nostr.bityacht.io relay.dreamith.to:443 43.6532 -79.3832
141 dm-test-nostr-rs-42-disabled.samt.st nostr.liberty.fans 43.6532 36.8767 -79.3832 -89.5879
142 relay.endfiat.money wot.makenomistakes.ca 59.3327 43.7064 18.0656 -79.3986
143 relay.tapestry.ninja relay.goodmorningbitcoin.com 40.8054 43.6532 -74.0241 -79.3832
144 relay.wavlake.com relay.layer.systems 41.2619 49.0291 -95.8608 8.35695
145 relay.wavefunc.live relay.paulstephenborile.com:443 41.8781 49.4543 -87.6298 11.0746
146 nostr.self-determined.de relay.ohstr.com 53.495 43.6532 10.2542 -79.3832
147 nostr-relay.xbytez.io:443 50.6924 3.20113
148 nostr.ac 38.958 -77.3592
149 ribo.us.nostria.app 43.6532 -79.3832
150 nostr.21crypto.ch 47.5356 8.73209
151 relay.chorus.community:443 48.5333 10.7
152 relay.cypherflow.ai 48.8575 2.35138
153 relay.agorist.space:443 52.3734 4.89406
154 relay.nostrian-conquest.com:443 41.223 -111.974
155 relay.keykeeper.world 40.7824 -74.0711
156 relay.getvia.xyz 60.1699 24.9384
157 relay.nuts.cash 52.3676 4.90414
158 kotukonostr.onrender.com 37.7775 -122.397
159 relay.mmwaves.de relay.minibolt.info 48.8575 43.6532 2.35138 -79.3832
160 syb.lol relay.dwadziesciajeden.pl 34.0549 52.2297 -118.243 21.0122
161 relay.fountain.fm:443 43.6532 -79.3832
162 relay.fountain.fm 43.6532 -79.3832
163 nostr-02.uid.ovh 50.9871 2.12554
164 relay.lanavault.space:443 60.1699 24.9384
165 nostr.carroarmato0.be 50.914 3.21378
166 nexus.libernet.app:443 43.6532 -79.3832
167 relay.artio.inf.unibe.ch 46.9501 7.43678
168 blossom.gnostr.cloud 43.6532 -79.3832
169 relay.binaryrobot.com 43.6532 -79.3832
170 relay.earthly.city 34.1749 -118.54
171 nostr.hifish.org 47.4244 8.57658
172 offchain.pub:443 39.1585 -94.5728
173 relay.bullishbounty.com:443 43.6532 -79.3832
174 strfry.openhoofd.nl:443 51.5717 3.70417
175 cs-relay.nostrdev.com:443 50.4754 12.3683
176 strfry.ymir.cloud 43.6532 -79.3832
177 nostrbtc.com 43.6532 -79.3832
178 relay.directsponsor.net 42.8864 -78.8784
179 nostr2.girino.org 43.6532 -79.3832
180 relay.sigit.io:443 50.4754 12.3683
181 relay.getsafebox.app 43.6532 -79.3832
182 antiprimal.net 43.6532 -79.3832
183 nostr.sathoarder.com 48.5734 7.75211
184 inbox.scuba323.com 40.8218 -74.45
185 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
186 nostr.tac.lol 47.4748 -122.273
187 nostr.davenov.com 50.1109 8.68213
188 relay.trotters.cc:443 43.6532 -79.3832
189 nostr.plantroon.com:443 50.1013 8.62643
190 relay.nostreon.com 60.1699 24.9384
191 nostr.easycryptosend.it 43.6532 -79.3832
192 nostr-01.yakihonne.com:443 1.32123 103.695
193 relay-testnet.k8s.layer3.news 37.3387 -121.885
194 nostr.purpura.cloud 43.6532 -79.3832
195 insta-relay.apps3.slidestr.net 40.4167 -3.70329
196 nostr.mifen.me 43.6532 -79.3832
197 testnet-relay.samt.st:443 40.8302 -74.1299
198 nostr.2b9t.xyz:443 34.0549 -118.243
199 relay.wavlake.com:443 41.2619 -95.8608
200 relay.wisp.talk:443 49.4543 11.0746
201 relay-dev.satlantis.io:443 39.0438 -77.4874
202 relay.satlantis.io 39.0438 -77.4874
203 relay.staging.plebeian.market 51.5072 -0.127586
204 relay.openfarmtools.org 60.1699 24.9384
205 relay.nostrhub.fr 48.1045 11.6004
206 nostr-relay.xbytez.io 50.6924 3.20113
207 relay.binaryrobot.com:443 43.6532 -79.3832
208 relay.samt.st 40.8302 -74.1299
209 relay.illuminodes.com 43.6532 -79.3832
210 relay.liberbitworld.org 43.6532 -79.3832
211 relay.olas.app:443 60.1699 24.9384
212 no.str.cr 8.96171 -83.5246
213 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
214 wot.rejecttheframe.xyz 43.6532 -79.3832
215 relay.nostriot.com:443 41.5695 -83.9786
216 nostr.plantroon.com 50.1013 8.62643
217 nostr-01.uid.ovh 50.9871 2.12554
218 relay.openresist.com:443 43.6532 -79.3832
219 nostr.overmind.lol 43.6532 -79.3832
220 relay.internationalright-wing.org -22.5022 -48.7114
221 nostr.myshosholoza.co.za:443 52.3676 4.90414
222 nostr.pbfs.io:443 50.4754 12.3683
223 21milionidinostr.duckdns.org 41.8967 12.4822
224 nostr.4rs.nl 49.0291 8.35696
225 relay.lanavault.space 60.1699 24.9384
226 relay.mostr.pub 43.6532 -79.3832
227 relay.nostar.org 43.6532 -79.3832
228 nostr.mom 50.4754 12.3683
229 relay.decentralia.fr 48.122 11.589
230 relay.agentry.com 42.8864 -78.8784
231 relay2.angor.io 48.1046 11.6002
232 slick.mjex.me 39.0418 -77.4744
233 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
234 relay.vrtmrz.net:443 43.6532 -79.3832
235 relay.beginningend.com 35.2227 -97.4786
236 chat-relay.zap-work.com:443 43.6532 -79.3832
237 relay.underorion.se 50.1109 8.68213
238 relay.mitchelltribe.com 39.0438 -77.4874
239 relay.qstr.app 51.5072 -0.127586
240 relay.cyberguy.fyi 52.6907 4.8181
241 strfry.bonsai.com:443 39.0438 -77.4874
242 relayone.soundhsa.com:443 39.0997 -94.5786
243 relay.sigit.io 50.4754 12.3683
244 relay.npubhaus.com 43.6532 -79.3832
245 relayrs.notoshi.win 43.6532 -79.3832
246 relay.mitchelltribe.com:443 39.0438 -77.4874
247 relay.44billion.net 43.6532 -79.3832
248 reraw.pbla2fish.cc 43.6532 -79.3832
249 articles.layer3.news:443 37.3387 -121.885
250 nostr.sovereignservices.xyz 43.6532 -79.3832
251 relay.nostx.io 43.6532 -79.3832
252 nostr-relay.amethyst.name 39.0067 -77.4291
253 0x-nostr-relay.fly.dev 37.7648 -122.432
254 relay.ohstr.com:443 43.6532 -79.3832
255 00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
256 nostr-relay.cbrx.io 43.6532 -79.3832
257 relay.wavlake.com 41.2619 -95.8608
258 purplerelay.com:443 43.6532 -79.3832
259 nostr-pr02.redscrypt.org 52.3676 4.90414
260 fanfares.nostr1.com:443 40.7057 -74.0136
261 kasztanowa.bieda.it 43.6532 -79.3832
262 relay.flashapp.me 43.6548 -79.3885
263 relay.typedcypher.com 51.5072 -0.127586
264 nostr.bond 50.1109 8.68213
265 nostr.azzamo.net 52.2633 21.0283
266 nexus.libernet.app 43.6532 -79.3832
267 relay.cosmicbolt.net 37.3986 -121.964
268 schnorr.me 43.6532 -79.3832
269 relay.mostro.network:443 40.8302 -74.1299
270 relay-arg.zombi.cloudrodion.com 1.35208 103.82
271 relay.chorus.community 48.5333 10.7
272 blossom.gnostr.cloud:443 43.6532 -79.3832
273 syb.lol:443 34.0549 -118.243
274 relay.dyne.org 49.0291 8.35705
275 btc.klendazu.com 41.2861 1.24993
276 wot.nostr.place 43.6532 -79.3832
277 relay.openresist.com 43.6532 -79.3832
278 rilo.nostria.app:443 43.6532 -79.3832
279 no.str.cr:443 8.96171 -83.5246
280 relay.mostr.pub:443 43.6532 -79.3832
281 relay.edufeed.org:443 49.4521 11.0767
282 nostr.debate.report 50.1109 8.68213
283 relay.satmaxt.xyz:443 43.6532 -79.3832
284 relay.artx.market:443 43.6548 -79.3885
285 relay-dev.gulugulu.moe 43.6532 -79.3832
286 relay.novospes.com 43.6532 -79.3832
287 relay.nostr-check.me 43.6532 -79.3832
288 nostr.computingcache.com 34.0356 -118.442
289 nostr.oxtr.dev 50.4754 12.3683
290 relay.fckstate.net 59.3293 18.0686
291 relay.vrtmrz.net 43.6532 -79.3832
292 relay.bornheimer.app 51.5072 -0.127586
293 relay.guggero.org 46.5971 9.59652
294 relay01.lnfi.network 35.6764 139.65
295 wot.shaving.kiwi 43.6532 -79.3832
296 nostr.twinkle.lol 51.902 7.6657
297 relay.edufeed.org 49.4521 11.0767
298 relay.lanacoin-eternity.com 40.8302 -74.1299
299 relay.satmaxt.xyz 43.6532 -79.3832
300 nostr.hifish.org:443 47.4244 8.57658
301 relay.cypherflow.ai:443 48.8575 2.35138
302 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
303 nostr.na.social:443 43.6532 -79.3832
304 nostr.rtvslawenia.com:443 49.4543 11.0746
305 relay.mypathtofire.de 42.8864 -78.8784
306 public.crostr.com 43.6532 -79.3832
307 relay.olas.app 60.1699 24.9384
308 relay.agora.social 50.7383 15.0648
309 ribo.nostria.app 43.6532 -79.3832
310 relay.lab.rytswd.com 49.4543 11.0746
311 relay.ditto.pub:443 43.6532 -79.3832
312 porchlight.social 43.6532 -79.3832
313 nostr.notribe.net 40.8302 -74.1299
314 relay.endfiat.money 59.3327 18.0656
315 nostr.myshosholoza.co.za 52.3676 4.90414
316 relay.nearhood.co.uk 51.5134 -0.0890675
317 relay.degmods.com 50.4754 12.3683
318 nostr.novacisko.cz 52.2026 20.9397
319 prl.plus 55.7628 37.5983
320 bruh.samt.st 43.6532 -79.3832
321 strfry.openhoofd.nl 51.5717 3.70417
322 nostr.spicyz.io 43.6532 -79.3832
323 nostr.na.social 43.6532 -79.3832
324 nip85.nosfabrica.com 39.0997 -94.5786
325 premium.primal.net 43.6532 -79.3832
326 fanfares.nostr1.com 40.7057 -74.0136
327 relay.scuba323.com 40.8218 -74.45
328 nostr2.girino.org:443 43.6532 -79.3832
329 relay.mmwaves.de 48.8575 2.35138
330 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
331 strfry.shock.network:443 39.0438 -77.4874
332 nostr.snowbla.de 50.4754 12.3683
333 nostr.spaceshell.xyz 43.6532 -79.3832
334 nostr.quali.chat 60.1699 24.9384
335 wot.utxo.one 43.6532 -79.3832
336 relay.mccormick.cx 52.3563 4.95714
337 mostro-p2p.tech 50.1109 8.68213
338 basspistol.org 49.0291 8.35696
339 ribo.nostria.app:443 43.6532 -79.3832
340 chorus.mikedilger.com:444 -36.8906 174.794
341 nostr.oxtr.dev:443 50.4754 12.3683
342 nostr.nodesmap.com 59.3327 18.0656
343 offchain.bostr.online 43.6532 -79.3832
344 purplerelay.com 43.6532 -79.3832
345 relayrs.notoshi.win:443 43.6532 -79.3832
346 relay.wavefunc.live 41.8781 -87.6298
347 relay.dreamith.to 43.6532 -79.3832
348 bendernostur.duckdns.org:8443 50.1109 8.68213
349 relay.nmail.li 50.9871 2.12554
350 nostr-relay.corb.net 39.6478 -104.988
351 relay.staging.plebeian.market:443 51.5072 -0.127586
352 spamspamspamspam.rest 43.6532 -79.3832
353 relay1.gfcom.info 13.9215 100.538
354 schnorr.me:443 43.6532 -79.3832
355 relay.lab.rytswd.com:443 49.4543 11.0746
356 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
357 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
358 relay.nostrmap.net 60.1699 24.9384
359 nostr.relay.hedwig.sh 60.1699 24.9384
360 relay.veganostr.com:443 60.1699 24.9384
361 relay.wavefunc.live:443 41.8781 -87.6298
362 nostr.mikoshi.de 52.52 13.405
363 syb.lol 34.0549 -118.243
364 relay1.nostrchat.io 60.1699 24.9384
365 nostr.wecsats.io:443 43.6532 -79.3832
366 nostr.chaima.info:443 51.5072 -0.127586
367 nostr.azzamo.net:443 52.2633 21.0283
368 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
369 nostr.unkn0wn.world 46.8499 9.53287
370 relayone.soundhsa.com 39.0997 -94.5786
371 x.kojira.io 43.6532 -79.3832
372 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
373 nostrelay.circum.space 52.6907 4.8181
374 relay.primal.net 43.6532 -79.3832
375 nostr.girino.org 43.6532 -79.3832
376 nostr.pbfs.io 50.4754 12.3683
377 relay.kalcafe.xyz 37.3986 -121.964
378 relay.gulugulu.moe 43.6532 -79.3832
379 top.testrelay.top 43.6532 -79.3832
380 relay.kilombino.com 43.6532 -79.3832
381 nos.lol:443 50.4754 12.3683
382 nos.lol 50.4754 12.3683
383 relay.nostr.place:443 43.6532 -79.3832
384 cache.trustr.ing 43.6548 -79.3885
385 relay.internationalright-wing.org:443 -22.5022 -48.7114
386 relay.laantungir.net -19.4692 -42.5315
387 relay.lightning.pub:443 39.0438 -77.4874
388 nostr.stakey.net 52.3676 4.90414
389 articles.layer3.news 37.3387 -121.885
390 relay.wisp.talk 49.4543 11.0746
391 relay.pyramid.li 47.4093 8.46503
392 relay.typedcypher.com:443 51.5072 -0.127586
393 dev.relay.stream 43.6532 -79.3832
394 relay.bullishbounty.com 43.6532 -79.3832
395 nostr.mom:443 50.4754 12.3683
396 relay.plebeian.market:443 50.1109 8.68213
397 nostr.hekster.org 37.3986 -121.964
398 nostrcity-club.fly.dev 37.7648 -122.432
399 nostr.vulpem.com 49.4543 11.0746
400 relay-dev.gulugulu.moe:443 43.6532 -79.3832
401 weboftrust.libretechsystems.xyz 55.4724 9.87335
402 nostr-relay.corb.net:443 39.6478 -104.988
403 wheat.happytavern.co 43.6532 -79.3832
404 relay.mappingbitcoin.com 43.6532 -79.3832
405 testnet-relay.samt.st 40.8302 -74.1299
406 relay.bitmacro.cloud 43.6532 -79.3832
407 dev.relay.edufeed.org 49.4521 11.0767
408 myvoiceourstory.org 37.3598 -121.981
409 relay.stickeroo.is-cool.dev 37.3387 -121.885
410 relay.agorist.space 52.3734 4.89406
411 freelay.sovbit.host 60.1699 24.9384
412 nostr-dev.wellorder.net 45.5201 -122.99
413 nostr.middling.mydns.jp 35.8099 140.12
414 cs-relay.nostrdev.com 50.4754 12.3683
415 x.kojira.io:443 43.6532 -79.3832
416 nostrelay.circum.space:443 52.6907 4.8181
417 nostr.janx.com 43.6532 -79.3832
418 relay.mrmave.work 43.6532 -79.3832
419 espelho.girino.org 43.6532 -79.3832
420 hol.is 43.6532 -79.3832
421 ribo.eu.nostria.app 43.6532 -79.3832
422 nostr.yutakobayashi.com 43.6532 -79.3832
423 relay.mostro.network 40.8302 -74.1299
424 communities.nos.social 40.8302 -74.1299
425 relay.solife.me 43.6532 -79.3832
426 yabu.me 35.6092 139.73
427 relay.islandbitcoin.com 12.8498 77.6545
428 nostr.wecsats.io 43.6532 -79.3832
429 nostr.tac.lol:443 47.4748 -122.273
430 relay.arx-ccn.com 50.4754 12.3683
431 nostrride.io 37.3986 -121.964
432 r.0kb.io:443 32.789 -96.7989
433 herbstmeister.com 34.0549 -118.243
434 relay.artx.market 43.6548 -79.3885
435 vault.iris.to 43.6532 -79.3832
436 relay.ru.ac.th 13.7607 100.627
437 temp.iris.to 43.6532 -79.3832
438 social.amanah.eblessing.co 48.1046 11.6002
439 nostr-relay.nextblockvending.com 47.2343 -119.853
440 wot.codingarena.top 50.4754 12.3683
441 relay.sincensura.org 43.6532 -79.3832
442 nostr.dlcdevkit.com 40.0992 -83.1141

View File

@ -41,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize WiFi Aware controller with persisted default
try {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
// Initialize Geohash Registries for persistence
try {
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)

View File

@ -1,6 +1,7 @@
package com.bitchat.android
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.OnBackPressedCallback
@ -19,6 +20,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@ -40,6 +42,7 @@ import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
@ -55,12 +58,14 @@ class MainActivity : OrientationAwareActivity() {
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private var pendingMeshForegroundServiceStart = false
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService) as T
return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
@ -76,7 +81,9 @@ class MainActivity : OrientationAwareActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.setRecentsScreenshotEnabled(false)
}
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
@ -111,9 +118,13 @@ class MainActivity : OrientationAwareActivity() {
// Initialize permission management
permissionManager = PermissionManager(this)
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
// Start the foreground service when allowed, then get mesh instances from the holder.
startMeshForegroundServiceBestEffort()
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to WiFi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@ -164,6 +175,17 @@ class MainActivity : OrientationAwareActivity() {
}
}
}
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
WifiAwareController.running.collect { running ->
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
unifiedMeshService.delegate = chatViewModel
}
}
}
}
// Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes
@ -222,6 +244,10 @@ class MainActivity : OrientationAwareActivity() {
onRetry = {
checkBluetoothAndProceed()
},
onSkip = {
mainViewModel.skipBluetoothCheck()
checkLocationAndProceed()
},
isLoading = isBluetoothLoading
)
}
@ -355,6 +381,13 @@ class MainActivity : OrientationAwareActivity() {
private fun checkBluetoothAndProceed() {
// Log.d("MainActivity", "Checking Bluetooth status")
// Check if user has skipped Bluetooth check for this session
if (mainViewModel.isBluetoothCheckSkipped.value) {
Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
checkLocationAndProceed()
return
}
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
@ -367,6 +400,12 @@ class MainActivity : OrientationAwareActivity() {
bluetoothStatusManager.logBluetoothStatus()
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (!bleRequired) {
// Skip BLE checks entirely when BLE is disabled in debug settings
checkLocationAndProceed()
return
}
when (mainViewModel.bluetoothStatus.value) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, check location services next
@ -472,6 +511,8 @@ class MainActivity : OrientationAwareActivity() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
// Ensure Wi-Fi Aware starts now that location is enabled
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed()
}
@ -540,8 +581,9 @@ class MainActivity : OrientationAwareActivity() {
else -> BatteryOptimizationStatus.ENABLED
}
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
when {
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
@ -576,6 +618,22 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateErrorMessage(message)
mainViewModel.updateOnboardingState(OnboardingState.ERROR)
}
private fun startMeshForegroundServiceBestEffort() {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
pendingMeshForegroundServiceStart = true
Log.i("MainActivity", "Deferring foreground mesh service start until activity is started")
return
}
try {
com.bitchat.android.service.MeshForegroundService.start(applicationContext)
pendingMeshForegroundServiceStart = false
} catch (e: Exception) {
pendingMeshForegroundServiceStart = true
Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e)
}
}
/**
* Check Battery Optimization status and proceed with onboarding flow
@ -674,9 +732,10 @@ class MainActivity : OrientationAwareActivity() {
return@launch
}
// Set up mesh service delegate and start services
meshService.delegate = chatViewModel
meshService.startServices()
// Set up unified mesh delegate and start enabled transports
unifiedMeshService.delegate = chatViewModel
unifiedMeshService.startServices()
startMeshForegroundServiceBestEffort()
Log.d("MainActivity", "Mesh service started successfully")
@ -714,17 +773,25 @@ class MainActivity : OrientationAwareActivity() {
handleVerificationIntent(intent)
}
}
override fun onStart() {
super.onStart()
if (pendingMeshForegroundServiceStart) {
startMeshForegroundServiceBestEffort()
}
}
override fun onResume() {
super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@ -739,6 +806,9 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
} else {
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
}
}
}
@ -748,7 +818,7 @@ class MainActivity : OrientationAwareActivity() {
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { meshService.delegate = null } catch (_: Exception) { }
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}

View File

@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
// Public update functions for MainActivity
fun updateOnboardingState(state: OnboardingState) {
_onboardingState.value = state
@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
fun updateBatteryOptimizationLoading(loading: Boolean) {
_isBatteryOptimizationLoading.value = loading
}
fun skipBluetoothCheck() {
_isBluetoothCheckSkipped.value = true
}
}

View File

@ -0,0 +1,69 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.icon.BitChatIcon
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
private val MultiClickThreshold = 300.milliseconds
@Composable
fun BitChatBrandButton(
onClick: () -> Unit,
onTripleClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.primary,
) {
var tapCount by remember { mutableIntStateOf(0) }
var resetJob by remember { mutableStateOf<Job?>(null) }
val coroutineScope = rememberCoroutineScope()
val currentOnClick by rememberUpdatedState(onClick)
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
IconButton(
onClick = {
tapCount += 1
resetJob?.cancel()
if (tapCount == 3) {
tapCount = 0
resetJob = null
currentOnTripleClick()
} else {
resetJob = coroutineScope.launch {
delay(MultiClickThreshold)
if (tapCount == 1) {
currentOnClick()
}
tapCount = 0
resetJob = null
}
}
},
modifier = modifier,
) {
Icon(
imageVector = BitChatIcon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(16.dp),
)
}
}

View File

@ -0,0 +1,96 @@
package com.bitchat.android.core.ui.component.text
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
internal data class ClickedAnnotation(
val tag: String,
val item: String,
)
internal fun findAnnotationAt(
text: AnnotatedString,
offset: Int,
annotationTags: List<String>,
): ClickedAnnotation? {
for (tag in annotationTags) {
text.getStringAnnotations(tag = tag, start = offset, end = offset)
.firstOrNull()
?.let { annotation ->
return ClickedAnnotation(tag = tag, item = annotation.item)
}
}
return null
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnnotatedClickableText(
text: AnnotatedString,
annotationTags: List<String>,
onAnnotationClick: (tag: String, item: String) -> Boolean,
modifier: Modifier = Modifier,
onLongPress: (() -> Unit)? = null,
color: Color = Color.Unspecified,
fontFamily: FontFamily? = null,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
style: TextStyle = LocalTextStyle.current,
) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
val currentOnLongPress by rememberUpdatedState(onLongPress)
Text(
text = text,
modifier = modifier.pointerInput(text, annotationTags, onLongPress != null) {
detectTapGestures(
onTap = { position ->
val offset = layoutResult
?.getOffsetForPosition(position)
?: return@detectTapGestures
var remainingTags = annotationTags
while (remainingTags.isNotEmpty()) {
val annotation = findAnnotationAt(
text = text,
offset = offset,
annotationTags = remainingTags,
) ?: break
if (currentOnAnnotationClick(annotation.tag, annotation.item)) {
return@detectTapGestures
}
remainingTags = remainingTags.drop(
remainingTags.indexOf(annotation.tag) + 1
)
}
},
onLongPress = currentOnLongPress?.let { callback ->
{ callback() }
},
)
},
color = color,
fontFamily = fontFamily,
softWrap = softWrap,
overflow = overflow,
style = style,
onTextLayout = { layoutResult = it },
)
}

View File

@ -0,0 +1,48 @@
package com.bitchat.android.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val BitChatIcon: ImageVector
get() {
_BitChatIcon?.let { return it }
return ImageVector.Builder(
name = "BitChatIcon",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 8f,
viewportHeight = 8f,
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(2f, 0f)
lineTo(6f, 0f)
lineTo(6f, 1f)
lineTo(7f, 1f)
lineTo(7f, 2f)
lineTo(8f, 2f)
lineTo(8f, 5f)
lineTo(7f, 5f)
lineTo(7f, 6f)
lineTo(6f, 6f)
lineTo(6f, 8f)
lineTo(5f, 8f)
lineTo(5f, 7f)
lineTo(3f, 7f)
lineTo(3f, 6f)
lineTo(1f, 6f)
lineTo(1f, 5f)
lineTo(0f, 5f)
lineTo(0f, 2f)
lineTo(1f, 2f)
lineTo(1f, 1f)
lineTo(2f, 1f)
close()
}
}.build().also { _BitChatIcon = it }
}
private var _BitChatIcon: ImageVector? = null

View File

@ -1,57 +0,0 @@
package com.bitchat.android.core.ui.utils
import androidx.compose.foundation.clickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
fun Modifier.singleOrTripleClickable(
onSingleClick: () -> Unit,
onTripleClick: () -> Unit,
clickTimeThreshold: Long = 300L
): Modifier = composed {
var tapCount by remember { mutableIntStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
var singleClickJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val coroutineScope = rememberCoroutineScope()
this.clickable {
val currentTime = System.currentTimeMillis()
if (currentTime - lastTapTime < clickTimeThreshold) {
tapCount++
} else {
tapCount = 1
}
lastTapTime = currentTime
// Cancel any pending single click action
singleClickJob?.cancel()
singleClickJob = null
when (tapCount) {
1 -> {
// Wait to see if more taps come
singleClickJob = coroutineScope.launch {
delay(clickTimeThreshold)
if (tapCount == 1) {
onSingleClick()
}
}
}
3 -> {
// Triple click detected - execute immediately
onTripleClick()
tapCount = 0
}
}
// Reset after threshold if no triple click
if (tapCount > 3) {
tapCount = 0
}
}
}

View File

@ -7,6 +7,9 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@ -190,6 +193,13 @@ open class EncryptionService(private val context: Context) {
}
return encrypted
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using Noise transport encryption
@ -202,6 +212,12 @@ open class EncryptionService(private val context: Context) {
}
return decrypted
}
@Throws(Exception::class)
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
return noiseService.decryptWithSession(data, peerID)
?: throw Exception("Failed generation-bound decryption from $peerID")
}
/**
* Sign data using our static identity key
@ -254,6 +270,25 @@ open class EncryptionService(private val context: Context) {
fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID)
}
/**
* Return the remote static key authenticated by the live Noise handshake.
* This deliberately bypasses announcement and PeerFingerprintManager
* caches; callers making downgrade decisions must bind to live channel
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
noiseService.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@ -265,9 +300,9 @@ open class EncryptionService(private val context: Context) {
/**
* Initiate a Noise handshake with a peer
*/
fun initiateHandshake(peerID: String): ByteArray? {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID)
return noiseService.initiateHandshake(peerID, replaceEstablished)
}
/**
@ -277,11 +312,24 @@ open class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Process one Noise handshake frame while preserving whether this exact call authenticated a
* new session. Unlike the response-only compatibility API, binding failures are propagated.
*/
@Throws(Exception::class)
open fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
return noiseService.processHandshakeMessageWithResult(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
fun removePeer(peerID: String) {
open fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)

View File

@ -0,0 +1,33 @@
package com.bitchat.android.favorites
import com.bitchat.android.services.ContactIdentityResolver
data class FavoriteControlMessage(
val isFavorite: Boolean,
val npub: String?
) {
companion object {
private const val FAVORITED = "[FAVORITED]"
private const val UNFAVORITED = "[UNFAVORITED]"
fun parse(content: String): FavoriteControlMessage? {
val trimmed = content.trim()
val isFavorite = when {
trimmed.startsWith(FAVORITED) -> true
trimmed.startsWith(UNFAVORITED) -> false
else -> return null
}
val encodedKey = trimmed.substringAfter(":", "").trim()
val npub = encodedKey
.takeIf { it.isNotEmpty() }
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.let { ContactIdentityResolver.npubFromHex(it) }
return FavoriteControlMessage(isFavorite = isFavorite, npub = npub)
}
fun encode(isFavorite: Boolean, npub: String?): String {
val prefix = if (isFavorite) FAVORITED else UNFAVORITED
return "$prefix:${npub.orEmpty()}"
}
}
}

View File

@ -2,20 +2,20 @@ package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
/**
* Bridging Noise and Nostr favorites
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
* peerID (16-hex) -> npub indexing for Nostr DM routing.
*/
data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
val peerNostrPublicKey: String?, // npub bech32 string
val peerNdrSessionPubkeyHex: String? = null, // Session lookup key used by nostr-double-ratchet
val peerNdrSessionPubkeyHex: String? = null,
val peerNickname: String,
val isFavorite: Boolean, // We favorited them
val theyFavoritedUs: Boolean, // They favorited us
@ -87,7 +87,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
@ -98,36 +97,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
}
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(pid)) return relationship
val pid = peerID.trim().lowercase()
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
return favorites[pid]
}
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid]?.let { indexedNpub ->
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
}
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
}
}
return null
}
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = nostrPubkey,
peerNostrPublicKey = normalizedNpub,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = nostrPubkey,
peerNdrSessionPubkeyHex = null,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
@ -143,11 +161,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
/** Update Nostr pubkey for a specific mesh peerID. */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.lowercase()
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
peerIdIndex[pid] = nostrPubkey
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
@ -156,36 +177,33 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
fun findNostrPubkeyForPeerID(peerID: String): String? {
return peerIdIndex[peerID.lowercase()]
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
}
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
// First, try direct match in peerIdIndex (values are stored as npub strings)
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
// Attempt legacy mapping via favorites Noise key association
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
if (targetHex != null) {
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
val rel = favorites.values.firstOrNull {
it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex ||
it.peerNdrSessionPubkeyHex == targetHex
}
if (rel != null) {
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
// Return 16-hex prefix as best-effort if no explicit mapping exists
return noiseHex.take(16)
}
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
}?.let { return it.key }
favorites.values.firstOrNull { relationship ->
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex ||
relationship.peerNdrSessionPubkeyHex == targetHex
}?.let { relationship ->
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
@ -200,7 +218,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = null,
peerNdrSessionPubkeyHex = null,
peerNickname = nickname,
isFavorite = isFavorite,
theyFavoritedUs = false,
@ -218,7 +235,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
if (existing != null) {
@ -236,6 +253,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() {
favorites.clear()
@ -248,23 +266,23 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex ||
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex ||
rel.peerNdrSessionPubkeyHex == targetHex
}?.peerNoisePublicKey
}
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
return favorites[keyHex]?.peerNostrPublicKey
}
/** Update the session lookup key used by nostr-double-ratchet for a peer. */
/** Persist the owner pubkey used to look up this peer's ratchet session. */
fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String) {
val normalized = normalizeNostrKeyToHex(peerPubkeyHex) ?: return
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex] ?: return
if (existing.peerNdrSessionPubkeyHex == normalized) return
@ -274,15 +292,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
)
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated NDR session pubkey for ${keyHex.take(16)}... -> ${normalized.take(16)}...")
}
/** Resolve the best lookup key for NDR session status/sending for a given peer. */
/** Resolve the best ratchet-session lookup key for this Noise identity. */
fun findNdrSessionPubkeyHex(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
val relationship = favorites[keyHex] ?: return null
return relationship.peerNdrSessionPubkeyHex
?: relationship.peerNostrPublicKey?.let(::normalizeNostrKeyToHex)
?: relationship.peerNostrPublicKey?.let(ContactIdentityResolver::nostrPubkeyHex)
}
// MARK: - Persistence
@ -325,7 +342,12 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
peerIdIndex.putAll(data)
data.forEach { (peerID, npub) ->
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
@ -351,6 +373,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
@ -358,14 +381,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
} else value.lowercase()
} catch (_: Exception) { null }
}
/** Serializable data for JSON storage */
@ -382,7 +397,7 @@ private data class FavoriteRelationshipData(
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNdrSessionPubkeyHex = relationship.peerNdrSessionPubkeyHex,
peerNickname = relationship.peerNickname,
@ -395,7 +410,7 @@ private data class FavoriteRelationshipData(
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,

View File

@ -539,6 +539,7 @@ class LocationChannelManager private constructor(private val context: Context) {
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.value = ChannelID.Mesh
_teleported.value = false
Log.d(TAG, "Cleared persisted channel selection")
}

View File

@ -0,0 +1,323 @@
package com.bitchat.android.hotspot
import android.content.Context
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import java.io.File
import java.io.FileInputStream
/**
* Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot.
* Based on NanoHTTPD.
*/
class ApkWebServer(
private val context: Context,
private val apkFile: File,
private val port: Int = DEFAULT_PORT
) : NanoHTTPD(port) {
companion object {
private const val TAG = "ApkWebServer"
const val DEFAULT_PORT = 9999
}
private val appVersion: String by lazy {
try {
context.packageManager
.getPackageArchiveInfo(apkFile.absolutePath, 0)
?.versionName
?: "Unknown"
} catch (e: Exception) {
"Unknown"
}
}
// Cache the HTML landing page (generated once, reused for all requests)
private val cachedHtml: String by lazy {
generateLandingPageHtml()
}
override fun serve(session: IHTTPSession): Response {
val uri = session.uri ?: "/"
Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
return when {
uri == "/bitchat.apk" -> {
serveApk()
}
uri == "/favicon.ico" -> {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
}
else -> {
serveLandingPage()
}
}
}
/**
* Serve the APK file.
*/
private fun serveApk(): Response {
return try {
if (!apkFile.exists()) {
Log.e(TAG, "APK file not found: ${apkFile.path}")
return newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"APK file not found"
)
}
Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)")
val inputStream = FileInputStream(apkFile)
val response = newFixedLengthResponse(
Response.Status.OK,
"application/vnd.android.package-archive",
inputStream,
apkFile.length()
)
response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"")
response.addHeader("Accept-Ranges", "bytes")
response
} catch (e: Exception) {
Log.e(TAG, "Error serving APK", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"Error serving APK: ${e.message}"
)
}
}
/**
* Serve the HTML landing page.
*/
private fun serveLandingPage(): Response {
return newFixedLengthResponse(
Response.Status.OK,
"text/html",
cachedHtml
)
}
/**
* Generate HTML landing page.
*/
private fun generateLandingPageHtml(): String {
val apkSizeMb = apkFile.length() / 1024 / 1024
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Download BitChat</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #333;
}
.container {
background: white;
border-radius: 20px;
padding: 40px 30px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
color: #667eea;
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 30px;
}
.info-box {
background: #f5f7fa;
padding: 15px;
border-radius: 10px;
}
.info-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
}
.info-value {
font-size: 18px;
font-weight: bold;
color: #333;
}
.download-button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 18px 40px;
border-radius: 50px;
text-decoration: none;
font-size: 18px;
font-weight: 600;
margin-bottom: 30px;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.download-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.download-button:active {
transform: translateY(0);
}
.instructions {
text-align: left;
background: #f5f7fa;
padding: 20px;
border-radius: 10px;
margin-top: 20px;
}
.instructions h3 {
font-size: 16px;
margin-bottom: 15px;
color: #667eea;
}
.instructions ol {
margin-left: 20px;
}
.instructions li {
margin-bottom: 10px;
line-height: 1.6;
font-size: 14px;
color: #555;
}
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 10px;
margin-top: 20px;
font-size: 13px;
color: #856404;
text-align: left;
}
.warning strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🔒</div>
<h1>BitChat</h1>
<p class="subtitle">Secure Mesh Messaging</p>
<div class="info-grid">
<div class="info-box">
<div class="info-label">Version</div>
<div class="info-value">$appVersion</div>
</div>
<div class="info-box">
<div class="info-label">Size</div>
<div class="info-value">${apkSizeMb} MB</div>
</div>
</div>
<a href="/bitchat.apk" class="download-button">
📥 Download BitChat
</a>
<div class="instructions">
<h3>📱 Installation Instructions</h3>
<ol>
<li>Tap the download button above</li>
<li>Wait for the download to complete</li>
<li>Open the downloaded APK file</li>
<li>If prompted, enable "Install from unknown sources" for your browser</li>
<li>Follow the installation prompts</li>
</ol>
</div>
<div class="warning">
<strong> Note:</strong>
If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed.
</div>
</div>
</body>
</html>
""".trimIndent()
}
/**
* Start the server.
*/
fun startServer() {
try {
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
Log.d(TAG, "Web server started on port $port")
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
throw e
}
}
/**
* Stop the server.
*/
fun stopServer() {
try {
stop()
Log.d(TAG, "Web server stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping web server", e)
}
}
}

View File

@ -0,0 +1,684 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import java.io.File
/**
* Activity for managing Wi-Fi P2P hotspot for offline APK sharing.
* Pure Compose implementation, no fragments.
*/
class HotspotActivity : ComponentActivity() {
companion object {
const val EXTRA_APK_PATH = "apk_path"
private const val TAG = "HotspotActivity"
}
private val viewModel: HotspotViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get APK path from intent
val apkPath = intent.getStringExtra(EXTRA_APK_PATH)
val apkFile = if (apkPath != null) {
File(apkPath)
} else {
// Fallback: Try to get cached APK
UniversalApkManager(this).getCachedApk()
}
if (apkFile == null || !apkFile.exists()) {
// No APK available, show error and finish
finish()
return
}
setContent {
BitchatTheme {
HotspotScreen(
viewModel = viewModel,
apkFile = apkFile,
onClose = { finish() }
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle notification action to stop hotspot
if (intent.action == "STOP_HOTSPOT") {
viewModel.stopHotspot()
finish()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HotspotScreen(
viewModel: HotspotViewModel,
apkFile: File,
onClose: () -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Share BitChat",
fontFamily = FontFamily.Monospace
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
Crossfade(
targetState = state,
label = "HotspotStateCrossfade",
modifier = Modifier.padding(padding)
) { currentState ->
when (currentState) {
is HotspotViewModel.HotspotState.Intro -> {
IntroScreen(
onStartHotspot = { viewModel.startHotspot(apkFile) }
)
}
is HotspotViewModel.HotspotState.Starting -> {
LoadingScreen()
}
is HotspotViewModel.HotspotState.Active -> {
ActiveHotspotScreen(state = currentState)
}
is HotspotViewModel.HotspotState.Error -> {
ErrorScreen(
message = currentState.message,
onRetry = { viewModel.resetToIntro() },
onClose = onClose
)
}
}
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun IntroScreen(onStartHotspot: () -> Unit) {
// Determine which permission to request based on Android version
val requiredPermission = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Manifest.permission.ACCESS_FINE_LOCATION
else -> null // No runtime permission needed on Android < 10
}
val permissionState = requiredPermission?.let {
rememberPermissionState(it) { granted ->
if (granted) {
onStartHotspot()
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Offline App Sharing",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "How it works:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
InfoItem("1. Your device creates a Wi-Fi hotspot")
InfoItem("2. Others connect to your hotspot")
InfoItem("3. They scan a QR code or enter a URL")
InfoItem("4. BitChat downloads directly to their device")
}
}
// Permission rationale (if needed)
if (permissionState != null && !permissionState.status.isGranted && permissionState.status.shouldShowRationale) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = " Permission Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
} else {
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "⚠️ Note",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
// Check permission before starting hotspot
if (permissionState == null || permissionState.status.isGranted) {
// No permission needed or already granted
onStartHotspot()
} else {
// Request permission (auto-start handled by onPermissionResult callback)
permissionState.launchPermissionRequest()
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp)
) {
Text(
// Starting the hotspot is the user's action. Android will ask
// for the required permission only when it has not already
// been granted.
text = "Start Hotspot",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
@Composable
fun InfoItem(text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
Text(
text = "Starting hotspot...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Wi-Fi", "Website")
Column(
modifier = Modifier.fillMaxSize()
) {
// Status banner
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Hotspot Active",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "${state.connectedPeers} device(s) connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
}
}
// Tabs
TabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
fontFamily = FontFamily.Monospace,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
)
}
)
}
}
// Tab content
when (selectedTab) {
0 -> WifiTabContent(
ssid = state.ssid,
password = state.password
)
1 -> WebsiteTabContent(
ipAddress = state.ipAddress,
port = state.port
)
}
}
}
@Composable
fun WifiTabContent(ssid: String, password: String) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Step 1: Connect to Wi-Fi",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Have others scan this QR code to connect:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
// QR Code
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
val wifiQr = remember(ssid, password, qrSize) {
QrCodeGenerator.generateWifiQr(ssid, password, qrSize)
}
if (wifiQr != null) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(16.dp)
) {
Image(
bitmap = wifiQr.asImageBitmap(),
contentDescription = "Wi-Fi QR Code",
modifier = Modifier.size(280.dp)
)
}
}
Text(
text = "Or enter manually:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// SSID
CredentialCard(
label = "Network Name (SSID)",
value = ssid,
onCopy = {
clipboardManager.setText(AnnotatedString(ssid))
}
)
// Password
CredentialCard(
label = "Password",
value = password,
onCopy = {
clipboardManager.setText(AnnotatedString(password))
}
)
}
}
@Composable
fun WebsiteTabContent(ipAddress: String, port: Int) {
val url = "http://$ipAddress:$port"
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Step 2: Download BitChat",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "After connecting to the Wi-Fi, scan this QR code:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
// QR Code
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
val urlQr = remember(url, qrSize) {
QrCodeGenerator.generateUrlQr(url, qrSize)
}
if (urlQr != null) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(16.dp)
) {
Image(
bitmap = urlQr.asImageBitmap(),
contentDescription = "Website URL QR Code",
modifier = Modifier.size(280.dp)
)
}
}
Text(
text = "Or open in browser:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// URL
CredentialCard(
label = "Website URL",
value = url,
onCopy = {
clipboardManager.setText(AnnotatedString(url))
}
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "📱 Instructions",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "1. Make sure you're connected to the Wi-Fi network above\n" +
"2. Open a web browser on your device\n" +
"3. Visit the URL above or scan the QR code\n" +
"4. Tap 'Download BitChat'\n" +
"5. Install the downloaded APK",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
fun CredentialCard(
label: String,
value: String,
onCopy: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
IconButton(onClick = onCopy) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@Composable
fun ErrorScreen(
message: String,
onRetry: () -> Unit,
onClose: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Error",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text("Try Again")
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onClose) {
Text("Close")
}
}
}

View File

@ -0,0 +1,503 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
import android.net.wifi.p2p.WifiP2pManager.*
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
/**
* Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing.
* Based on Briar's implementation.
*/
class HotspotManager(private val context: Context) {
companion object {
private const val TAG = "HotspotMgr"
// Retry configuration
private const val MAX_FRAMEWORK_ATTEMPTS = 5
private const val RETRY_DELAY_MILLIS = 1000L
// Group info polling interval
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
// Give up if the group never forms within this window after creation succeeded
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
// SSID and password configuration
private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
private const val SSID_SUFFIX_LENGTH = 8
private const val PASSWORD_LENGTH = 16
// Characters to use for random generation (excluding confusing ones)
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
}
private val wifiP2pManager: WifiP2pManager? =
context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
private var channel: Channel? = null
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
private val handler = Handler(Looper.getMainLooper())
private val random = SecureRandom()
private var currentGroup: WifiP2pGroup? = null
private var callback: HotspotCallback? = null
private var isStarting = false
private var hasNotifiedStarted = false // Track if we've notified the callback
private var isReceiverRegistered = false // Track receiver registration to prevent leaks
// Saved credentials for reconnection
private var savedSsid: String? = null
private var savedPassword: String? = null
// Broadcast receiver for Wi-Fi P2P events
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
Log.d(TAG, "Wi-Fi P2P state changed: $state")
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
Log.d(TAG, "Wi-Fi P2P connection changed")
requestGroupInfo()
}
}
}
}
/**
* Start the Wi-Fi P2P hotspot.
*/
fun startHotspot(callback: HotspotCallback) {
if (isStarting) {
Log.w(TAG, "Hotspot already starting")
return
}
if (wifiP2pManager == null) {
Log.e(TAG, "Wi-Fi P2P not available on this device")
callback.onError("Wi-Fi Direct not supported on this device")
return
}
val missingPermission = requiredRuntimePermission()?.takeUnless {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (missingPermission != null) {
Log.w(TAG, "Cannot start hotspot without $missingPermission")
callback.onError("Nearby Wi-Fi permission is required to start the hotspot")
return
}
this.callback = callback
isStarting = true
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
// Register broadcast receiver (only if not already registered)
if (!isReceiverRegistered) {
val intentFilter = IntentFilter().apply {
addAction(WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION)
}
context.registerReceiver(broadcastReceiver, intentFilter)
isReceiverRegistered = true
Log.d(TAG, "Broadcast receiver registered")
}
// Acquire locks
acquireLocks()
// Load or generate credentials
if (savedSsid == null || savedPassword == null) {
savedSsid = generateSsid()
savedPassword = generatePassword()
Log.d(TAG, "Generated new credentials: SSID=$savedSsid")
} else {
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
}
// Start P2P framework with retries
startWifiP2pFramework(1)
}
/**
* Stop the hotspot.
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
isStarting = false
hasNotifiedStarted = false
// Stop group info polling
handler.removeCallbacksAndMessages(null)
// Remove group
channel?.let { ch ->
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
}
})
}
// Release locks
releaseLocks()
// Unregister receiver (only if registered)
if (isReceiverRegistered) {
try {
context.unregisterReceiver(broadcastReceiver)
isReceiverRegistered = false
Log.d(TAG, "Broadcast receiver unregistered")
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Receiver was not registered", e)
isReceiverRegistered = false
}
}
currentGroup = null
channel = null
callback = null
}
/**
* Get current connection information.
*/
fun getConnectionInfo(): ConnectionInfo? {
val group = currentGroup ?: return null
val ipAddress = getAccessPointAddress()
return ConnectionInfo(
ssid = group.networkName ?: savedSsid ?: "",
password = group.passphrase ?: savedPassword ?: "",
ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP
connectedPeers = group.clientList?.size ?: 0
)
}
/**
* Start Wi-Fi P2P framework with retry logic.
*/
private fun startWifiP2pFramework(attempt: Int) {
if (attempt > MAX_FRAMEWORK_ATTEMPTS) {
Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts")
failStartup("Failed to start hotspot. Please try again.")
return
}
Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
if (channel == null) {
Log.e(TAG, "Failed to initialize P2P channel")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
return
}
createGroup(attempt)
}
/**
* Create Wi-Fi P2P group.
*/
@SuppressLint("MissingPermission")
private fun createGroup(attempt: Int) {
val ch = channel ?: return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
.setPassphrase(savedPassword!!)
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
.build()
wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch))
} else {
// Android 9 and below: System-generated SSID/password
wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() {
if (channel !== requestChannel) {
Log.w(TAG, "Removing group created after hotspot was stopped")
wifiP2pManager?.removeGroup(requestChannel, null)
return
}
Log.d(TAG, "P2P group created successfully")
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
override fun onFailure(reason: Int) {
if (channel != null) {
handleGroupCreationFailure(reason, attempt)
}
}
}
/**
* Handle group creation failure with retry logic.
*/
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
val reasonStr = when (reason) {
ERROR -> "ERROR"
P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
BUSY -> "BUSY"
else -> "UNKNOWN($reason)"
}
Log.w(TAG, "Failed to create group: $reasonStr")
if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) {
// Framework is busy, retry
Log.d(TAG, "P2P framework busy, retrying...")
handler.postDelayed({
startWifiP2pFramework(attempt + 1)
}, RETRY_DELAY_MILLIS)
} else {
failStartup("Failed to create hotspot: $reasonStr")
}
}
/**
* Terminal startup failure: release all resources (locks, receiver, handler
* callbacks) before notifying the callback, so a failed attempt doesn't leak
* and block subsequent attempts.
*/
private fun failStartup(message: String) {
val cb = callback
stopHotspot()
cb?.onError(message)
}
/**
* Start polling for group info to track connected clients.
*/
private fun startGroupInfoPolling() {
requestGroupInfo()
// Keep polling even while the group info is still null — the first
// requestGroupInfo() after createGroup() can legitimately return null
// while the group is forming. Give up only after a timeout.
var elapsedMillis = 0L
handler.postDelayed(object : Runnable {
override fun run() {
if (channel == null) return
elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS
if (currentGroup == null && !hasNotifiedStarted &&
elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS
) {
Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms")
failStartup("Hotspot failed to start. Please try again.")
return
}
requestGroupInfo()
handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
}, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
/**
* Request current group information.
*/
@SuppressLint("MissingPermission")
private fun requestGroupInfo() {
val ch = channel ?: return
try {
wifiP2pManager?.requestGroupInfo(ch) { group ->
if (group != null) {
currentGroup = group
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
} else {
Log.w(TAG, "requestGroupInfo returned null group")
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("Nearby Wi-Fi permission was revoked. Grant it and try again.")
}
}
private fun requiredRuntimePermission(): String? {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
Manifest.permission.NEARBY_WIFI_DEVICES
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Manifest.permission.ACCESS_FINE_LOCATION
else -> null
}
}
/**
* Acquire WakeLock and WifiLock to keep hotspot active.
*/
private fun acquireLocks() {
try {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"BitChat:HotspotWakeLock"
)
wakeLock?.acquire(30 * 60 * 1000L)
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
} else {
android.net.wifi.WifiManager.WIFI_MODE_FULL
}
wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock")
wifiLock?.acquire()
Log.d(TAG, "Acquired WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error acquiring locks", e)
}
}
/**
* Release WakeLock and WifiLock.
*/
private fun releaseLocks() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
wifiLock?.let {
if (it.isHeld) {
it.release()
}
}
wifiLock = null
Log.d(TAG, "Released WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error releasing locks", e)
}
}
/**
* Get the IP address of the P2P access point.
* Looks for network interface starting with "p2p".
*/
private fun getAccessPointAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
if (iface.name.startsWith("p2p")) {
val addresses = iface.interfaceAddresses
for (addr in addresses) {
val address = addr.address
// IPv4 only (4 bytes)
if (address.address.size == 4) {
return address.hostAddress
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting access point address", e)
}
return null
}
/**
* Generate random SSID.
* Format: DIRECT-BC-XXXXXXXX
*/
private fun generateSsid(): String {
val suffix = (1..SSID_SUFFIX_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
return "$SSID_PREFIX$suffix"
}
/**
* Generate random password.
* 16 characters, excluding confusing characters.
*/
private fun generatePassword(): String {
return (1..PASSWORD_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
}
/**
* Connection information for the hotspot.
*/
data class ConnectionInfo(
val ssid: String,
val password: String,
val ipAddress: String,
val connectedPeers: Int
)
/**
* Callback interface for hotspot events.
*/
interface HotspotCallback {
fun onHotspotStarted()
fun onConnectionInfoUpdated(info: ConnectionInfo?)
fun onError(message: String)
}
}

View File

@ -0,0 +1,154 @@
package com.bitchat.android.hotspot
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
/**
* ViewModel for managing hotspot state and lifecycle.
*/
class HotspotViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "HotspotViewModel"
}
private val _state = MutableStateFlow<HotspotState>(HotspotState.Intro)
val state: StateFlow<HotspotState> = _state.asStateFlow()
private var hotspotManager: HotspotManager? = null
private var webServer: ApkWebServer? = null
private val context = application.applicationContext
/**
* Start the hotspot with the provided APK file.
*/
fun startHotspot(apkFile: File) {
if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) {
Log.w(TAG, "Hotspot already starting or active")
return
}
Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}")
_state.value = HotspotState.Starting
viewModelScope.launch {
try {
// Start hotspot
val manager = HotspotManager(context)
hotspotManager = manager
manager.startHotspot(object : HotspotManager.HotspotCallback {
override fun onHotspotStarted() {
viewModelScope.launch {
Log.d(TAG, "Hotspot started successfully")
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to get hotspot connection info")
return@launch
}
// Start web server
try {
val server = ApkWebServer(context, apkFile)
server.startServer()
webServer = server
Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}")
// Update state with connection info
_state.value = HotspotState.Active(
ssid = info.ssid,
password = info.password,
ipAddress = info.ipAddress,
port = ApkWebServer.DEFAULT_PORT,
connectedPeers = info.connectedPeers
)
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
manager.stopHotspot()
_state.value = HotspotState.Error("Failed to start web server: ${e.message}")
}
}
}
override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) {
viewModelScope.launch {
// Update peer count if we're active
val currentState = _state.value
if (currentState is HotspotState.Active && info != null) {
_state.value = currentState.copy(connectedPeers = info.connectedPeers)
}
}
}
override fun onError(message: String) {
viewModelScope.launch {
Log.e(TAG, "Hotspot error: $message")
_state.value = HotspotState.Error(message)
}
}
})
} catch (e: Exception) {
Log.e(TAG, "Error starting hotspot", e)
hotspotManager?.stopHotspot()
_state.value = HotspotState.Error(e.message ?: "Unknown error")
}
}
}
/**
* Stop the hotspot and web server.
*/
fun stopHotspot() {
Log.d(TAG, "Stopping hotspot")
webServer?.stopServer()
webServer = null
hotspotManager?.stopHotspot()
hotspotManager = null
_state.value = HotspotState.Intro
}
/**
* Reset to intro state (for retry after error).
*/
fun resetToIntro() {
stopHotspot()
_state.value = HotspotState.Intro
}
override fun onCleared() {
super.onCleared()
Log.d(TAG, "ViewModel cleared, stopping hotspot")
stopHotspot()
}
/**
* Hotspot state sealed class.
*/
sealed class HotspotState {
object Intro : HotspotState()
object Starting : HotspotState()
data class Active(
val ssid: String,
val password: String,
val ipAddress: String,
val port: Int,
val connectedPeers: Int
) : HotspotState()
data class Error(val message: String) : HotspotState()
}
}

View File

@ -0,0 +1,126 @@
package com.bitchat.android.hotspot
import android.graphics.Bitmap
import android.util.Log
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
/**
* Utility for generating QR codes for Wi-Fi connection and URL.
*/
object QrCodeGenerator {
private const val TAG = "QrCodeGenerator"
/**
* Generate QR code for Wi-Fi connection.
* Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
*
* This format is recognized by most Android/iOS devices for instant Wi-Fi connection.
*
* @param ssid Wi-Fi network name
* @param password Wi-Fi password
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? {
if (ssid.isBlank() || password.isBlank()) {
Log.w(TAG, "SSID or password is blank")
return null
}
// Escape special characters
val escapedSsid = escapeWifiString(ssid)
val escapedPassword = escapeWifiString(password)
// Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;"
Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid")
return generateQrBitmap(wifiString, sizePx)
}
/**
* Generate QR code for URL.
*
* @param url Website URL (e.g., "http://192.168.49.1:9999")
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
fun generateUrlQr(url: String, sizePx: Int): Bitmap? {
if (url.isBlank()) {
Log.w(TAG, "URL is blank")
return null
}
Log.d(TAG, "Generating URL QR code: $url")
return generateQrBitmap(url, sizePx)
}
/**
* Generate QR code bitmap from string data.
*
* @param data String data to encode
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) {
Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx")
return null
}
return try {
val matrix = QRCodeWriter().encode(
data,
BarcodeFormat.QR_CODE,
sizePx,
sizePx
)
bitmapFromMatrix(matrix)
} catch (e: Exception) {
Log.e(TAG, "Error generating QR code", e)
null
}
}
/**
* Convert BitMatrix to Bitmap.
* Pattern from VerificationSheet.kt.
*/
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] = if (matrix[x, y]) {
android.graphics.Color.BLACK
} else {
android.graphics.Color.WHITE
}
}
}
return bitmap
}
/**
* Escape special characters in Wi-Fi SSID/password for QR code format.
* Special characters that need escaping: \ ; , " :
*/
private fun escapeWifiString(input: String): String {
return input
.replace("\\", "\\\\") // Backslash must be escaped first
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\"", "\\\"")
.replace(":", "\\:")
}
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.identity
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
@ -7,6 +8,8 @@ import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
@ -18,7 +21,7 @@ import androidx.core.content.edit
* - Secure storage using Android EncryptedSharedPreferences
* - Fingerprint calculation and identity validation
*/
class SecureIdentityStateManager(private val context: Context) {
class SecureIdentityStateManager {
companion object {
private const val TAG = "SecureIdentityStateManager"
@ -32,12 +35,25 @@ class SecureIdentityStateManager(private val context: Context) {
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates
// process-wide so concurrent promotions cannot lose one another or
// race a panic wipe.
private val privateMediaPinsLock = Any()
private var privateMediaPinsEpoch = 0L
}
private val prefs: SharedPreferences
private val lock = Any()
init {
private var privateMediaPinsEpochAtCreation: Long
constructor(context: Context) {
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
// Create master key for encryption
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@ -52,6 +68,15 @@ class SecureIdentityStateManager(private val context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/** Test-only storage injection; production always uses encrypted prefs. */
internal constructor(prefs: SharedPreferences, testOnly: Boolean) {
require(testOnly) { "Plain SharedPreferences are test-only" }
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
this.prefs = prefs
}
// MARK: - Static Key Management
@ -293,6 +318,78 @@ class SecureIdentityStateManager(private val context: Context) {
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// MARK: - Authenticated private-media capability pins
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized false
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.any { it.equals(fingerprint, ignoreCase = true) } == true
}
}
/** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */
@SuppressLint("UseKtx")
fun storeAuthenticatedPeerState(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit = {}
): Boolean {
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
val normalizedFingerprint = fingerprint.lowercase()
return synchronized(privateMediaPinsLock) {
// A controller that survived panic must not republish pre-wipe proof state.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.toMutableSet() ?: mutableSetOf()
records.removeAll { it.startsWith("$normalizedFingerprint:") }
val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16)
records.add(
"$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}"
)
val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records)
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
pins.add(normalizedFingerprint)
editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins)
}
// This result is a security boundary: do not publish the Ed key in memory unless the
// encrypted identity record and its HSTS pin were durably committed together.
editor.commit().also { committed ->
if (committed) onCommitted()
}
}
}
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
if (!isValidFingerprint(fingerprint)) return null
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
val prefix = "${fingerprint.lowercase()}:"
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
val fields = record.split(':', limit = 3)
if (fields.size != 3) return@synchronized null
val capabilities = runCatching {
PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16))
}.getOrNull() ?: return@synchronized null
val signingKeyHex = fields[2]
if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null
val signingKey = runCatching {
signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}.getOrNull() ?: return@synchronized null
AuthenticatedPeerState(capabilities, signingKey)
}
}
fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? =
getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf()
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@ -368,9 +465,16 @@ class SecureIdentityStateManager(private val context: Context) {
/**
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
prefs.edit().clear().apply()
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
if (!prefs.edit().clear().commit()) {
Log.e(TAG, "Identity preference wipe could not be committed")
}
}
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear identity data: ${e.message}")

View File

@ -0,0 +1,37 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */
object AnnouncementIdentityValidator {
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L
fun verify(
packet: BitchatPacket,
claimedPeerID: String,
nowMs: Long = System.currentTimeMillis(),
verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean
): IdentityAnnouncement? {
if (packet.type != MessageType.ANNOUNCE.value) return null
val now = nowMs.coerceAtLeast(0).toULong()
val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp
if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null
val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null
if (announcement.signingPublicKey.size != 32) return null
val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null
if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null
val signature = packet.signature ?: return null
val canonicalData = packet.toBinaryDataForSigning() ?: return null
return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) {
announcement
} else {
null
}
}
}

View File

@ -0,0 +1,14 @@
package com.bitchat.android.mesh
/**
* Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that
* authentication attempt.
*/
internal object AuthenticatedBleLinkPolicy {
data class Claim(val deviceAddress: String, val linkID: String)
fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean =
claim != null &&
claim.deviceAddress == authenticatedAddress &&
claim.linkID == authenticatedLinkID
}

View File

@ -0,0 +1,235 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal interface AuthenticatedPeerStateStore {
fun load(fingerprint: String): AuthenticatedPeerState?
fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean
fun isPrivateMediaPinned(fingerprint: String): Boolean
}
internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun load(fingerprint: String): AuthenticatedPeerState? =
identityState.getAuthenticatedPeerState(fingerprint)
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted)
override fun isPrivateMediaPinned(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
}
internal sealed interface AuthenticatedPeerStateStatus {
data object Missing : AuthenticatedPeerStateStatus
data object Awaiting : AuthenticatedPeerStateStatus
data object TimedOut : AuthenticatedPeerStateStatus
data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus
}
/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */
internal class AuthenticatedPeerStateCoordinator(
private val scope: CoroutineScope,
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val withAuthenticatedSession: (
String,
AuthenticatedNoiseSession,
() -> Boolean
) -> Boolean,
private val store: AuthenticatedPeerStateStore,
private val localStateProvider: () -> AuthenticatedPeerState,
private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit,
private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean,
private val onResolution: (String) -> Unit,
private val proofTimeoutMs: Long = 5_000L
) {
private data class SessionState(
val authenticatedSession: AuthenticatedNoiseSession,
val fingerprint: String,
var status: AuthenticatedPeerStateStatus,
var echoSent: Boolean,
var timeoutJob: Job? = null
)
private val lock = Any()
private val sessions = ConcurrentHashMap<String, SessionState>()
fun onSessionAuthenticated(
peerID: String,
authenticatedRemoteStatic: ByteArray,
authenticatedSessionToken: ByteArray
) {
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return
if (authenticatedSessionToken.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) return
val authenticatedSession = AuthenticatedNoiseSession(
authenticatedRemoteStatic.copyOf(),
authenticatedSessionToken.copyOf()
)
ensureSession(peerID, authenticatedSession)
}
/** Install one watchdog/exchange for this exact live generation, without resetting it. */
private fun ensureSession(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): SessionState? {
val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null
if (authenticatedSession.sessionToken.size != 32 ||
authenticatedSession.sessionToken.all { it == 0.toByte() }
) return null
// Ignore a delayed callback or policy snapshot if a later generation is already active.
if (authenticatedSessionProvider(peerID) != authenticatedSession) return null
val session = SessionState(
authenticatedSession = authenticatedSession,
fingerprint = fingerprint(authenticatedRemoteStatic),
status = AuthenticatedPeerStateStatus.Awaiting,
echoSent = false
)
val installed = withAuthenticatedSession(peerID, authenticatedSession) {
synchronized(lock) {
val existing = sessions[peerID]
if (existing?.authenticatedSession == authenticatedSession) {
return@synchronized false
}
sessions.put(peerID, session)?.timeoutJob?.cancel()
true
}
}
if (!installed) return synchronized(lock) { sessions[peerID] }
// Emit for every authenticated generation/rekey. Failure does not relax the watchdog.
runCatching { sendState(peerID, localStateProvider(), authenticatedSession) }
val timeout = scope.launch {
delay(proofTimeoutMs)
val resolved = synchronized(lock) {
val current = sessions[peerID]
if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) {
false
} else {
current.status = AuthenticatedPeerStateStatus.TimedOut
true
}
}
if (resolved) onResolution(peerID)
}
synchronized(lock) {
if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) {
session.timeoutJob = timeout
} else {
timeout.cancel()
}
}
return session
}
/** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */
fun receive(
peerID: String,
state: AuthenticatedPeerState,
decryptedSession: AuthenticatedNoiseSession
): Boolean {
val remoteStatic = decryptedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false
if (decryptedSession.sessionToken.size != 32 ||
decryptedSession.sessionToken.all { it == 0.toByte() }
) return false
val currentFingerprint = fingerprint(remoteStatic)
var shouldEcho = false
var echoSession: AuthenticatedNoiseSession? = null
val accepted = withAuthenticatedSession(peerID, decryptedSession) {
synchronized(lock) {
val current = sessions[peerID] ?: return@synchronized false
if (current.fingerprint != currentFingerprint ||
current.authenticatedSession != decryptedSession
) return@synchronized false
val proven = current.status as? AuthenticatedPeerStateStatus.Proven
if (proven != null) return@synchronized proven.state == state
try {
// Persist before publishing the replacement Ed key in memory, so a restart
// cannot reopen copied-static first-announce poisoning. The Noise manager
// lease prevents this generation from being replaced during the transition.
if (!store.persist(currentFingerprint, state) {
// Publish while the persistence epoch lock is still held. A panic wipe
// can therefore happen before both operations or after both, never between.
applyAuthenticatedState(peerID, remoteStatic, state)
}
) return@synchronized false
current.timeoutJob?.cancel()
current.status = AuthenticatedPeerStateStatus.Proven(state)
if (!current.echoSent) {
current.echoSent = true
shouldEcho = true
echoSession = current.authenticatedSession
}
true
} catch (_: Exception) {
false
}
}
}
if (!accepted) return false
if (shouldEcho) {
val exactSession = echoSession ?: return false
runCatching { sendState(peerID, localStateProvider(), exactSession) }
}
onResolution(peerID)
return true
}
fun status(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): AuthenticatedPeerStateStatus {
ensureSession(peerID, authenticatedSession)
val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey)
return synchronized(lock) {
sessions[peerID]?.takeIf {
it.fingerprint == currentFingerprint &&
it.authenticatedSession == authenticatedSession
}?.status
?: AuthenticatedPeerStateStatus.Missing
}
}
fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? {
if (noisePublicKey.size != 32) return null
return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf()
}
fun isPrivateMediaPinned(peerID: String): Boolean {
val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false
return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey))
}
fun clear(peerID: String) {
synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() }
}
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}

View File

@ -0,0 +1,18 @@
package com.bitchat.android.mesh
import com.bitchat.android.protocol.MessageType
/**
* iOS-compatible BLE padding policy.
*
* Keep this aligned with iOS BLEOutboundPacketPolicy.padsBLEFrame(for:):
* only Noise frames are padded over BLE.
*/
object BLEPacketPaddingPolicy {
fun shouldPadForBLE(type: UByte): Boolean {
return when (MessageType.fromValue(type)) {
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true
else -> false
}
}
}

View File

@ -1,13 +0,0 @@
package com.bitchat.android.mesh
object BlePacketBudget {
private const val ATT_PAYLOAD_OVERHEAD_BYTES = 3
private const val DEFAULT_PACKET_LIMIT_BYTES = 182
private const val MIN_PACKET_LIMIT_BYTES = 20
fun packetLimitBytesForMtu(mtu: Int?): Int {
val payloadBytes = (mtu ?: (DEFAULT_PACKET_LIMIT_BYTES + ATT_PAYLOAD_OVERHEAD_BYTES)) -
ATT_PAYLOAD_OVERHEAD_BYTES
return payloadBytes.coerceAtLeast(MIN_PACKET_LIMIT_BYTES)
}
}

View File

@ -1,94 +0,0 @@
package com.bitchat.android.mesh
import com.bitchat.android.protocol.BitchatPacket
import java.util.concurrent.ConcurrentHashMap
/**
* Reassembles characteristic writes that arrive in multiple offset chunks.
*
* CoreBluetooth may split a single packet across multiple writes when acting as
* the central. Android's GATT server callback receives those chunks one by one,
* so we keep a per-device sparse buffer and only hand the packet upstream once
* the accumulated bytes decode successfully.
*/
class BleWriteAccumulator {
private data class PendingWrite(
val buffer: ByteArray,
val receivedRanges: List<IntRange>
)
private val pendingWrites = ConcurrentHashMap<String, PendingWrite>()
fun append(deviceAddress: String, offset: Int, chunk: ByteArray): BitchatPacket? {
if (chunk.isEmpty()) {
return null
}
val current = pendingWrites[deviceAddress]
val existing = if (offset == 0 && current?.receivedRanges?.any { it.first == 0 } == true) {
null
} else {
current
}
val end = offset + chunk.size
val existingBuffer = existing?.buffer ?: ByteArray(0)
val combined = if (existingBuffer.size >= end) {
existingBuffer.copyOf()
} else {
existingBuffer.copyOf(end)
}
chunk.copyInto(combined, destinationOffset = offset)
val mergedRanges = mergeRanges(existing?.receivedRanges.orEmpty(), IntRange(offset, end - 1))
val pendingWrite = PendingWrite(combined, mergedRanges)
pendingWrites[deviceAddress] = pendingWrite
if (!isContiguousFromStart(pendingWrite)) {
return null
}
val packet = BitchatPacket.fromBinaryData(combined) ?: return null
val canonicalEncoding = packet.toBinaryData() ?: return null
if (!canonicalEncoding.contentEquals(combined)) {
return null
}
pendingWrites.remove(deviceAddress)
return packet
}
fun clear(deviceAddress: String) {
pendingWrites.remove(deviceAddress)
}
fun clearAll() {
pendingWrites.clear()
}
private fun mergeRanges(existing: List<IntRange>, next: IntRange): List<IntRange> {
val sorted = buildList {
addAll(existing)
add(next)
}.sortedBy { it.first }
if (sorted.isEmpty()) {
return emptyList()
}
val merged = mutableListOf<IntRange>()
var current = sorted.first()
for (candidate in sorted.drop(1)) {
current = if (candidate.first <= current.last + 1) {
current.first..maxOf(current.last, candidate.last)
} else {
merged.add(current)
candidate
}
}
merged.add(current)
return merged
}
private fun isContiguousFromStart(pendingWrite: PendingWrite): Boolean {
val onlyRange = pendingWrite.receivedRanges.singleOrNull() ?: return false
return onlyRange.first == 0 && onlyRange.last + 1 == pendingWrite.buffer.size
}
}

View File

@ -42,7 +42,12 @@ class BluetoothConnectionManager(
// Delegate for component managers to call back to main manager
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
override fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
) {
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
device?.let { bluetoothDevice ->
// Get current RSSI for this device and update if available
@ -54,7 +59,7 @@ class BluetoothConnectionManager(
if (peerID == myPeerID) return // Ignore messages from self
delegate?.onPacketReceived(packet, peerID, device)
delegate?.onPacketReceived(packet, peerID, device, ingressLinkID)
}
override fun onDeviceConnected(device: BluetoothDevice) {
@ -63,8 +68,8 @@ class BluetoothConnectionManager(
delegate?.onDeviceConnected(device)
}
override fun onDeviceDisconnected(device: BluetoothDevice) {
delegate?.onDeviceDisconnected(device)
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) {
delegate?.onDeviceDisconnected(device, linkID)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@ -88,22 +93,55 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID)
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
} catch (_: Exception) {
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
}
}
private fun isGattServerEnabled(): Boolean {
return isBleTransportEnabled() &&
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
}
private fun isGattClientEnabled(): Boolean {
return isBleTransportEnabled() &&
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
}
init {
powerManager.delegate = this
// Observe debug settings to enforce role state while active
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Master transport enable/disable
connectionScope.launch {
dbg.bleEnabled.collect { enabled ->
if (enabled) return@collect
if (isActive) {
disableTransport()
}
}
}
// Role enable/disable
connectionScope.launch {
dbg.gattServerEnabled.collect { enabled ->
if (!isActive) return@collect
if (enabled) startServer() else stopServer()
if (enabled && isBleTransportEnabled()) startServer() else stopServer()
}
}
connectionScope.launch {
dbg.gattClientEnabled.collect { enabled ->
if (!isActive) return@collect
if (enabled) startClient() else stopClient()
if (enabled && isBleTransportEnabled()) startClient() else stopClient()
}
}
@ -163,6 +201,12 @@ class BluetoothConnectionManager(
*/
fun startServices(): Boolean {
Log.i(TAG, "Starting power-optimized Bluetooth services...")
if (!isBleTransportEnabled()) {
Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
disableTransport()
return false
}
if (!permissionManager.hasBluetoothPermissions()) {
Log.e(TAG, "Missing Bluetooth permissions")
@ -197,9 +241,8 @@ class BluetoothConnectionManager(
powerManager.start()
// Start server/client based on debug settings
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
val startServer = dbg?.gattServerEnabled?.value != false
val startClient = dbg?.gattClientEnabled?.value != false
val startServer = isGattServerEnabled()
val startClient = isGattClientEnabled()
if (startServer) {
if (!serverManager.start()) {
@ -234,6 +277,19 @@ class BluetoothConnectionManager(
return false
}
}
/**
* Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled.
*/
fun disableTransport() {
Log.i(TAG, "Disabling BLE transport")
isActive = false
connectionScope.launch {
clientManager.stop()
serverManager.stop()
connectionTracker.stop()
}
}
/**
* Stop all Bluetooth services with proper cleanup
@ -278,10 +334,10 @@ class BluetoothConnectionManager(
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(routed: RoutedPacket) {
if (!isActive) return
packetBroadcaster.broadcastPacket(
fun broadcastPacket(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastPacket(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
@ -289,7 +345,7 @@ class BluetoothConnectionManager(
}
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive) return false
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendToPeer(
peerID,
routed,
@ -306,7 +362,7 @@ class BluetoothConnectionManager(
* Send a packet directly to a specific peer, without broadcasting to others.
*/
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
if (!isActive) return false
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeer(
RoutedPacket(packet),
peerID,
@ -314,12 +370,29 @@ class BluetoothConnectionManager(
serverManager.getCharacteristic()
)
}
fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToLink(
RoutedPacket(packet),
deviceAddress,
linkID,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
// Expose role controls for debug UI
fun startServer() { connectionScope.launch { serverManager.start() } }
fun startServer() {
if (!isActive || !isBleTransportEnabled()) return
connectionScope.launch { if (isGattServerEnabled()) serverManager.start() }
}
fun stopServer() { connectionScope.launch { serverManager.stop() } }
fun startClient() { connectionScope.launch { clientManager.start() } }
fun startClient() {
if (!isActive || !isBleTransportEnabled()) return
connectionScope.launch { if (isGattClientEnabled()) clientManager.start() }
}
fun stopClient() { connectionScope.launch { clientManager.stop() } }
// Inject nickname resolver for broadcaster logs
@ -347,7 +420,10 @@ class BluetoothConnectionManager(
/**
* Public: connect/disconnect helpers for debug UI
*/
fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
fun connectToAddress(address: String): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return clientManager.connectToAddress(address)
}
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
@ -358,10 +434,10 @@ class BluetoothConnectionManager(
clientManager.stop()
serverManager.stop()
delay(200)
if (isActive) {
if (isActive && isBleTransportEnabled()) {
// Restart managers if service is active
serverManager.start()
clientManager.start()
if (isGattServerEnabled()) serverManager.start()
if (isGattClientEnabled()) clientManager.start()
}
}
}
@ -396,11 +472,17 @@ class BluetoothConnectionManager(
Log.i(TAG, "Power mode changed to: $newMode")
connectionScope.launch {
if (!isActive || !isBleTransportEnabled()) {
serverManager.stop()
clientManager.stop()
return@launch
}
// Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings if server enabled
val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
val serverEnabled = isGattServerEnabled()
if (serverEnabled) {
serverManager.restartAdvertising()
} else {
@ -411,7 +493,7 @@ class BluetoothConnectionManager(
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
val clientEnabled = isGattClientEnabled()
if (clientEnabled) {
clientManager.restartScanning()
} else {
@ -427,6 +509,10 @@ class BluetoothConnectionManager(
}
override fun onScanStateChanged(shouldScan: Boolean) {
if (!isActive || !isBleTransportEnabled()) {
clientManager.onScanStateChanged(false)
return
}
clientManager.onScanStateChanged(shouldScan)
}
@ -437,8 +523,13 @@ class BluetoothConnectionManager(
* Delegate interface for Bluetooth connection manager callbacks
*/
interface BluetoothConnectionManagerDelegate {
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
)
fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
}

View File

@ -4,13 +4,12 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CopyOnWriteArrayList
import java.util.UUID
/**
* Tracks all Bluetooth connections and handles cleanup
@ -18,31 +17,22 @@ import java.util.concurrent.CopyOnWriteArrayList
class BluetoothConnectionTracker(
private val connectionScope: CoroutineScope,
private val powerManager: PowerManager
) {
) : MeshConnectionTracker(connectionScope, TAG) {
companion object {
private const val TAG = "BluetoothConnectionTracker"
private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS
private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds
}
// Connection tracking - reduced memory footprint
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
private val deviceMtus = ConcurrentHashMap<String, Int>()
private val pendingNotificationAcks =
ConcurrentHashMap<String, ConcurrentLinkedQueue<CompletableDeferred<Int>>>()
// 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)
private val scanRSSI = ConcurrentHashMap<String, Int>()
// Connection attempt tracking with automatic cleanup
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
// State management
private var isActive = false
private val peerBindingLock = Any()
/**
* Consolidated device connection information
@ -54,55 +44,67 @@ class BluetoothConnectionTracker(
val rssi: Int = Int.MIN_VALUE,
val isClient: Boolean = false,
val connectedAt: Long = System.currentTimeMillis(),
val peerID: String? = null
val peerID: String? = null,
/** Unique to this GATT connection, even when Android reuses the device address. */
val linkID: String = UUID.randomUUID().toString()
)
/**
* Connection attempt tracking with automatic expiry
*/
data class ConnectionAttempt(
val attempts: Int,
val lastAttempt: Long = System.currentTimeMillis()
) {
fun isExpired(): Boolean =
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
fun shouldRetry(): Boolean =
attempts < MAX_CONNECTION_ATTEMPTS &&
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
override fun start() {
super.start()
}
/**
* Start the connection tracker
*/
fun start() {
isActive = true
startPeriodicCleanup()
}
/**
* Stop the connection tracker
*/
fun stop() {
isActive = false
override fun stop() {
super.stop()
cleanupAllConnections()
clearAllConnections()
}
// Abstract implementations
override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id)
override fun disconnect(id: String) {
connectedDevices[id]?.gatt?.let {
try { it.disconnect() } catch (_: Exception) { }
}
cleanupDeviceConnection(id)
Log.d(TAG, "Requested disconnect for $id")
}
override fun getConnectionCount(): Int = connectedDevices.size
/**
* Add a device connection
*/
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress)
synchronized(peerBindingLock) {
connectedDevices[deviceAddress] = deviceConn
// A mapping authenticates a GATT connection, not a reusable Bluetooth address.
addressPeerMap.remove(deviceAddress)
}
removePendingConnection(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
}
/**
* Update a device connection
*/
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
connectedDevices[deviceAddress] = deviceConn
synchronized(peerBindingLock) {
connectedDevices[deviceAddress] = deviceConn
}
}
fun updateDeviceConnectionIfCurrent(
deviceAddress: String,
linkID: String,
update: (DeviceConnection) -> DeviceConnection
): Boolean = synchronized(peerBindingLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != linkID) return@synchronized false
connectedDevices[deviceAddress] = update(current)
true
}
/**
@ -111,6 +113,17 @@ class BluetoothConnectionTracker(
fun getDeviceConnection(deviceAddress: String): DeviceConnection? {
return connectedDevices[deviceAddress]
}
fun getCurrentLinkID(deviceAddress: String): String? =
connectedDevices[deviceAddress]?.linkID
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(peerBindingLock) {
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
addressPeerMap[deviceAddress] = peerID
true
}
/**
* Get all connected devices
@ -168,9 +181,7 @@ class BluetoothConnectionTracker(
/**
* Check if device is already connected
*/
fun isDeviceConnected(deviceAddress: String): Boolean {
return connectedDevices.containsKey(deviceAddress)
}
fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
/**
* Check if a peer is already connected (by PeerID)
@ -180,113 +191,15 @@ class BluetoothConnectionTracker(
return connectedDevices.values.any { it.peerID == peerID }
}
/**
* Check if connection attempt is allowed
*/
fun isConnectionAttemptAllowed(deviceAddress: String): Boolean {
val existingAttempt = pendingConnections[deviceAddress]
return existingAttempt?.let {
it.isExpired() || it.shouldRetry()
} ?: true
}
/**
* Add a pending connection attempt
*/
fun addPendingConnection(deviceAddress: String): Boolean {
Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress")
synchronized(pendingConnections) {
// Double-check inside synchronized block
val currentAttempt = pendingConnections[deviceAddress]
if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress")
return false
}
if (currentAttempt != null) {
Log.d(TAG, "Tracker: current attempt: $currentAttempt")
}
// Update connection attempt atomically
// 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)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true
}
}
/**
* Disconnect a specific device (by MAC address)
*/
fun disconnectDevice(deviceAddress: String) {
connectedDevices[deviceAddress]?.gatt?.let {
try { it.disconnect() } catch (_: Exception) { }
}
cleanupDeviceConnection(deviceAddress)
Log.d(TAG, "Requested disconnect for $deviceAddress")
}
/**
* Remove a pending connection
*/
fun removePendingConnection(deviceAddress: String) {
pendingConnections.remove(deviceAddress)
}
fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress)
/**
* Get connected device count
*/
fun getConnectedDeviceCount(): Int = connectedDevices.size
fun updateDeviceMtu(deviceAddress: String, mtu: Int) {
if (mtu > 0) {
deviceMtus[deviceAddress] = mtu
}
}
fun enqueueNotificationAck(deviceAddress: String): CompletableDeferred<Int> {
val deferred = CompletableDeferred<Int>()
pendingNotificationAcks
.getOrPut(deviceAddress) { ConcurrentLinkedQueue() }
.add(deferred)
return deferred
}
fun completeNotificationAck(deviceAddress: String, status: Int) {
val queue = pendingNotificationAcks[deviceAddress] ?: return
while (true) {
val deferred = queue.poll() ?: break
if (deferred.complete(status)) {
break
}
}
if (queue.isEmpty()) {
pendingNotificationAcks.remove(deviceAddress, queue)
}
}
fun cancelNotificationAck(
deviceAddress: String,
deferred: CompletableDeferred<Int>,
removeImmediately: Boolean = false
) {
deferred.cancel()
val queue = pendingNotificationAcks[deviceAddress] ?: return
if (removeImmediately) {
queue.remove(deferred)
}
if (queue.isEmpty()) {
pendingNotificationAcks.remove(deviceAddress, queue)
}
}
fun clearNotificationAcks(deviceAddress: String) {
pendingNotificationAcks.remove(deviceAddress)?.forEach { it.cancel() }
}
fun getDevicePacketLimit(deviceAddress: String): Int {
return BlePacketBudget.packetLimitBytesForMtu(deviceMtus[deviceAddress])
}
fun getConnectedDeviceCount(): Int = getConnectionCount()
/**
* Check if connection limit is reached
@ -352,14 +265,33 @@ class BluetoothConnectionTracker(
* Clean up a specific device connection
*/
fun cleanupDeviceConnection(deviceAddress: String) {
connectedDevices.remove(deviceAddress)?.let { deviceConn ->
synchronized(peerBindingLock) {
connectedDevices.remove(deviceAddress)
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
}
deviceMtus.remove(deviceAddress)
clearNotificationAcks(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
fun cleanupDeviceConnectionIfCurrent(
deviceAddress: String,
expectedLinkID: String
): Boolean = synchronized(peerBindingLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != expectedLinkID) {
return@synchronized false
}
if (connectedDevices.remove(deviceAddress, current)) {
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
true
} else {
false
}
}
/**
* Clean up all connections
@ -389,41 +321,23 @@ class BluetoothConnectionTracker(
connectedDevices.clear()
subscribedDevices.clear()
addressPeerMap.clear()
deviceMtus.clear()
pendingNotificationAcks.values.forEach { queue -> queue.forEach { it.cancel() } }
pendingNotificationAcks.clear()
pendingConnections.clear()
scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Start periodic cleanup of expired connections
* Mark that we have received the first ANNOUNCE over this device connection.
*/
private fun startPeriodicCleanup() {
connectionScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
if (!isActive) break
try {
// Clean up expired pending connections
val expiredConnections = pendingConnections.filter { it.value.isExpired() }
expiredConnections.keys.forEach { pendingConnections.remove(it) }
// Log cleanup if any
if (expiredConnections.isNotEmpty()) {
Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts")
}
// Log current state
Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending")
} catch (e: Exception) {
Log.w(TAG, "Error in periodic cleanup: ${e.message}")
}
}
}
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
}
/**

View File

@ -32,6 +32,11 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
// Self-healing scan recovery tuning
private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
}
// Core Bluetooth components
@ -39,11 +44,28 @@ class BluetoothGattClientManager(
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
} catch (_: Exception) {
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
}
}
private fun isClientRoleEnabled(): Boolean {
return isBleTransportEnabled() &&
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
}
/**
* Public: Connect to a device by MAC address (for debug UI)
*/
fun connectToAddress(deviceAddress: String): Boolean {
if (!isClientRoleEnabled()) {
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
return false
}
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
return if (device != null) {
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
@ -61,8 +83,16 @@ class BluetoothGattClientManager(
// Scan rate limiting to prevent "scanning too frequently" errors
private var lastScanStartTime = 0L
private var lastScanStopTime = 0L
private var isCurrentlyScanning = false
@Volatile private var isCurrentlyScanning = false
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
// Self-healing scan state.
// scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
// from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
@Volatile private var scanningDesired = false
@Volatile private var lastScanResultTime = 0L
private var scanRetryCount = 0
private var scanWatchdogJob: Job? = null
// RSSI monitoring state
private var rssiMonitoringJob: Job? = null
@ -75,12 +105,10 @@ class BluetoothGattClientManager(
*/
fun start(): Boolean {
// Respect debug setting
try {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
return false
}
} catch (_: Exception) { }
if (!isClientRoleEnabled()) {
Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
return false
}
if (isActive) {
Log.d(TAG, "GATT client already active; start is a no-op")
@ -106,12 +134,16 @@ class BluetoothGattClientManager(
connectionScope.launch {
if (powerManager.shouldUseDutyCycle()) {
Log.i(TAG, "Using power-aware duty cycling")
// Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
} else {
scanningDesired = true
startScanning()
}
// Start RSSI monitoring
startRSSIMonitoring()
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
startScanWatchdog()
}
return true
@ -121,6 +153,8 @@ class BluetoothGattClientManager(
* Stop client manager
*/
fun stop() {
scanningDesired = false
stopScanWatchdog()
if (!isActive) {
// Idempotent stop
stopScanning()
@ -150,7 +184,8 @@ class BluetoothGattClientManager(
* Handle scan state changes from power manager
*/
fun onScanStateChanged(shouldScan: Boolean) {
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
val enabled = isClientRoleEnabled()
scanningDesired = shouldScan && enabled
if (shouldScan && enabled) {
startScanning()
} else {
@ -199,7 +234,7 @@ class BluetoothGattClientManager(
@Suppress("DEPRECATION")
private fun startScanning() {
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
val enabled = isClientRoleEnabled()
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
// Rate limit scan starts to prevent "scanning too frequently" errors
@ -217,7 +252,7 @@ class BluetoothGattClientManager(
// Schedule delayed scan start
connectionScope.launch {
delay(remainingWait)
if (isActive && !isCurrentlyScanning) {
if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
startScanning()
}
}
@ -251,22 +286,39 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
when (errorCode) {
1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
1 -> {
// Already started: the stack thinks a scan is running. Re-arm from a clean
// state so we don't stay wedged (stop then restart with backoff).
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
stopScanning()
scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
}
2 -> {
// App registration failed: common transient stack fault. Previously had NO
// retry, which left discovery dead until a manual BLE toggle.
Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
}
3 -> {
Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
}
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
5 -> {
// Out of hardware resources: back off longer so other scanners/connections
// can free up before we try again.
Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
}
6 -> {
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
connectionScope.launch {
delay(10000) // Wait 10 seconds before retrying
if (isActive) {
startScanning()
}
}
scheduleScanRestart("too-frequently", 10_000L)
}
else -> {
Log.e(TAG, "Unknown scan failure code: $errorCode")
scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
}
else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
}
}
}
@ -304,6 +356,76 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
}
}
/**
* Schedule a scan restart with incremental backoff. Used to recover from transient scan
* failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
* manual BLE toggle.
*/
private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
scanRetryCount++
val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
connectionScope.launch {
delay(delayMs)
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
startScanning()
}
}
}
/**
* Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
* onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
* believing it is scanning while it is not. This re-arms the scanner in those cases.
*/
private fun startScanWatchdog() {
scanWatchdogJob?.cancel()
scanWatchdogJob = connectionScope.launch {
while (isActive) {
delay(SCAN_WATCHDOG_INTERVAL_MS)
try {
// Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
// and the client-disabled state via scanningDesired.
if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
val now = System.currentTimeMillis()
if (!isCurrentlyScanning) {
Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
startScanning()
} else if (lastScanResultTime > 0L &&
now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
// We think we're scanning but haven't seen anything for a long time. The scan
// may have silently died (flag wedged true). Force a clean re-arm.
Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
forceRestartScan()
}
} catch (e: Exception) {
Log.w(TAG, "Scan watchdog error: ${e.message}")
}
}
}
}
private fun stopScanWatchdog() {
scanWatchdogJob?.cancel()
scanWatchdogJob = null
}
/**
* Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
*/
private fun forceRestartScan() {
stopScanning()
connectionScope.launch {
delay(500)
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
startScanning()
}
}
}
/**
* Handle scan result and initiate connection if appropriate
@ -320,6 +442,10 @@ class BluetoothGattClientManager(
return
}
// Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
lastScanResultTime = System.currentTimeMillis()
scanRetryCount = 0
// Try to extract peerID from Service Data (if available) for stable identity
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
val peerID = if (serviceData != null && serviceData.size >= 8) {
@ -402,9 +528,11 @@ class BluetoothGattClientManager(
*/
@Suppress("DEPRECATION")
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
if (!isClientRoleEnabled()) return
if (!permissionManager.hasBluetoothPermissions()) return
val deviceAddress = device.address
val linkID = UUID.randomUUID().toString()
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
val gattCallback = object : BluetoothGattCallback() {
@ -426,11 +554,11 @@ class BluetoothGattClientManager(
}
} else {
Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress")
connectionTracker.cleanupDeviceConnection(deviceAddress)
}
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
// Notify higher layers about device disconnection to update direct flags
delegate?.onDeviceDisconnected(gatt.device)
delegate?.onDeviceDisconnected(gatt.device, linkID)
connectionScope.launch {
delay(500) // CLEANUP_DELAY
@ -448,7 +576,6 @@ class BluetoothGattClientManager(
Log.i(TAG, "Client: MTU changed for $deviceAddress to $mtu with status $status")
if (status == BluetoothGatt.GATT_SUCCESS) {
connectionTracker.updateDeviceMtu(deviceAddress, mtu)
Log.i(TAG, "MTU successfully negotiated for $deviceAddress. Discovering services.")
// Now that MTU is set, connection is fully ready.
@ -457,7 +584,8 @@ class BluetoothGattClientManager(
gatt = gatt,
rssi = rssi,
isClient = true,
peerID = peerID // Store the peerID discovered during scan
peerID = peerID, // Store the peerID discovered during scan
linkID = linkID
)
connectionTracker.addDeviceConnection(deviceAddress, deviceConn)
@ -476,9 +604,11 @@ class BluetoothGattClientManager(
if (service != null) {
val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID)
if (characteristic != null) {
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(characteristic = characteristic)
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
if (connectionTracker.updateDeviceConnectionIfCurrent(
deviceAddress,
linkID
) { it.copy(characteristic = characteristic) }
) {
Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress")
}
@ -518,7 +648,7 @@ class BluetoothGattClientManager(
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, gatt.device)
delegate?.onPacketReceived(packet, peerID, gatt.device, linkID)
} else {
Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
@ -531,9 +661,8 @@ class BluetoothGattClientManager(
Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm")
// Update the connection tracker with new RSSI value
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(rssi = rssi)
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
it.copy(rssi = rssi)
}
} else {
Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status")
@ -563,7 +692,7 @@ class BluetoothGattClientManager(
*/
fun restartScanning() {
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
val enabled = isClientRoleEnabled()
if (!isActive || !enabled) return
connectionScope.launch {

View File

@ -8,11 +8,13 @@ import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* Manages GATT server operations, advertising, and server-side connections
@ -29,6 +31,9 @@ class BluetoothGattServerManager(
companion object {
private const val TAG = "BluetoothGattServerManager"
// Self-healing advertising recovery tuning
private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
}
// Core Bluetooth components
@ -39,12 +44,26 @@ class BluetoothGattServerManager(
// GATT server for peripheral mode
private var gattServer: BluetoothGattServer? = null
private val serverLinkIDs = ConcurrentHashMap<String, String>()
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiseCallback: AdvertiseCallback? = null
private var advertiseRetryCount = 0
// State management
private var isActive = false
private val writeAccumulator = BleWriteAccumulator()
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
} catch (_: Exception) {
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
}
}
private fun isServerRoleEnabled(): Boolean {
return isBleTransportEnabled() &&
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
}
/**
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
@ -62,12 +81,10 @@ class BluetoothGattServerManager(
*/
fun start(): Boolean {
// Respect debug setting
try {
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
return false
}
} catch (_: Exception) { }
if (!isServerRoleEnabled()) {
Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
return false
}
if (isActive) {
Log.d(TAG, "GATT server already active; start is a no-op")
@ -109,7 +126,7 @@ class BluetoothGattServerManager(
// Ensure server is closed if present
gattServer?.close()
gattServer = null
writeAccumulator.clearAll()
serverLinkIDs.clear()
Log.i(TAG, "GATT server stopped (already inactive)")
return
}
@ -131,7 +148,7 @@ class BluetoothGattServerManager(
// Close GATT server
gattServer?.close()
gattServer = null
writeAccumulator.clearAll()
serverLinkIDs.clear()
Log.i(TAG, "GATT server stopped")
}
@ -165,6 +182,8 @@ class BluetoothGattServerManager(
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.i(TAG, "Server: Device connected ${device.address}")
val linkID = UUID.randomUUID().toString()
serverLinkIDs[device.address] = linkID
// Get best available RSSI (scan RSSI for server connections)
val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE
@ -172,7 +191,8 @@ class BluetoothGattServerManager(
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
device = device,
rssi = rssi,
isClient = false
isClient = false,
linkID = linkID
)
connectionTracker.addDeviceConnection(device.address, deviceConn)
@ -185,10 +205,12 @@ class BluetoothGattServerManager(
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.i(TAG, "Server: Device disconnected ${device.address}")
writeAccumulator.clear(device.address)
connectionTracker.cleanupDeviceConnection(device.address)
val linkID = serverLinkIDs.remove(device.address)
if (linkID != null) {
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
}
// Notify delegate about device disconnection so higher layers can update direct flags
delegate?.onDeviceDisconnected(device)
delegate?.onDeviceDisconnected(device, linkID)
}
}
}
@ -223,20 +245,29 @@ class BluetoothGattServerManager(
}
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
Log.i(
TAG,
"Server: Received write from ${device.address}, size=${value.size} bytes offset=$offset prepared=$preparedWrite"
)
val packet = writeAccumulator.append(device.address, offset, value)
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val linkID = serverLinkIDs[device.address]
if (linkID == null) {
Log.w(TAG, "Server: Dropping packet from stale connection ${device.address}")
if (responseNeeded) {
gattServer?.sendResponse(
device,
requestId,
BluetoothGatt.GATT_FAILURE,
0,
null
)
}
return
}
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, device)
delegate?.onPacketReceived(packet, peerID, device, linkID)
} else {
Log.d(
TAG,
"Server: Buffered partial write from ${device.address}, size=${value.size} bytes offset=$offset"
)
Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
}
if (responseNeeded) {
@ -276,25 +307,6 @@ class BluetoothGattServerManager(
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
}
}
override fun onMtuChanged(device: BluetoothDevice, mtu: Int) {
if (!isActive) {
Log.d(TAG, "Server: Ignoring MTU update after shutdown")
return
}
Log.i(TAG, "Server: MTU changed for ${device.address} to $mtu")
connectionTracker.updateDeviceMtu(device.address, mtu)
}
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
connectionTracker.completeNotificationAck(device.address, status)
if (!isActive) {
Log.d(TAG, "Server: Notification callback after shutdown for ${device.address} status=$status")
return
}
Log.d(TAG, "Server: Notification delivered to ${device.address} with status $status")
}
}
// Proper cleanup sequencing to prevent race conditions
@ -349,7 +361,7 @@ class BluetoothGattServerManager(
@Suppress("DEPRECATION")
private fun startAdvertising() {
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
val enabled = isServerRoleEnabled()
// Guard conditions never throw here to avoid crashing the app from a background coroutine
if (!permissionManager.hasBluetoothPermissions()) {
@ -401,6 +413,7 @@ class BluetoothGattServerManager(
advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
advertiseRetryCount = 0
val mode = try {
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
} catch (_: Exception) { "unknown" }
@ -409,6 +422,28 @@ class BluetoothGattServerManager(
override fun onStartFailure(errorCode: Int) {
Log.e(TAG, "Advertising failed: $errorCode")
// Previously this only logged, so if advertising failed this device became
// undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED ->
Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
ADVERTISE_FAILED_DATA_TOO_LARGE ->
Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
scheduleAdvertiseRestart("too-many-advertisers")
}
ADVERTISE_FAILED_INTERNAL_ERROR -> {
Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
scheduleAdvertiseRestart("internal-error")
}
else -> {
Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
scheduleAdvertiseRestart("unknown-$errorCode")
}
}
}
}
@ -434,12 +469,29 @@ class BluetoothGattServerManager(
}
}
/**
* Schedule an advertising restart with incremental backoff after a transient failure.
*/
private fun scheduleAdvertiseRestart(reason: String) {
advertiseRetryCount++
val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS)
Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)")
connectionScope.launch {
delay(delayMs)
if (isActive && isServerRoleEnabled()) {
stopAdvertising()
delay(100)
startAdvertising()
}
}
}
/**
* Restart advertising (for power mode changes)
*/
fun restartAdvertising() {
// Respect debug setting
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
val enabled = isServerRoleEnabled()
if (!isActive || !enabled) {
stopAdvertising()
return

View File

@ -5,13 +5,10 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattServer
import android.bluetooth.BluetoothStatusCodes
import android.os.Build
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -20,13 +17,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.Job
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.channels.actor
/**
@ -56,8 +47,6 @@ class BluetoothPacketBroadcaster(
companion object {
private const val TAG = "BluetoothPacketBroadcaster"
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
private const val FRAGMENT_SEND_DELAY_MS = com.bitchat.android.util.AppConstants.Mesh.FRAGMENT_SEND_DELAY_MS
private const val NOTIFICATION_ACK_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Mesh.NOTIFICATION_ACK_TIMEOUT_MS
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
@ -126,8 +115,7 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val transferJobs = ConcurrentHashMap<String, Job>()
private val notificationMutexes = ConcurrentHashMap<String, Mutex>()
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
@ -148,72 +136,15 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
) {
val packet = routed.packet
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
// Check if we need to fragment
if (fragmentManager != null) {
val maxPacketSize = resolveMaxPacketSize(packet, routed)
val fragments = try {
fragmentManager.createFragments(packet, maxPacketSize = maxPacketSize)
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
if (isFile) {
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
}
return
}
if (fragments.size > 1) {
if (isFile) {
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
}
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
if (transferId != null) {
TransferProgressManager.start(transferId, fragments.size)
}
val job = connectionScope.launch {
var sent = 0
fragments.forEach { fragment ->
if (!isActive) return@launch
// If cancelled, stop sending remaining fragments
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
delay(FRAGMENT_SEND_DELAY_MS)
if (transferId != null) {
sent += 1
TransferProgressManager.progress(transferId, sent, fragments.size)
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
}
}
}
if (transferId != null) {
transferJobs[transferId] = job
job.invokeOnCompletion { transferJobs.remove(transferId) }
}
return
}
}
// Send single packet if no fragmentation needed
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
broadcastSinglePacket(routed, gattServer, characteristic)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
): Boolean {
return fragmentingSender.send(routed, "BLE broadcast") { packet ->
broadcastSinglePacket(packet, gattServer, characteristic)
true
}
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()
return true
return fragmentingSender.cancelTransfer(transferId)
}
/**
@ -225,18 +156,49 @@ class BluetoothPacketBroadcaster(
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
if (!hasPeerConnection(targetPeerID)) return false
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
}
}
fun sendPacketToLink(
routed: RoutedPacket,
deviceAddress: String,
linkID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean = fragmentingSender.send(routed, "BLE link $deviceAddress") { packet ->
val data = packet.packet.toBinaryData(
padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.packet.type)
) ?: return@send false
val currentLink = connectionTracker.getDeviceConnection(deviceAddress)
?.takeIf { it.linkID == linkID }
?: return@send false
if (currentLink.isClient) {
return@send writeToDeviceConn(currentLink, data)
}
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { it.address == deviceAddress }
?: return@send false
notifyDevice(serverTarget, data, gattServer, characteristic)
}
private fun sendSinglePacketToPeer(
routed: RoutedPacket,
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
// iOS-compatible: Use selective padding policy for BLE
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
val data = packet.toBinaryData(padding = padForBLE) ?: return false
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
@ -251,10 +213,6 @@ class BluetoothPacketBroadcaster(
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@ -265,10 +223,6 @@ class BluetoothPacketBroadcaster(
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@ -276,54 +230,6 @@ class BluetoothPacketBroadcaster(
return false
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
private fun resolveMaxPacketSize(packet: BitchatPacket, routed: RoutedPacket): Int {
val senderID = packet.senderID.toHexString()
if (packet.senderID.toHexString() == myPeerID && packet.route?.isNotEmpty() == true) {
val firstHop = packet.route!!.first().toHexString()
return maxPacketSizeForPeer(firstHop)
}
if (packet.recipientID != SpecialRecipients.BROADCAST) {
val recipientID = packet.recipientID?.toHexString().orEmpty()
if (recipientID.isNotEmpty()) {
return maxPacketSizeForPeer(recipientID)
}
}
val candidateLimits = mutableListOf<Int>()
connectionTracker.getSubscribedDevices().forEach { device ->
if (device.address == routed.relayAddress) return@forEach
if (connectionTracker.addressPeerMap[device.address] == senderID) return@forEach
candidateLimits += connectionTracker.getDevicePacketLimit(device.address)
}
connectionTracker.getConnectedDevices().values.forEach { deviceConn ->
if (!deviceConn.isClient || deviceConn.gatt == null || deviceConn.characteristic == null) return@forEach
if (deviceConn.device.address == routed.relayAddress) return@forEach
if (connectionTracker.addressPeerMap[deviceConn.device.address] == senderID) return@forEach
candidateLimits += connectionTracker.getDevicePacketLimit(deviceConn.device.address)
}
return candidateLimits.minOrNull() ?: BlePacketBudget.packetLimitBytesForMtu(null)
}
private fun maxPacketSizeForPeer(peerID: String): Int {
val candidateLimits = mutableListOf<Int>()
connectionTracker.getSubscribedDevices()
.filter { connectionTracker.addressPeerMap[it.address] == peerID }
.forEach { candidateLimits += connectionTracker.getDevicePacketLimit(it.address) }
connectionTracker.getConnectedDevices().values
.filter { connectionTracker.addressPeerMap[it.device.address] == peerID }
.forEach { candidateLimits += connectionTracker.getDevicePacketLimit(it.device.address) }
return candidateLimits.minOrNull() ?: BlePacketBudget.packetLimitBytesForMtu(null)
}
/**
* Public entry point for broadcasting - submits request to actor for serialization
@ -355,34 +261,19 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
// Try server-side connections first
val targetDevice = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
if (targetDevice != null) {
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
return true
}
if (!hasPeerConnection(targetPeerID)) return false
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
}
}
// Try client-side connections next
val targetConn = connectionTracker.getConnectedDevices().values
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
if (targetConn != null) {
if (writeToDeviceConn(targetConn, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
return true
}
}
return false
private fun hasPeerConnection(targetPeerID: String): Boolean {
val hasServerTarget = connectionTracker.getSubscribedDevices()
.any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
if (hasServerTarget) return true
return connectionTracker.getConnectedDevices().values
.any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
}
/**
@ -394,7 +285,9 @@ class BluetoothPacketBroadcaster(
characteristic: BluetoothGattCharacteristic?
) {
val packet = routed.packet
val data = packet.toBinaryData() ?: return
// iOS-compatible: Use selective padding policy for BLE
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
val data = packet.toBinaryData(padding = padForBLE) ?: return
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
val incomingAddr = routed.relayAddress
@ -417,7 +310,7 @@ class BluetoothPacketBroadcaster(
if (serverTarget != null) {
Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}")
if (notifyDeviceSuspending(serverTarget, data, gattServer, characteristic)) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[serverTarget.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo)
sent = true
@ -454,7 +347,7 @@ class BluetoothPacketBroadcaster(
// If found, send directly
if (targetDevice != null) {
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
if (notifyDeviceSuspending(targetDevice, data, gattServer, characteristic)) {
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
return // Sent, no need to continue
@ -494,7 +387,7 @@ class BluetoothPacketBroadcaster(
Log.d(TAG, "Skipping broadcast to client back to sender: ${device.address}")
return@forEach
}
val sent = notifyDeviceSuspending(device, data, gattServer, characteristic)
val sent = notifyDevice(device, data, gattServer, characteristic)
if (sent) {
val toPeer = connectionTracker.addressPeerMap[device.address]
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
@ -530,69 +423,20 @@ class BluetoothPacketBroadcaster(
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
return runBlocking {
notifyDeviceSuspending(device, data, gattServer, characteristic)
}
}
private suspend fun notifyDeviceSuspending(
device: BluetoothDevice,
data: ByteArray,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val mutex = notificationMutexes.getOrPut(device.address) { Mutex() }
return mutex.withLock {
val char = characteristic ?: return@withLock false
val server = gattServer ?: return@withLock false
val ack = connectionTracker.enqueueNotificationAck(device.address)
try {
val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
server.notifyCharacteristicChanged(device, char, false, data)
} else {
@Suppress("DEPRECATION")
run {
char.value = data
if (server.notifyCharacteristicChanged(device, char, false)) {
BluetoothStatusCodes.SUCCESS
} else {
BluetoothStatusCodes.ERROR_UNKNOWN
}
}
}
if (queued != BluetoothStatusCodes.SUCCESS) {
connectionTracker.cancelNotificationAck(device.address, ack, removeImmediately = true)
Log.w(TAG, "Queued notification failed for ${device.address} with status $queued")
return@withLock false
}
val callbackStatus = withTimeoutOrNull(NOTIFICATION_ACK_TIMEOUT_MS) {
ack.await()
}
when {
callbackStatus == null -> {
connectionTracker.cancelNotificationAck(device.address, ack)
Log.w(TAG, "Timed out waiting for notification ack from ${device.address}")
false
}
callbackStatus != BluetoothGatt.GATT_SUCCESS -> {
Log.w(TAG, "Notification send failed for ${device.address} with callback status $callbackStatus")
false
}
else -> true
}
} catch (e: Exception) {
connectionTracker.cancelNotificationAck(device.address, ack, removeImmediately = true)
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.removeSubscribedDevice(device)
connectionTracker.addressPeerMap.remove(device.address)
}
false
return try {
characteristic?.let { char ->
char.value = data
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
connectionScope.launch {
delay(CLEANUP_DELAY)
connectionTracker.removeSubscribedDevice(device)
connectionTracker.addressPeerMap.remove(device.address)
}
false
}
}
@ -605,16 +449,9 @@ class BluetoothPacketBroadcaster(
): Boolean {
return try {
deviceConn.characteristic?.let { char ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
(deviceConn.gatt?.writeCharacteristic(char, data, char.writeType)
?: BluetoothStatusCodes.ERROR_UNKNOWN) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
char.value = data
deviceConn.gatt?.writeCharacteristic(char) ?: false
}
}
char.value = data
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
result
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
@ -633,7 +470,7 @@ class BluetoothPacketBroadcaster(
return buildString {
appendLine("=== Packet Broadcaster Debug Info ===")
appendLine("Broadcaster Scope Active: ${broadcasterScope.isActive}")
appendLine("Transfer Jobs Active: ${transferJobs.size}")
appendLine("Actor Channel Closed: ${broadcasterActor.isClosedForSend}")
appendLine("Connection Scope Active: ${connectionScope.isActive}")
}
}

View File

@ -51,101 +51,123 @@ class FragmentManager {
* Create fragments from a large packet - 100% iOS Compatible
* Matches iOS sendFragmentedPacket() implementation exactly
*/
fun createFragments(
packet: BitchatPacket,
maxPacketSize: Int = FRAGMENT_SIZE_THRESHOLD
): List<BitchatPacket> {
/** Generic/public packets retain the full UInt16 fragment-count range. */
fun createFragments(packet: BitchatPacket): List<BitchatPacket> =
createFragments(packet, 0xFFFF)
/**
* Create a fragment plan with a caller-selected bound. Private media uses
* 256 for cross-platform admission; generic/public traffic retains the
* UInt16 wire limit.
*/
fun createFragments(packet: BitchatPacket, maxFragments: Int): List<BitchatPacket> {
try {
if (maxFragments !in 1..0xFFFF) {
Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments")
return emptyList()
}
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
val encoded = packet.toBinaryData()
val encoded = packet.toBinaryData()
if (encoded == null) {
Log.e(TAG, "❌ Failed to encode packet to binary data")
return emptyList()
}
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = try {
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = try {
MessagePadding.unpad(encoded)
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
return emptyList()
}
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= maxPacketSize) {
return listOf(packet) // No fragmentation needed
}
val fragments = mutableListOf<BitchatPacket>()
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// Match the iOS BLE send path: fragment based on the current link budget.
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (maxPacketSize - packetOverhead)
.coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
return listOf(packet) // No fragmentation needed
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val fragments = mutableListOf<BitchatPacket>()
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
// iOS: for (index, fragment) in fragments.enumerated()
for (index in fragmentChunks.indices) {
val fragmentData = fragmentChunks[index]
// Create iOS-compatible fragment payload
val fragmentPayload = FragmentPayload(
fragmentID = fragmentID,
index = index,
total = fragmentChunks.size,
originalType = packet.type,
data = fragmentData
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
fragments.add(fragmentPacket)
}
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// 512 - Overhead
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val requiredFragments = (
(fullData.size.toLong() + maxDataSize.toLong() - 1L) / maxDataSize.toLong()
).toInt()
if (requiredFragments > maxFragments) {
Log.w(
TAG,
"Rejecting outbound packet requiring $requiredFragments fragments " +
"(caller cap: $maxFragments)"
)
return emptyList()
}
// Do not allocate chunk copies until the plan passes the hard bound.
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
// iOS: for (index, fragment) in fragments.enumerated()
for (index in fragmentChunks.indices) {
val fragmentData = fragmentChunks[index]
// Create iOS-compatible fragment payload
val fragmentPayload = FragmentPayload(
fragmentID = fragmentID,
index = index,
total = fragmentChunks.size,
originalType = packet.type,
data = fragmentData
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
fragments.add(fragmentPacket)
}
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
return fragments
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)

View File

@ -0,0 +1,158 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Shared transport send wrapper that applies bitchat packet fragmentation and
* transfer progress before a transport writes packets to its concrete medium.
*/
class FragmentingPacketSender(
private val scope: CoroutineScope,
private val fragmentManager: FragmentManager?,
private val logTag: String,
private val interFragmentDelayMs: Long = 20L
) {
private val transferJobs = ConcurrentHashMap<String, Job>()
fun send(
routed: RoutedPacket,
description: String,
sendSingle: (RoutedPacket) -> Boolean
): Boolean {
val transferId = transferIdFor(routed)
val packets = packetsForTransport(routed) ?: return false
val total = packets.size
if (total <= 1) {
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
val sent = sendSingle(
routed.copy(
packet = packets.first(),
transferId = transferId,
preparedPackets = null
)
)
if (sent && transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return sent
}
Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description")
if (transferId != null) {
TransferProgressManager.start(transferId, total)
}
val job = scope.launch(start = CoroutineStart.LAZY) {
var sent = 0
for (packet in packets) {
if (!isActive) return@launch
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
val fragment = routed.copy(
packet = packet,
transferId = transferId,
preparedPackets = null
)
val delivered = try {
sendSingle(fragment)
} catch (e: Exception) {
Log.e(logTag, "Fragment send failed for $description: ${e.message}", e)
false
}
if (!delivered) {
Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments")
return@launch
}
sent += 1
if (transferId != null) {
TransferProgressManager.progress(transferId, sent, total)
}
if (sent < total) {
delay(interFragmentDelayMs)
}
}
if (transferId != null) {
TransferProgressManager.complete(transferId, total)
}
}
if (transferId != null) {
transferJobs[transferId] = job
job.invokeOnCompletion { transferJobs.remove(transferId, job) }
}
job.start()
return true
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()
return true
}
private fun packetsForTransport(routed: RoutedPacket): List<BitchatPacket>? {
routed.preparedPackets?.let { prepared ->
if (prepared.isEmpty() ||
prepared.size > com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) {
Log.e(logTag, "Rejected invalid prepared fragment plan (${prepared.size} packets)")
return null
}
return prepared
}
val packet = routed.packet
if (packet.type == MessageType.FRAGMENT.value) {
return listOf(packet)
}
val manager = fragmentManager ?: return listOf(packet)
return try {
val fragments = manager.createFragments(packet)
if (fragments.isEmpty()) {
Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
null
} else {
fragments
}
} catch (e: Exception) {
Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e)
null
}
}
private fun transferIdFor(routed: RoutedPacket): String? {
routed.transferId?.let { return it }
val packet = routed.packet
return if (packet.type == MessageType.FILE_TRANSFER.value) {
sha256Hex(packet.payload)
} else {
null
}
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
bytes.size.toString(16)
}
}

View File

@ -0,0 +1,143 @@
package com.bitchat.android.mesh
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.)
* Encapsulates common state machine logic:
* - Connection attempt tracking (retries, backoff)
* - Pending connection management
* - Automatic cleanup of expired attempts
*/
abstract class MeshConnectionTracker(
private val scope: CoroutineScope,
protected val tag: String
) {
companion object {
const val CONNECTION_RETRY_DELAY = 5_000L
const val MAX_CONNECTION_ATTEMPTS = 3
const val CLEANUP_INTERVAL = 30_000L
}
/**
* Connection attempt tracking with automatic expiry
*/
protected data class ConnectionAttempt(
val attempts: Int,
val lastAttempt: Long = System.currentTimeMillis()
) {
fun isExpired(): Boolean =
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
fun shouldRetry(): Boolean =
attempts < MAX_CONNECTION_ATTEMPTS &&
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
}
// Tracks in-progress or failed attempts
protected val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
private var isActive = false
/**
* Start the tracker and its cleanup loop
*/
open fun start() {
isActive = true
startPeriodicCleanup()
}
/**
* Stop the tracker
*/
open fun stop() {
isActive = false
pendingConnections.clear()
}
/**
* Check if a connection attempt is allowed for this peer/address
*/
fun isConnectionAttemptAllowed(id: String): Boolean {
// If already connected, usually no need to retry (subclasses can override logic if needed,
// but typically the caller checks isConnected() first).
val existingAttempt = pendingConnections[id]
return existingAttempt?.let {
it.isExpired() || it.shouldRetry()
} ?: true
}
/**
* Record a new connection attempt.
* Returns true if the attempt was recorded (allowed), false if skipped.
*/
fun addPendingConnection(id: String): Boolean {
synchronized(pendingConnections) {
val currentAttempt = pendingConnections[id]
// If strictly not allowed right now, reject
if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
Log.d(tag, "Connection attempt already in progress for $id")
return false
}
// Update attempt count
// Reset to 1 if expired, otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[id] = ConnectionAttempt(attempts)
Log.d(tag, "Added pending connection for $id (attempts: $attempts)")
return true
}
}
/**
* Remove a pending attempt (e.g., on success or fatal error)
*/
fun removePendingConnection(id: String) {
pendingConnections.remove(id)
}
/**
* Abstract: Subclasses must define what "connected" means
*/
abstract fun isConnected(id: String): Boolean
/**
* Abstract: Subclasses must implement disconnect logic
*/
abstract fun disconnect(id: String)
/**
* Abstract: Subclasses report their active connection count
*/
abstract fun getConnectionCount(): Int
private fun startPeriodicCleanup() {
scope.launch {
while (isActive) {
try {
delay(CLEANUP_INTERVAL)
if (!isActive) break
// Clean up expired pending connections
val expired = pendingConnections.filter { it.value.isExpired() }
expired.keys.forEach { pendingConnections.remove(it) }
if (expired.isNotEmpty()) {
Log.d(tag, "Cleaned up ${expired.size} expired connection attempts")
}
} catch (e: CancellationException) {
break
} catch (e: Exception) {
Log.w(tag, "Error in periodic cleanup: ${e.message}")
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatMessage
/**
* Shared mesh delegate interface for transport-agnostic callbacks.
*/
interface MeshDelegate {
fun didReceiveMessage(message: BitchatMessage)
fun didUpdatePeerList(peers: List<String>)
fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
}

View File

@ -0,0 +1,37 @@
package com.bitchat.android.mesh
/**
* Shared helpers for mesh packet handling.
*/
object MeshPacketUtils {
/**
* Convert hex string peer ID to binary data (8 bytes), matching iOS behavior.
*/
fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 }
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
/**
* Hash payloads to a stable hex ID for transfer tracking.
*/
fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
bytes.size.toString(16)
}
}

View File

@ -0,0 +1,67 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
/**
* Transport-agnostic mesh service API for UI and routing layers.
*/
interface MeshService {
val myPeerID: String
var delegate: MeshDelegate?
fun startServices()
fun stopServices()
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendNdrEvent(peerID: String, payload: String): Boolean
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation
fun cancelFileTransfer(transferId: String): Boolean
fun sendBroadcastAnnounce()
fun sendAnnouncementToPeer(peerID: String)
fun getPeerNicknames(): Map<String, String>
fun getPeerRSSI(): Map<String, Int>
fun getActivePeerCount(): Int
fun hasEstablishedSession(peerID: String): Boolean
fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState
fun initiateNoiseHandshake(peerID: String)
fun getPeerFingerprint(peerID: String): String?
fun getPeerInfo(peerID: String): PeerInfo?
fun peerSupportsAuthenticatedCapability(
peerID: String,
capability: com.bitchat.android.model.PeerCapabilities
): Boolean
fun updatePeerInfo(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean
): Boolean
fun getIdentityFingerprint(): String
fun getStaticNoisePublicKey(): ByteArray?
fun shouldShowEncryptionIcon(peerID: String): Boolean
fun getEncryptedPeers(): List<String>
fun getDeviceAddressForPeer(peerID: String): String?
fun getDeviceAddressToPeerMapping(): Map<String, String>
fun printDeviceAddressesForPeers(): String
fun getDebugStatus(): String
fun clearAllInternalData()
fun clearAllEncryptionData()
}

View File

@ -0,0 +1,33 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
/**
* Transport abstraction used by MeshCore to send packets via a specific medium.
*/
interface MeshTransport {
val id: String
fun broadcastPacket(routed: RoutedPacket)
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
/**
* Send through an exact transport generation rather than a reusable peer alias.
* Transports that cannot prove the link identity must decline the operation.
*/
fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean = false
fun cancelTransfer(transferId: String): Boolean = false
fun getDeviceAddressForPeer(peerID: String): String? = null
fun getDeviceAddressToPeerMapping(): Map<String, String> = emptyMap()
fun getTransportDebugInfo(): String = ""
}

View File

@ -1,16 +1,23 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.NdrFeatureGate
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
import kotlin.random.Random
sealed class AnnounceHandlingResult {
data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult()
object Rejected : AnnounceHandlingResult()
}
/**
* Handles processing of different message types
@ -20,7 +27,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
companion object {
private const val TAG = "MessageHandler"
private const val MAX_PENDING_NOISE_ENCRYPTED_PER_PEER = 16
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
}
// Delegate for callbacks
@ -31,8 +38,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Coroutines
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val pendingNoiseEncryptedLock = Any()
private val pendingNoiseEncrypted = mutableMapOf<String, java.util.ArrayDeque<RoutedPacket>>()
/**
* Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
@ -56,21 +61,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
try {
// Decrypt the message using the Noise service
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData == null) {
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryption == null) {
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
if (delegate?.hasNoiseSession(peerID) != true) {
queuePendingNoiseEncrypted(peerID, routed)
}
return
}
val decryptedData = decryption.plaintext
if (decryptedData.isEmpty()) {
Log.w(TAG, "Decrypted data is empty from $peerID")
return
}
// NEW: Use NoisePayload system exactly like iOS
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
if (noisePayload == null) {
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
@ -88,7 +90,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Handle favorite/unfavorite notifications embedded as PMs
val pmContent = privateMessage.content
if (pmContent.startsWith("[FAVORITED]") || pmContent.startsWith("[UNFAVORITED]")) {
if (FavoriteControlMessage.parse(pmContent) != null) {
handleFavoriteNotificationFromMesh(pmContent, peerID)
// Acknowledge delivery for UX parity
sendDeliveryAck(privateMessage.messageID, peerID)
@ -106,7 +108,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID,
mentions = null // TODO: Parse mentions if needed
mentions = null
)
// Notify delegate
@ -145,6 +147,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
}
}
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
if (authenticatedState == null) {
Log.w(TAG, "Dropping malformed authenticated peer state from ${peerID.take(8)}")
} else {
delegate?.onAuthenticatedPeerStateReceived(
peerID,
authenticatedState,
decryption.authenticatedSession
)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS
@ -172,8 +187,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.NDR_EVENT -> {
Log.d(TAG, "🔐 NDR OOB event received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onNdrEventReceived(peerID, noisePayload.data, packet.timestamp.toLong())
if (NdrFeatureGate.isEnabled()) {
delegate?.onNdrEventReceived(
peerID,
noisePayload.data,
packet.timestamp.toLong(),
decryption.authenticatedSession
)
}
}
}
@ -181,27 +202,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
}
}
private fun queuePendingNoiseEncrypted(peerID: String, routed: RoutedPacket) {
synchronized(pendingNoiseEncryptedLock) {
val queue = pendingNoiseEncrypted.getOrPut(peerID) { java.util.ArrayDeque() }
if (queue.size >= MAX_PENDING_NOISE_ENCRYPTED_PER_PEER) {
queue.removeFirst()
}
queue.addLast(routed)
Log.d(TAG, "🕒 Queued encrypted Noise packet from $peerID until handshake completes (pending=${queue.size})")
}
}
suspend fun flushPendingNoiseEncrypted(peerID: String) {
val queued = synchronized(pendingNoiseEncryptedLock) {
pendingNoiseEncrypted.remove(peerID)?.toList().orEmpty()
}
if (queued.isEmpty()) return
Log.d(TAG, "🔓 Replaying ${queued.size} queued encrypted Noise packet(s) from $peerID after handshake")
queued.forEach { handleNoiseEncrypted(it) }
}
/**
* Send delivery ACK for a received private message - exactly like iOS
@ -245,36 +245,44 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
* Handle announce message with TLV decoding and signature verification - exactly like iOS
*/
suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false
}
suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
if (peerID == myPeerID) return AnnounceHandlingResult.Rejected
// Ignore stale announcements older than STALE_PEER_TIMEOUT
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
// during identity learning, or later signed messages cannot be verified.
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
return false
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return AnnounceHandlingResult.Rejected
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
val announcement = IdentityAnnouncement.decode(packet.payload)
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
delegate?.verifyEd25519Signature(signature, data, key) ?: false
}
if (announcement == null) {
Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format")
return false
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}")
return AnnounceHandlingResult.Rejected
}
// Verify packet signature using the announced signing public key
var verified = false
if (packet.signature != null) {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = delegate?.verifyEd25519Signature(packet.signature!!, packet.toBinaryDataForSigning()!!, announcement.signingPublicKey) ?: false
if (!verified) {
Log.w(TAG, "⚠️ Signature verification for announce failed ${peerID.take(8)}")
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state")
return AnnounceHandlingResult.Rejected
}
var verified = true
// Check for existing peer with different noise public key
// If existing peer has a different noise public key, do not consider this verified
val existingPeer = delegate?.getPeerInfo(peerID)
@ -284,10 +292,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
verified = false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(
TAG,
"Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof"
)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility)
if (!verified) {
Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...")
return false
return AnnounceHandlingResult.Rejected
}
// Successfully decoded TLV format exactly like iOS
@ -301,22 +320,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val signingPublicKey = announcement.signingPublicKey
// Update peer info with verification status through new method
val isFirstAnnounce = delegate?.updatePeerInfo(
val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = true
isVerified = true,
capabilities = announcement.capabilities
) ?: false
// Update peer ID binding with noise public key for identity management
delegate?.updatePeerIDBinding(
newPeerID = peerID,
nickname = nickname,
publicKey = noisePublicKey,
previousPeerID = null
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
@ -325,7 +337,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
} catch (_: Exception) { }
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
return isFirstAnnounce
return AnnounceHandlingResult.Accepted(isFirstAnnounce)
}
/**
@ -431,7 +443,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
@ -447,6 +459,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Fallback: plain text
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
@ -463,14 +476,26 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
*/
private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) {
try {
// Verify signature if present
if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) {
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) ==
com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val signatureIsValid = packet.signature != null &&
delegate?.verifySignature(packet, peerID) == true
// Migration fallback is visible to relays, so sender authenticity
// is mandatory. Never accept an unsigned directed raw file.
if (isFileTransfer && !signatureIsValid) {
Log.w(TAG, "Unsigned or invalid signed private file from $peerID")
return
}
// Preserve prior behavior for other directed packet types: verify
// a signature whenever one is present.
if (!isFileTransfer && packet.signature != null && !signatureIsValid) {
Log.w(TAG, "Invalid signature for private message from $peerID")
return
}
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
@ -574,24 +599,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
*/
private fun handleFavoriteNotificationFromMesh(content: String, fromPeerID: String) {
try {
val isFavorite = content.startsWith("[FAVORITED]")
val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") }
val control = FavoriteControlMessage.parse(content) ?: return
// Update mutual favorite status in persistence
// Resolve full Noise key if available via delegate peer info
val peerInfo = delegate?.getPeerInfo(fromPeerID)
val noiseKey = peerInfo?.noisePublicKey
if (noiseKey != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
if (npub != null) {
// Index by noise key and current mesh peerID for fast Nostr routing
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
if (control.npub != null) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, control.npub)
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, control.npub)
}
// Determine iOS-style guidance text
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val guidance = if (isFavorite) {
val guidance = if (control.isFavorite) {
if (rel?.isFavorite == true) {
" — mutual! You can continue DMs via Nostr when out of mesh."
} else {
@ -601,8 +621,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
". DMs over Nostr will pause unless you both favorite again."
}
// Emit system message via delegate callback
val action = if (isFavorite) "favorited" else "unfavorited"
val action = if (control.isFavorite) "favorited" else "unfavorited"
val sys = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "${peerInfo.nickname} $action you$guidance",
@ -629,7 +648,14 @@ interface MessageHandlerDelegate {
fun getNetworkSize(): Int
fun getMyNickname(): String?
fun getPeerInfo(peerID: String): PeerInfo?
fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean
fun updatePeerInfoFromVerifiedAnnouncement(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities? = null
): Boolean
// Packet operations
fun sendPacket(packet: BitchatPacket)
@ -639,15 +665,22 @@ interface MessageHandlerDelegate {
// Cryptographic operations
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray?
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray?
fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult?
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
// Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?)
fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {}
// Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
@ -659,5 +692,10 @@ interface MessageHandlerDelegate {
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onNdrEventReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onNdrEventReceived(
peerID: String,
payload: ByteArray,
timestampMs: Long,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
)
}

View File

@ -143,7 +143,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle public packet types (no address check needed)
when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed)
@ -153,7 +153,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
@ -179,10 +179,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle Noise handshake message - SIMPLIFIED iOS-compatible version
*/
private suspend fun handleNoiseHandshake(routed: RoutedPacket) {
private suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
delegate?.handleNoiseHandshake(routed)
return delegate?.handleNoiseHandshake(routed) ?: false
}
/**
@ -197,10 +197,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle announce message
*/
private suspend fun handleAnnounce(routed: RoutedPacket) {
private suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}")
delegate?.handleAnnounce(routed)
return delegate?.handleAnnounce(routed) ?: false
}
/**
@ -231,7 +231,14 @@ class PacketProcessor(private val myPeerID: String) {
val reassembledPacket = delegate?.handleFragment(routed.packet)
if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message")
handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress))
handleReceivedPacket(
RoutedPacket(
packet = reassembledPacket,
peerID = routed.peerID,
relayAddress = routed.relayAddress,
ingressLinkID = routed.ingressLinkID
)
)
}
// Fragment relay is now handled by centralized PacketRelayManager
@ -314,7 +321,7 @@ interface PacketProcessorDelegate {
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket)
fun handleAnnounce(routed: RoutedPacket)
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?

View File

@ -1,6 +1,8 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@ -17,7 +19,11 @@ data class PeerInfo(
var noisePublicKey: ByteArray?,
var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification
var isVerifiedNickname: Boolean, // NEW: Verification status flag
var lastSeen: Long // Using Long instead of Date for simplicity
var lastSeen: Long, // Using Long instead of Date for simplicity
var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05
var hasVerifiedAnnouncement: Boolean = false,
/** Noise key that the preserved capability state was actually signed alongside. */
var verifiedAnnouncementNoisePublicKey: ByteArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -39,6 +45,14 @@ data class PeerInfo(
} else if (other.signingPublicKey != null) return false
if (isVerifiedNickname != other.isVerifiedNickname) return false
if (lastSeen != other.lastSeen) return false
if (capabilities != other.capabilities) return false
if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false
val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey
val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey
if (thisVerifiedAnnouncementKey != null) {
if (otherVerifiedAnnouncementKey == null) return false
if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false
} else if (otherVerifiedAnnouncementKey != null) return false
return true
}
@ -52,6 +66,9 @@ data class PeerInfo(
result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0)
result = 31 * result + isVerifiedNickname.hashCode()
result = 31 * result + lastSeen.hashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + hasVerifiedAnnouncement.hashCode()
result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0)
return result
}
}
@ -108,12 +125,104 @@ class PeerManager {
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean
): Boolean {
val existing = peers[peerID]
return updatePeerInfoInternal(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = isVerified,
capabilities = existing?.capabilities,
hasVerifiedAnnouncement = existing?.hasVerifiedAnnouncement == true,
verifiedAnnouncementNoisePublicKey = existing?.verifiedAnnouncementNoisePublicKey
)
}
/**
* Apply the exact capability state from a signature-verified announce.
* A null value is meaningful: the peer signed an old-format announce that
* omitted TLV 0x05. Normal peer refreshes use [updatePeerInfo] and retain
* the last signed capability state instead of accidentally erasing it.
*/
fun updatePeerInfoFromVerifiedAnnouncement(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: PeerCapabilities?
): Boolean = updatePeerInfoInternal(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = isVerified,
capabilities = capabilities,
hasVerifiedAnnouncement = true,
verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf()
)
/** Replace capability/Ed identity from Noise 0x21 in one peer-map mutation. */
@Synchronized
fun applyAuthenticatedPeerState(
peerID: String,
authenticatedNoisePublicKey: ByteArray,
state: AuthenticatedPeerState
) {
val existing = peers[peerID]
val announcementMatchesAuthenticatedState = existing?.hasVerifiedAnnouncement == true &&
existing.verifiedAnnouncementNoisePublicKey?.contentEquals(authenticatedNoisePublicKey) == true &&
existing.signingPublicKey?.contentEquals(state.signingPublicKey) == true
val replacement = PeerInfo(
id = peerID,
// A copied-static preannouncement cannot retain its attacker-chosen display name once
// authenticated peer state proves a different Ed key.
nickname = existing?.nickname?.takeIf { announcementMatchesAuthenticatedState } ?: peerID,
isConnected = true,
isDirectConnection = existing?.isDirectConnection ?: false,
noisePublicKey = authenticatedNoisePublicKey.copyOf(),
signingPublicKey = state.signingPublicKey.copyOf(),
isVerifiedNickname = existing?.isVerifiedNickname == true && announcementMatchesAuthenticatedState,
lastSeen = System.currentTimeMillis(),
capabilities = state.capabilities,
hasVerifiedAnnouncement = announcementMatchesAuthenticatedState,
verifiedAnnouncementNoisePublicKey = authenticatedNoisePublicKey.copyOf()
.takeIf { announcementMatchesAuthenticatedState }
)
peers[peerID] = replacement
if (existing == null || existing != replacement) notifyPeerListUpdate()
}
private fun updatePeerInfoInternal(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: PeerCapabilities?,
hasVerifiedAnnouncement: Boolean,
verifiedAnnouncementNoisePublicKey: ByteArray?
): Boolean {
if (peerID == "unknown") return false
fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean {
if (a == null && b == null) return true
if (a == null || b == null) return false
return a.contentEquals(b)
}
val now = System.currentTimeMillis()
val existingPeer = peers[peerID]
val isNewPeer = existingPeer == null
val wasVerified = existingPeer?.isVerifiedNickname == true
val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname
val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
val connectedChanged = existingPeer != null && existingPeer.isConnected != true
val capabilitiesChanged = existingPeer != null && existingPeer.capabilities != capabilities
val announcementStateChanged = existingPeer != null &&
existingPeer.hasVerifiedAnnouncement != hasVerifiedAnnouncement
// Update or create peer info
val peerInfo = PeerInfo(
@ -124,7 +233,10 @@ class PeerManager {
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerifiedNickname = isVerified,
lastSeen = now
lastSeen = now,
capabilities = capabilities,
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
verifiedAnnouncementNoisePublicKey = verifiedAnnouncementNoisePublicKey?.copyOf()
)
peers[peerID] = peerInfo
@ -133,18 +245,28 @@ class PeerManager {
// No legacy maps; peers map is the single source of truth
// Maintain announcedPeers for first-time announce semantics
val shouldNotify = when {
isNewPeer && isVerified -> true
wasVerified != isVerified -> true
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged ||
capabilitiesChanged || announcementStateChanged -> true
else -> false
}
if (isNewPeer && isVerified) {
announcedPeers.add(peerID)
notifyPeerListUpdate()
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
return true
} else if (isVerified) {
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
} else {
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
}
if (shouldNotify) {
notifyPeerListUpdate()
}
return false
return isNewPeer && isVerified
}
/**
@ -179,14 +301,6 @@ class PeerManager {
}
}
/**
* Force a peer list update notification.
* Call this when connection state changes to refresh UI badges.
*/
fun refreshPeerList() {
notifyPeerListUpdate()
}
// MARK: - Legacy Methods (maintained for compatibility)
/**
@ -414,6 +528,10 @@ class PeerManager {
val peerList = getActivePeerIDs()
delegate?.onPeerListUpdated(peerList)
}
fun refreshPeerList() {
notifyPeerListUpdate()
}
/**
* Start periodic cleanup of stale peers

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
internal sealed interface PrivateMediaPolicyDecision {
data class Encrypted(
val authenticatedSession: AuthenticatedNoiseSession
) : PrivateMediaPolicyDecision
data object RequiresLegacyConsent : PrivateMediaPolicyDecision
data object NeedsHandshake : PrivateMediaPolicyDecision
data object AwaitingPeerState : PrivateMediaPolicyDecision
data class Blocked(val reason: String) : PrivateMediaPolicyDecision
}
/**
* Binds an advertised private-media capability to a live Noise remote-static
* key and persists an HSTS-style pin by that authenticated key's SHA-256
* fingerprint. Announcements alone can never create a pin.
*/
internal class PrivateMediaSecurityController(
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val peerStateStatusProvider: (
String,
AuthenticatedNoiseSession
) -> AuthenticatedPeerStateStatus,
private val isPrivateMediaPinned: (String) -> Boolean
) {
fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
val authenticatedSession = authenticatedSessionProvider(peerID)
?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 }
?: return PrivateMediaPolicyDecision.NeedsHandshake
return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) {
AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState
is AuthenticatedPeerStateStatus.Proven -> {
if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
} else if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
)
} else {
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
AuthenticatedPeerStateStatus.Missing ->
// A live crypto session can become visible just before its authenticated callback
// installs the coordinator generation. Never treat that gap as a watchdog timeout.
PrivateMediaPolicyDecision.AwaitingPeerState
AuthenticatedPeerStateStatus.TimedOut -> {
if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session did not provide authenticated peer state"
)
} else {
// A Noise-capable old client that does not know 0x21 may use explicit one-shot
// legacy consent after the five-second generation watchdog.
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
}
}
}

View File

@ -0,0 +1,215 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import java.util.concurrent.atomic.AtomicBoolean
enum class PrivateMediaWireMode {
ENCRYPTED_NOISE_0X20,
SIGNED_DIRECTED_RAW_0X22
}
class PreparedPrivateMediaTransfer internal constructor(
val transferId: String,
val wireMode: PrivateMediaWireMode,
private val commitAction: () -> Boolean
) {
private val committed = AtomicBoolean(false)
/** A prepared transfer is single-use, including after a failed commit. */
fun commit(): Boolean {
if (!committed.compareAndSet(false, true)) return false
return commitAction()
}
}
sealed interface PrivateMediaPreparation {
data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation
data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation
data object NeedsHandshake : PrivateMediaPreparation
data object AwaitingPeerState : PrivateMediaPreparation
data class Rejected(val reason: String) : PrivateMediaPreparation
}
internal data class BuiltPrivateMediaTransfer(
val packet: BitchatPacket,
val fragments: List<BitchatPacket>,
val wireMode: PrivateMediaWireMode
)
internal sealed interface PrivateMediaBuildOutcome {
data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome
data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome
data object NeedsHandshake : PrivateMediaBuildOutcome
data object AwaitingPeerState : PrivateMediaBuildOutcome
data class Rejected(val reason: String) : PrivateMediaBuildOutcome
}
internal sealed interface PrivateMediaEncryptionResult {
data class Success(val ciphertext: ByteArray) : PrivateMediaEncryptionResult
data object GenerationChanged : PrivateMediaEncryptionResult
data object Failed : PrivateMediaEncryptionResult
}
/** Builds, routes, signs, and fragments exactly once before UI local echo. */
internal class PrivateMediaTransferPreparer(
private val senderID: ByteArray,
private val ttl: UByte,
private val policyProvider: (String) -> PrivateMediaPolicyDecision,
private val encrypt: (
ByteArray,
String,
AuthenticatedNoiseSession
) -> PrivateMediaEncryptionResult,
private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?,
private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>,
private val now: () -> ULong = { System.currentTimeMillis().toULong() }
) {
fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean
): PrivateMediaBuildOutcome = prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining = 1
)
private fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean,
generationRetriesRemaining: Int
): PrivateMediaBuildOutcome {
val maxPrivateFragments =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
val absolutePayloadUpperBound =
maxPrivateFragments.toLong() *
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE.toLong()
// The file content alone cannot exceed the total bytes carried by every
// possible fragment. Reject before TLV encoding, encryption, signing,
// and packet serialization make additional full-size copies.
if (file.content.size.toLong() > absolutePayloadUpperBound) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
val policy = policyProvider(recipientPeerID)
val mode = when (policy) {
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
if (!allowLegacyFallback) {
return PrivateMediaBuildOutcome.RequiresLegacyConsent(
"This older client cannot receive encrypted private media. " +
"Sending this one file will expose its contents to mesh relays, " +
"although the directed packet will still be signed."
)
}
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22
}
PrivateMediaPolicyDecision.NeedsHandshake ->
return PrivateMediaBuildOutcome.NeedsHandshake
PrivateMediaPolicyDecision.AwaitingPeerState ->
return PrivateMediaBuildOutcome.AwaitingPeerState
is PrivateMediaPolicyDecision.Blocked ->
return PrivateMediaBuildOutcome.Rejected(policy.reason)
}
val filePayload = file.encode()
?: return PrivateMediaBuildOutcome.Rejected("Failed to encode private media")
val packet = when (mode) {
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
val encryption = try {
encrypt(
plaintext,
recipientPeerID,
(policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession
)
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
val ciphertext = when (encryption) {
is PrivateMediaEncryptionResult.Success -> encryption.ciphertext
PrivateMediaEncryptionResult.GenerationChanged -> {
if (generationRetriesRemaining > 0) {
return prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining - 1
)
}
return PrivateMediaBuildOutcome.AwaitingPeerState
}
PrivateMediaEncryptionResult.Failed ->
return PrivateMediaBuildOutcome.Rejected(
"The authenticated Noise session could not encrypt this file"
)
}
BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = senderID.copyOf(),
recipientID = recipientID.copyOf(),
timestamp = now(),
payload = ciphertext,
signature = null,
ttl = ttl
)
}
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 -> BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = senderID.copyOf(),
recipientID = recipientID.copyOf(),
timestamp = now(),
payload = filePayload,
signature = null,
ttl = ttl
)
}
val finalized = finalizeRoutedAndSigned(packet)
?: return PrivateMediaBuildOutcome.Rejected(
if (mode == PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22) {
"Could not sign the legacy private-media packet; nothing was sent"
} else {
"Could not sign the encrypted private-media packet; nothing was sent"
}
)
if (finalized.signature?.size != 64) {
return PrivateMediaBuildOutcome.Rejected(
"Could not produce a valid Ed25519 private-media signature; nothing was sent"
)
}
val fragments = fragment(finalized, maxPrivateFragments)
if (fragments.isEmpty()) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
if (fragments.size > maxPrivateFragments) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
return PrivateMediaBuildOutcome.Ready(
BuiltPrivateMediaTransfer(finalized, fragments, mode)
)
}
}

View File

@ -5,6 +5,8 @@ import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
@ -54,6 +56,22 @@ class SecurityManager(private val encryptionService: EncryptionService, private
val currentTime = System.currentTimeMillis()
val messageType = MessageType.fromValue(packet.type)
// LEAVE mutates presence immediately and cannot be safely replayed after the in-memory
// duplicate cache expires (or after an app restart). Bound it to the same five-minute
// security window used for duplicate retention while tolerating symmetric clock skew.
if (messageType == MessageType.LEAVE) {
val now = currentTime.coerceAtLeast(0).toULong()
val clockSkew = if (packet.timestamp >= now) {
packet.timestamp - now
} else {
now - packet.timestamp
}
if (clockSkew > MESSAGE_TIMEOUT.toULong()) {
Log.w(TAG, "Dropping stale or future-dated LEAVE from $peerID")
return false
}
}
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
@ -71,15 +89,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
return false
}
// Record only authenticated packets. Recording an attacker-controlled
// invalid packet first would let it poison duplicate detection for a
// later legitimate packet with the same timestamp and payload.
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
@ -102,19 +122,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Skip our own handshake messages
if (peerID == myPeerID) return false
// If we already have an established session but the peer is initiating a new handshake,
// drop the existing session so we can re-establish cleanly.
var forcedRehandshake = false
if (encryptionService.hasEstablishedSession(peerID)) {
Log.d(TAG, "Received new Noise handshake from $peerID with an existing session. Dropping old session to re-handshake.")
try {
encryptionService.removePeer(peerID)
forcedRehandshake = true
} catch (e: Exception) {
Log.w(TAG, "Failed to remove existing Noise session for $peerID: ${e.message}")
}
}
if (packet.payload.isEmpty()) {
Log.w(TAG, "Noise handshake packet has empty payload")
return false
@ -123,26 +130,46 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Prevent duplicate handshake processing
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
if (!forcedRehandshake && processedKeyExchanges.contains(exchangeKey)) {
if (processedKeyExchanges.contains(exchangeKey)) {
Log.d(TAG, "Already processed handshake: $exchangeKey")
return false
}
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
processedKeyExchanges.add(exchangeKey)
try {
// Process the Noise handshake through the updated EncryptionService
val response = encryptionService.processHandshakeMessage(packet.payload, peerID)
// The session manager preserves an existing transport in a separate responder-candidate
// flow and reports whether this exact frame completed authentication. Never infer that
// from ambient session state: a rejected replacement may leave the old session active.
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
processedKeyExchanges.add(exchangeKey)
if (response != null) {
if (result.response != null) {
Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
// Send handshake response through delegate
delegate?.sendHandshakeResponse(peerID, response)
delegate?.sendHandshakeResponse(peerID, result.response)
}
// Check if session is now established (handshake complete)
if (encryptionService.hasEstablishedSession(peerID)) {
if (result.establishedNow) {
val authenticatedRemoteStaticKey = result.authenticatedRemoteStaticKey
if (authenticatedRemoteStaticKey == null) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key")
return false
}
val authenticatedSessionToken = result.authenticatedSessionToken
if (authenticatedSessionToken?.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its generation token")
return false
}
val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
Log.d(TAG, "✅ Noise handshake completed with $peerID")
delegate?.onKeyExchangeCompleted(peerID, packet.payload)
delegate?.onKeyExchangeCompleted(
peerID = peerID,
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
authenticatedSessionToken = authenticatedSessionToken,
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
)
}
return true
@ -154,21 +181,13 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
/**
* Verify packet signature
* Verify a packet signature against the signing key learned from the
* peer's verified announcement. Signatures cover the canonical packet,
* not only its payload; otherwise routing and recipient fields could be
* changed without invalidating the signature.
*/
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return packet.signature?.let { signature ->
try {
val isValid = encryptionService.verify(signature, packet.payload, peerID)
if (!isValid) {
Log.w(TAG, "Invalid signature for packet from $peerID")
}
isValid
} catch (e: Exception) {
Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}")
false
}
} ?: true // No signature means verification passes
return verifyPacketSignature(packet, peerID)
}
/**
@ -194,13 +213,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private
null
}
}
fun encryptForPeer(
data: ByteArray,
recipientPeerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray? = try {
encryptionService.encryptForSession(data, recipientPeerID, expectedSession)
} catch (e: Exception) {
Log.e(TAG, "Noise generation changed before encrypting for $recipientPeerID: ${e.message}")
null
}
/**
* Decrypt payload from specific peer
*/
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? {
return try {
encryptionService.decrypt(encryptedData, senderPeerID)
encryptionService.decryptWithSession(encryptedData, senderPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}")
null
@ -237,14 +267,52 @@ class SecurityManager(private val encryptionService: EncryptionService, private
*/
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
// Public packets that mutate identity, presence, or user-visible state must prove the
// signing key learned from a verified announcement. LEAVE is included so an attacker
// cannot evict a claimed peer or amplify a forged departure through relay.
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
MessageType.FILE_TRANSFER,
MessageType.LEAVE
)) {
return true
}
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
encryptionService.verifyEd25519Signature(signature, data, key)
} ?: run {
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from $peerID")
return false
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state for $peerID")
return false
}
val existingPeer = delegate?.getPeerInfo(peerID)
if (
existingPeer?.noisePublicKey != null &&
!existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Noise-key replacement for $peerID")
return false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE signing-key replacement without authenticated peer state for $peerID")
return false
}
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
@ -252,21 +320,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
if (signingPublicKey == null) {
// If we don't have a key (and it's not an announce), we can't verify.
@ -416,7 +471,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
* Delegate interface for security manager callbacks
*/
interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray)
fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
)
fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
}

View File

@ -0,0 +1,473 @@
package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.NdrFeatureGate
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.wifiaware.WifiAwareController
/**
* Feature-facing mesh service that hides local transport selection from the rest of the app.
*
* BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh
* behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever
* local transport already has the peer/session, falling back to a connected transport handshake.
*/
class UnifiedMeshService(
private val context: Context,
private val bluetooth: BluetoothMeshService
) : MeshService, BluetoothMeshDelegate {
companion object {
private const val TAG = "UnifiedMeshService"
}
override val myPeerID: String
get() = bluetooth.myPeerID
override var delegate: MeshDelegate? = null
set(value) {
field = value
refreshDelegates()
}
fun refreshDelegates() {
try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { }
try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { }
}
override fun startServices() {
if (isBleEnabled()) {
try { bluetooth.startServices() } catch (e: Exception) {
Log.w(TAG, "Failed to start BLE transport: ${e.message}")
}
} else {
try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { }
}
try { WifiAwareController.startIfPossible() } catch (e: Exception) {
Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
}
refreshDelegates()
}
override fun stopServices() {
try { bluetooth.stopServices() } catch (_: Exception) { }
try { WifiAwareController.stop() } catch (_: Exception) { }
}
override fun sendMessage(content: String, mentions: List<String>, channel: String?) {
when {
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
else -> wifiService()?.sendMessage(content, mentions, channel)
}
}
override fun sendPrivateMessage(
content: String,
recipientPeerID: String,
recipientNickname: String,
messageID: String?
) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
}
}
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname)
}
}
override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {
val myNpub = try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
} catch (_: Exception) {
null
}
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = getPeerNicknames()[peerID] ?: peerID
if (hasEstablishedSession(peerID)) {
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
}
}
override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
when {
isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
}
}
override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
when {
isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
}
}
override fun sendNdrEvent(peerID: String, payload: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
val capability = com.bitchat.android.model.PeerCapabilities.NOSTR_DOUBLE_RATCHET
return when {
bleSupportsAuthenticatedCapability(peerID, capability) ->
bluetooth.sendNdrEvent(peerID, payload)
wifiSupportsAuthenticatedCapability(peerID, capability) ->
wifiService()?.sendNdrEvent(peerID, payload) == true
else -> false
}
}
override fun sendFileBroadcast(file: BitchatFilePacket) {
when {
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
else -> wifiService()?.sendFileBroadcast(file)
}
}
override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file)
isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file)
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendFilePrivate(recipientPeerID, file)
else -> wifiService()?.sendFilePrivate(recipientPeerID, file)
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation {
return when {
isBleReady(recipientPeerID) -> bluetooth.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
)
isWifiReady(recipientPeerID) -> wifiService()?.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
) ?: PrivateMediaPreparation.Rejected("Wi-Fi Aware transport is unavailable")
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
)
else -> wifiService()?.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
) ?: PrivateMediaPreparation.Rejected("No local transport is available for this peer")
}
}
override fun cancelFileTransfer(transferId: String): Boolean {
val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
return bleCancelled || wifiCancelled
}
override fun sendBroadcastAnnounce() {
if (isBleEnabled()) {
try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { }
}
try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { }
}
override fun sendAnnouncementToPeer(peerID: String) {
when {
isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID)
else -> wifiService()?.sendAnnouncementToPeer(peerID)
}
}
override fun getPeerNicknames(): Map<String, String> {
val merged = linkedMapOf<String, String>()
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
return merged
}
override fun getPeerRSSI(): Map<String, Int> {
val merged = linkedMapOf<String, Int>()
try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { }
try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { }
return merged
}
override fun getActivePeerCount(): Int {
return mergedPeerIDs().filter { it != myPeerID }.distinct().size
}
override fun hasEstablishedSession(peerID: String): Boolean {
return isBleReady(peerID) || isWifiReady(peerID)
}
override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState {
val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null }
return when {
bleState is NoiseSession.NoiseSessionState.Established -> bleState
wifiState is NoiseSession.NoiseSessionState.Established -> wifiState
bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState
wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState
bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState
wifiState != null -> wifiState
else -> bleState
}
}
override fun initiateNoiseHandshake(peerID: String) {
when {
isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID)
isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID)
isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID)
else -> wifiService()?.initiateNoiseHandshake(peerID)
}
}
override fun getPeerFingerprint(peerID: String): String? {
return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null }
?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null }
}
override fun getPeerInfo(peerID: String): PeerInfo? {
val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null }
val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null }
return when {
ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble
wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi
ble?.isConnected == true -> ble
wifi?.isConnected == true -> wifi
else -> ble ?: wifi
}
}
override fun peerSupportsAuthenticatedCapability(
peerID: String,
capability: com.bitchat.android.model.PeerCapabilities
): Boolean =
bleSupportsAuthenticatedCapability(peerID, capability) ||
wifiSupportsAuthenticatedCapability(peerID, capability)
private fun bleSupportsAuthenticatedCapability(
peerID: String,
capability: com.bitchat.android.model.PeerCapabilities
): Boolean {
return try {
isBleReady(peerID) && bluetooth.peerSupportsAuthenticatedCapability(peerID, capability)
} catch (_: Exception) {
false
}
}
private fun wifiSupportsAuthenticatedCapability(
peerID: String,
capability: com.bitchat.android.model.PeerCapabilities
): Boolean {
return try {
isWifiReady(peerID) &&
wifiService()?.peerSupportsAuthenticatedCapability(peerID, capability) == true
} catch (_: Exception) {
false
}
}
override fun updatePeerInfo(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean
): Boolean {
val bleUpdated = try {
bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
} catch (_: Exception) {
false
}
val wifiUpdated = try {
wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true
} catch (_: Exception) {
false
}
return bleUpdated || wifiUpdated
}
override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint()
override fun getStaticNoisePublicKey(): ByteArray? {
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
}
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
return hasEstablishedSession(peerID)
}
override fun getEncryptedPeers(): List<String> {
val encrypted = linkedSetOf<String>()
try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { }
try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { }
mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) }
return encrypted.toList()
}
override fun getDeviceAddressForPeer(peerID: String): String? {
return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
}
override fun getDeviceAddressToPeerMapping(): Map<String, String> {
val merged = linkedMapOf<String, String>()
try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { }
try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { }
return merged
}
override fun printDeviceAddressesForPeers(): String {
return buildString {
appendLine(bluetooth.printDeviceAddressesForPeers())
wifiService()?.let {
appendLine()
appendLine(it.printDeviceAddressesForPeers())
}
}
}
override fun getDebugStatus(): String {
return buildString {
appendLine("=== Unified Mesh Service Debug Status ===")
appendLine("My Peer ID: $myPeerID")
appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}")
appendLine()
appendLine(bluetooth.getDebugStatus())
wifiService()?.let {
appendLine()
appendLine(it.getDebugStatus())
}
}
}
override fun clearAllInternalData() {
try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
}
override fun clearAllEncryptionData() {
try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { }
try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { }
}
override fun didReceiveMessage(message: BitchatMessage) {
delegate?.didReceiveMessage(message)
}
override fun didUpdatePeerList(peers: List<String>) {
delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() })
}
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
delegate?.didReceiveChannelLeave(channel, fromPeer)
}
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
delegate?.didReceiveDeliveryAck(messageID, recipientPeerID)
}
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
delegate?.didReceiveReadReceipt(messageID, recipientPeerID)
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveNdrEvent(peerID, payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
}
override fun getNickname(): String? = delegate?.getNickname()
override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false
private fun mergedPeerIDs(): List<String> {
val ids = linkedSetOf<String>()
try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { }
try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { }
try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { }
return ids.toList()
}
private fun wifiService(): MeshService? {
return try {
WifiAwareController.getService()?.also { service ->
if (delegate != null && service.delegate !== this) {
service.delegate = this
}
}
} catch (_: Exception) {
null
}
}
private fun isBleEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
} catch (_: Exception) {
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
}
}
private fun isBleConnected(peerID: String): Boolean {
return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
}
private fun isWifiConnected(peerID: String): Boolean {
return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
}
private fun isBleReady(peerID: String): Boolean {
return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID)
}
private fun isWifiReady(peerID: String): Boolean {
return try {
val wifi = wifiService()
wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean {
return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false }
}
}

View File

@ -0,0 +1,86 @@
package com.bitchat.android.model
/**
* Canonical payload carried inside Noise payload type 0x21.
*
* Wire format:
* `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]`
* `[type=0x02][len=32][Ed25519 public key]`
*
* Unknown TLVs are skipped. Both known fields must occur exactly once.
*/
data class AuthenticatedPeerState(
val capabilities: PeerCapabilities,
val signingPublicKey: ByteArray
) {
init {
require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) {
"Ed25519 public key must be 32 bytes"
}
}
fun encode(): ByteArray {
val capabilityBytes = capabilities.encoded()
return buildList<Byte>(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) {
add(VERSION.toByte())
add(CAPABILITIES_TLV.toByte())
add(capabilityBytes.size.toByte())
addAll(capabilityBytes.toList())
add(SIGNING_PUBLIC_KEY_TLV.toByte())
add(SIGNING_PUBLIC_KEY_SIZE.toByte())
addAll(signingPublicKey.toList())
}.toByteArray()
}
companion object {
const val VERSION = 0x01
private const val CAPABILITIES_TLV = 0x01
private const val SIGNING_PUBLIC_KEY_TLV = 0x02
private const val SIGNING_PUBLIC_KEY_SIZE = 32
fun decode(data: ByteArray): AuthenticatedPeerState? {
if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null
var offset = 1
var capabilities: PeerCapabilities? = null
var signingPublicKey: ByteArray? = null
while (offset < data.size) {
if (offset + 2 > data.size) return null
val type = data[offset].toInt() and 0xFF
val length = data[offset + 1].toInt() and 0xFF
offset += 2
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
CAPABILITIES_TLV -> {
if (capabilities != null || length !in 1..8) return null
val decoded = PeerCapabilities.decode(value)
if (!decoded.encoded().contentEquals(value)) return null
capabilities = decoded
}
SIGNING_PUBLIC_KEY_TLV -> {
if (signingPublicKey != null || length != SIGNING_PUBLIC_KEY_SIZE) return null
signingPublicKey = value
}
else -> Unit
}
}
val decodedCapabilities = capabilities ?: return null
val decodedSigningKey = signingPublicKey ?: return null
return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey)
}
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedPeerState &&
capabilities == other.capabilities &&
signingPublicKey.contentEquals(other.signingPublicKey))
override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode()
}

View File

@ -83,6 +83,9 @@ data class FragmentPayload(
* Matches iOS implementation exactly
*/
fun encode(): ByteArray {
require(index in 0..0xFFFF) { "Fragment index would truncate UInt16: $index" }
require(total in 1..0xFFFF) { "Fragment total would truncate UInt16: $total" }
require(index < total) { "Fragment index $index must be below total $total" }
val payload = ByteArray(HEADER_SIZE + data.size)
// Fragment ID (8 bytes)

View File

@ -2,7 +2,6 @@ package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
/**
* Identity announcement structure with TLV encoding
@ -12,7 +11,9 @@ import com.bitchat.android.util.*
data class IdentityAnnouncement(
val nickname: String,
val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement)
val signingPublicKey: ByteArray // Ed25519 public key for signing
val signingPublicKey: ByteArray, // Ed25519 public key for signing
val capabilities: PeerCapabilities? = null,
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList()
) : Parcelable {
/**
@ -21,7 +22,8 @@ data class IdentityAnnouncement(
private enum class TLVType(val value: UByte) {
NICKNAME(0x01u),
NOISE_PUBLIC_KEY(0x02u),
SIGNING_PUBLIC_KEY(0x03u); // NEW: Ed25519 signing public key
SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key
CAPABILITIES(0x05u);
companion object {
fun fromValue(value: UByte): TLVType? {
@ -37,7 +39,8 @@ data class IdentityAnnouncement(
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) {
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 ||
unknownTLVs.any { it.value.size > 255 }) {
return null
}
@ -57,6 +60,21 @@ data class IdentityAnnouncement(
result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte())
result.add(signingPublicKey.size.toByte())
result.addAll(signingPublicKey.toList())
// Optional little-endian feature bitfield. Old clients skip this TLV.
capabilities?.encoded()?.let { capabilityBytes ->
result.add(TLVType.CAPABILITIES.value.toByte())
result.add(capabilityBytes.size.toByte())
result.addAll(capabilityBytes.toList())
}
// Preserve extensions this build does not understand. This includes
// gossip TLV 0x04 when an announcement is decoded through this model.
unknownTLVs.forEach { tlv ->
result.add(tlv.type.toByte())
result.add(tlv.value.size.toByte())
result.addAll(tlv.value.toList())
}
return result.toByteArray()
}
@ -73,6 +91,8 @@ data class IdentityAnnouncement(
var nickname: String? = null
var noisePublicKey: ByteArray? = null
var signingPublicKey: ByteArray? = null
var capabilities: PeerCapabilities? = null
val unknownTLVs = mutableListOf<UnknownAnnouncementTLV>()
while (offset + 2 <= dataCopy.size) {
// Read TLV type
@ -102,20 +122,36 @@ data class IdentityAnnouncement(
TLVType.SIGNING_PUBLIC_KEY -> {
signingPublicKey = value
}
TLVType.CAPABILITIES -> {
capabilities = PeerCapabilities.decode(value)
}
null -> {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
continue
// Retain unknown extensions so callers can forward or
// re-encode the announcement without erasing them.
unknownTLVs += UnknownAnnouncementTLV(typeValue.toInt(), value)
}
}
}
// All three fields are required
return if (nickname != null && noisePublicKey != null && signingPublicKey != null) {
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey)
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs)
} else {
null
}
}
/** Construct the announcement emitted by this Android build. */
fun forLocalPeer(
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray
): IdentityAnnouncement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
capabilities = PeerCapabilities.LOCAL_SUPPORTED
)
}
// Override equals and hashCode since we use ByteArray
@ -128,6 +164,8 @@ data class IdentityAnnouncement(
if (nickname != other.nickname) return false
if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
if (capabilities != other.capabilities) return false
if (unknownTLVs != other.unknownTLVs) return false
return true
}
@ -136,10 +174,12 @@ data class IdentityAnnouncement(
var result = nickname.hashCode()
result = 31 * result + noisePublicKey.contentHashCode()
result = 31 * result + signingPublicKey.contentHashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + unknownTLVs.hashCode()
return result
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)"
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})"
}
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.model
import com.bitchat.android.BuildConfig
/**
* Coordinated rollout gate for Nostr double-ratchet transport.
*
* Production builds stay fail-closed until the kind-1402 envelope migration
* is implemented and the maintainers explicitly enable the rollout.
*/
object NdrFeatureGate {
@Volatile
private var debugTestOverride = false
fun isEnabled(): Boolean =
BuildConfig.NDR_ROLLOUT_ENABLED || (BuildConfig.DEBUG && debugTestOverride)
internal fun setEnabledForTests(enabled: Boolean) {
check(BuildConfig.DEBUG) { "The NDR test override is unavailable in release builds" }
debugTestOverride = enabled
}
}

View File

@ -23,13 +23,25 @@ enum class NoisePayloadType(val value: UByte) {
DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
NDR_EVENT(0x12u), // UTF-8 Nostr event JSON for double-ratchet OOB bootstrap
FILE_TRANSFER(0x20u);
FILE_TRANSFER(0x20u),
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
PEER_STATE(0x21u),
/** UTF-8 Nostr event/URL used only for authenticated double-ratchet bootstrap. */
NDR_EVENT(0x22u);
companion object {
// #1434 prerelease iOS builds briefly emitted private files as 0x09. Keep this
// decode-only: every NoisePayload constructed by Android still encodes FILE_TRANSFER as
// its canonical 0x20 value, so the compatibility alias cannot leak into new traffic.
private val PRERELEASE_FILE_TRANSFER_RAW_VALUE = 0x09u.toUByte()
fun fromValue(value: UByte): NoisePayloadType? {
return values().find { it.value == value }
return if (value == PRERELEASE_FILE_TRANSFER_RAW_VALUE) {
FILE_TRANSFER
} else {
values().find { it.value == value }
}
}
}
}

View File

@ -0,0 +1,78 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Feature bits advertised in IdentityAnnouncement TLV 0x05.
*
* The wire representation matches iOS: a minimal little-endian bitfield that
* always contains at least one byte. Unknown bits in the low 64 bits are kept
* so a decode/re-encode cycle does not erase capabilities added by newer
* clients.
*/
@Parcelize
data class PeerCapabilities(val rawValue: Long) : Parcelable {
fun contains(capability: PeerCapabilities): Boolean =
(rawValue and capability.rawValue) == capability.rawValue
fun encoded(): ByteArray {
var remaining = rawValue
val bytes = mutableListOf<Byte>()
do {
bytes += remaining.toByte()
remaining = remaining ushr 8
} while (remaining != 0L)
return bytes.toByteArray()
}
companion object {
val NONE = PeerCapabilities(0)
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
/** Authenticated out-of-band bootstrap for the Nostr double ratchet. */
val NOSTR_DOUBLE_RATCHET = PeerCapabilities(1L shl 11)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED: PeerCapabilities
get() {
val ndrCapability = if (NdrFeatureGate.isEnabled()) {
NOSTR_DOUBLE_RATCHET.rawValue
} else {
0L
}
return PeerCapabilities(PRIVATE_MEDIA.rawValue or ndrCapability)
}
/**
* Decode the low 64 bits and ignore any future extension bytes, which
* is the same forward-compatible behavior used by iOS.
*/
fun decode(data: ByteArray): PeerCapabilities {
var rawValue = 0L
data.take(8).forEachIndexed { index, byte ->
rawValue = rawValue or ((byte.toLong() and 0xFF) shl (8 * index))
}
return PeerCapabilities(rawValue)
}
}
}
/** An announcement TLV not understood by this build, retained verbatim. */
@Parcelize
class UnknownAnnouncementTLV(
val type: Int,
val value: ByteArray
) : Parcelable {
init {
require(type in 0..0xFF) { "TLV type must fit in one byte" }
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is UnknownAnnouncementTLV && type == other.type && value.contentEquals(other.value))
override fun hashCode(): Int = 31 * type + value.contentHashCode()
}

View File

@ -10,5 +10,10 @@ data class RoutedPacket(
val packet: BitchatPacket,
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null // Optional stable transfer ID for progress tracking
val transferId: String? = null, // Optional stable transfer ID for progress tracking
/** Exact fragments admitted during private-media prepare; never rebuild them at commit. */
val preparedPackets: List<BitchatPacket>? = null,
// Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement
// sockets for the same provisional peer and must never be serialized onto the mesh.
val ingressLinkID: String? = null
)

View File

@ -168,6 +168,27 @@ class ArtiTorManager private constructor() {
fun currentSocksAddress(): InetSocketAddress? = socksAddr
/**
* Wait until the currently selected HTTP route can be used.
*
* When Tor mode is enabled, [socksAddr] is intentionally published before
* bootstrap completes so clients fail closed instead of leaking traffic
* directly. Callers that initiate one-shot HTTP work should wait here rather
* than repeatedly connecting to a SOCKS port that is not listening yet.
*/
suspend fun awaitSelectedRoute(timeoutMs: Long): Boolean {
if (currentSocksAddress() == null || isProxyEnabled()) {
return true
}
return withTimeoutOrNull(timeoutMs) {
statusFlow.first {
currentSocksAddress() == null || isProxyEnabled()
}
true
} ?: false
}
suspend fun applyMode(application: Application, mode: TorMode) {
applyMutex.withLock {
try {

View File

@ -51,7 +51,6 @@ class NoiseEncryptionService(private val context: Context) {
// Callbacks
var onPeerAuthenticated: ((String, String) -> Unit)? = null // (peerID, fingerprint)
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
var onSessionEstablished: ((String) -> Unit)? = null // peerID established transport session
init {
// Initialize identity state manager for persistent storage
@ -71,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) {
private fun initializeSessionManager() {
// Create new session manager with current keys
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
@ -149,6 +149,15 @@ class NoiseEncryptionService(private val context: Context) {
fun getPeerPublicKeyData(peerID: String): ByteArray? {
return sessionManager.getRemoteStaticKey(peerID)
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
sessionManager.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = sessionManager.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Clear persistent identity (for panic mode)
@ -179,12 +188,9 @@ class NoiseEncryptionService(private val context: Context) {
* Initiate a Noise handshake with a peer
* Returns the first handshake message to send
*/
fun initiateHandshake(peerID: String): ByteArray? {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
return try {
sessionManager.initiateHandshake(peerID)
} catch (e: NoiseSessionError.HandshakeAlreadyInProgress) {
Log.d(TAG, "Handshake already in progress with $peerID; not sending a competing init")
null
sessionManager.initiateHandshake(peerID, replaceEstablished)
} catch (e: Exception) {
Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}")
null
@ -197,12 +203,25 @@ class NoiseEncryptionService(private val context: Context) {
*/
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
return try {
sessionManager.processHandshakeMessage(peerID, data)
processHandshakeMessageWithResult(data, peerID).response
} catch (e: Exception) {
Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}")
null
}
}
/**
* Typed handshake result for security-sensitive callers that must distinguish a null response
* from a newly authenticated session or a rejected handshake. Identity mismatch exceptions are
* intentionally propagated to the caller.
*/
@Throws(Exception::class)
fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
return sessionManager.processHandshakeMessageWithResult(peerID, data)
}
/**
* Check if we have an established session with a peer
@ -237,6 +256,13 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = sessionManager.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using established Noise session
@ -254,6 +280,14 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult? =
try {
sessionManager.decryptWithSession(encryptedData, peerID)
} catch (e: Exception) {
Log.e(TAG, "Failed generation-bound decryption from $peerID: ${e.message}")
null
}
// MARK: - Peer Management
@ -385,6 +419,20 @@ class NoiseEncryptionService(private val context: Context) {
// Store fingerprint mapping via centralized manager
// This is the ONLY place where fingerprints are stored - after successful Noise handshake
fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey)
// Preserve the canonical peerID -> npub index, but only after Noise proves possession of
// the static key. Announcement-time indexing was unsafe because a peer can copy another
// party's public Noise key without possessing its private key.
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findNostrPubkey(remoteStaticKey)
?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.updateNostrPublicKeyForPeerID(peerID, npub)
}
} catch (_: Exception) {
// Favorites may not be initialized in isolated/background crypto tests.
}
// Calculate fingerprint for logging and callback
val fingerprint = calculateFingerprint(remoteStaticKey)
@ -393,7 +441,6 @@ class NoiseEncryptionService(private val context: Context) {
// Notify about authentication
onPeerAuthenticated?.invoke(peerID, fingerprint)
onSessionEstablished?.invoke(peerID)
}
/**

View File

@ -0,0 +1,30 @@
package com.bitchat.android.noise
import java.security.MessageDigest
/**
* Canonical binding between an authenticated Noise static key and its mesh wire identity.
*
* Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase
* hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not
* sufficient on its own: the claimed wire ID must also match this derivation.
*/
object NoisePeerIdentity {
const val STATIC_PUBLIC_KEY_SIZE = 32
const val WIRE_PEER_ID_LENGTH = 16
private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$")
fun derivePeerID(staticPublicKey: ByteArray): String? {
if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null
return MessageDigest.getInstance("SHA-256")
.digest(staticPublicKey)
.take(8)
.joinToString("") { "%02x".format(it) }
}
fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean {
if (!wirePeerIDPattern.matches(claimedPeerID)) return false
return derivePeerID(staticPublicKey) == claimedPeerID
}
}

View File

@ -153,6 +153,9 @@ class NoiseSession(
// Session state
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
private val creationTime = System.currentTimeMillis()
private var handshakeStartMs: Long? = null
private var lastHandshakeActivityMs: Long? = null
private var handshakeMessage1: ByteArray? = null
// Session counters
private var currentPattern = 0;
@ -194,8 +197,16 @@ class NoiseSession(
fun getState(): NoiseSessionState = state
fun isEstablished(): Boolean = state is NoiseSessionState.Established
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
fun isInitiatorSession(): Boolean = isInitiator
fun getCreationTime(): Long = creationTime
fun isInitiatorRole(): Boolean = isInitiator
fun getHandshakeStartMs(): Long? = handshakeStartMs
fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
lastHandshakeActivityMs = timestampMs
}
init {
try {
@ -318,19 +329,25 @@ class NoiseSession(
// Initialize handshake as initiator
initializeNoiseHandshake(HandshakeState.INITIATOR)
state = NoiseSessionState.Handshaking
if (handshakeStartMs == null) {
handshakeStartMs = System.currentTimeMillis()
}
lastHandshakeActivityMs = System.currentTimeMillis()
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
currentPattern++
val firstMessage = messageBuffer.copyOf(messageLength)
handshakeMessage1 = firstMessage
// Validate message size matches XX pattern expectations
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
}
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
val ePrefix = firstMessage.take(4).toByteArray().toHexString()
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
return firstMessage
} catch (e: Exception) {
state = NoiseSessionState.Failed(e)
@ -345,19 +362,24 @@ class NoiseSession(
*/
@Synchronized
fun processHandshakeMessage(message: ByteArray): ByteArray? {
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
val inputPrefix = message.take(4).toByteArray().toHexString()
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
try {
// Initialize as responder if receiving first message
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
initializeNoiseHandshake(HandshakeState.RESPONDER)
state = NoiseSessionState.Handshaking
if (handshakeStartMs == null) {
handshakeStartMs = System.currentTimeMillis()
}
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
}
if (state != NoiseSessionState.Handshaking) {
throw IllegalStateException("Invalid state for handshake: $state")
}
lastHandshakeActivityMs = System.currentTimeMillis()
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
@ -367,7 +389,8 @@ class NoiseSession(
// Read the incoming message - the Noise library will handle validation
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
currentPattern++
Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
val readPrefix = message.take(4).toByteArray().toHexString()
Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
// Check what action the handshake state wants us to take next
val action = handshakeStateLocal.getAction()
@ -428,27 +451,36 @@ class NoiseSession(
Log.d(TAG, "Completing XX handshake with $peerID")
try {
// Split handshake state into transport ciphers
val cipherPair = handshakeState?.split()
sendCipher = cipherPair?.getSender()
receiveCipher = cipherPair?.getReceiver()
// Extract remote static key if available
if (handshakeState?.hasRemotePublicKey() == true) {
val remoteDH = handshakeState?.getRemotePublicKey()
if (remoteDH != null) {
remoteStaticPublicKey = ByteArray(32)
remoteDH.getPublicKey(remoteStaticPublicKey!!, 0)
Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}")
}
val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed
// Authenticate the remote static key's claimed mesh identity before split creates
// transport ciphers or the session can become observable as Established.
if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed
val remoteDH = activeHandshake.getRemotePublicKey()
?: throw NoiseSessionError.HandshakeFailed
val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE)
remoteDH.getPublicKey(authenticatedRemoteKey, 0)
val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) {
authenticatedRemoteKey.fill(0)
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
remoteStaticPublicKey = authenticatedRemoteKey
Log.d(TAG, "Remote static public key is bound to $peerID")
// Only a bound remote identity may derive transport ciphers.
val cipherPair = activeHandshake.split()
sendCipher = cipherPair.getSender()
receiveCipher = cipherPair.getReceiver()
// Extract handshake hash for channel binding
handshakeHash = handshakeState?.getHandshakeHash()
// getHandshakeHash() exposes the handshake state's backing array. Clone it before
// destroy() zeroizes that state, or every completed session appears to have the same
// all-zero channel-binding token.
handshakeHash = activeHandshake.getHandshakeHash().clone()
// Clean up handshake state
handshakeState?.destroy()
activeHandshake.destroy()
handshakeState = null
messagesSent = 0
@ -573,7 +605,12 @@ class NoiseSession(
}
val (extractedNonce, ciphertext) = nonceAndCiphertext
if (ciphertext.size < receiveCipher!!.macLength) {
Log.w(TAG, "Ciphertext too short: ${ciphertext.size} < ${receiveCipher!!.macLength}")
throw SessionError.DecryptionFailed
}
// Validate nonce with sliding window replay protection
if (!isValidNonce(extractedNonce, highestReceivedNonce, replayWindow)) {
Log.w(TAG, "Replay attack detected: nonce $extractedNonce rejected for $peerID")

View File

@ -3,20 +3,62 @@ package com.bitchat.android.noise
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
data class NoiseHandshakeProcessingResult(
val response: ByteArray?,
val establishedNow: Boolean,
/** The bound remote static key only when this exact call completed authentication. */
val authenticatedRemoteStaticKey: ByteArray? = null,
/** Handshake hash identifying that exact authenticated Noise generation. */
val authenticatedSessionToken: ByteArray? = null
)
/** Atomic snapshot of the live authenticated Noise generation. */
class AuthenticatedNoiseSession(
remoteStaticKey: ByteArray,
sessionToken: ByteArray
) {
private val remoteStaticKeyBytes = remoteStaticKey.copyOf()
private val sessionTokenBytes = sessionToken.copyOf()
val remoteStaticKey: ByteArray get() = remoteStaticKeyBytes.copyOf()
val sessionToken: ByteArray get() = sessionTokenBytes.copyOf()
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedNoiseSession &&
remoteStaticKeyBytes.contentEquals(other.remoteStaticKeyBytes) &&
sessionTokenBytes.contentEquals(other.sessionTokenBytes))
override fun hashCode(): Int =
31 * remoteStaticKeyBytes.contentHashCode() + sessionTokenBytes.contentHashCode()
}
/** Plaintext and generation binding captured atomically from the session that decrypted it. */
data class NoiseDecryptionResult(
val plaintext: ByteArray,
val authenticatedSession: AuthenticatedNoiseSession
)
/**
* SIMPLIFIED Noise session manager - focuses on core functionality only
*/
class NoiseSessionManager(
private val localStaticPrivateKey: ByteArray,
private val localStaticPublicKey: ByteArray
private val localStaticPublicKey: ByteArray,
private val localPeerID: String
) {
companion object {
private const val TAG = "NoiseSessionManager"
private const val INITIAL_XX_MESSAGE_SIZE = 32
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
private const val SESSION_TOKEN_SIZE = 32
}
private val sessions = ConcurrentHashMap<String, NoiseSession>()
// An inbound replacement handshake must prove its authenticated static-key binding before it
// can evict a working transport session. Keep responder candidates outside the active map.
private val responderCandidates = ConcurrentHashMap<String, NoiseSession>()
// Callbacks
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
@ -27,8 +69,10 @@ class NoiseSessionManager(
/**
* Add new session for a peer
*/
@Synchronized
fun addSession(peerID: String, session: NoiseSession) {
sessions[peerID] = session
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
Log.d(TAG, "Added new session for $peerID")
}
@ -43,9 +87,10 @@ class NoiseSessionManager(
/**
* Remove session for a peer
*/
@Synchronized
fun removeSession(peerID: String) {
sessions[peerID]?.destroy()
sessions.remove(peerID)
sessions.remove(peerID)?.destroy()
responderCandidates.remove(peerID)?.destroy()
Log.d(TAG, "Removed session for $peerID")
}
@ -53,17 +98,42 @@ class NoiseSessionManager(
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
@Synchronized
fun initiateHandshake(peerID: String): ByteArray {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
val now = System.currentTimeMillis()
val existing = getSession(peerID)
if (existing?.isHandshaking() == true) {
Log.d(TAG, "Handshake already in progress with $peerID; not restarting")
throw NoiseSessionError.HandshakeAlreadyInProgress
if (existing != null) {
when {
existing.isEstablished() -> {
if (!replaceEstablished) {
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
return null
}
val candidate = createSession(peerID, isInitiator = true)
responderCandidates.remove(peerID)?.destroy()
responderCandidates[peerID] = candidate
return try {
candidate.startHandshake()
} catch (e: Exception) {
responderCandidates.remove(peerID, candidate)
candidate.destroy()
throw e
}
}
existing.isHandshaking() -> {
if (!isHandshakeStale(existing, now)) {
Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
return null
}
Log.d(TAG, "Handshake with $peerID is stale; restarting")
removeSession(peerID)
}
else -> {
removeSession(peerID)
}
}
}
// Remove any existing session first
removeSession(peerID)
// Create new session as initiator
val session = NoiseSession(
@ -80,7 +150,7 @@ class NoiseSessionManager(
Log.d(TAG, "Started handshake with $peerID as INITIATOR")
return handshakeData
} catch (e: Exception) {
sessions.remove(peerID)
if (sessions.remove(peerID, session)) session.destroy()
throw e
}
}
@ -88,63 +158,167 @@ class NoiseSessionManager(
/**
* Handle incoming handshake message
*/
@Synchronized
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
try {
var session = getSession(peerID)
// If both peers initiate at the same time, the inbound 32-byte XX
// message is the peer's first message. Yield to it so one side can
// become responder instead of trying to read it as message 2.
if (
session?.isInitiatorSession() == true &&
session.isHandshaking() &&
message.size == INITIAL_XX_MESSAGE_SIZE
) {
Log.d(TAG, "Simultaneous initiator collision with $peerID; switching to RESPONDER")
removeSession(peerID)
session = null
}
return processHandshakeMessageWithResult(peerID, message).response
}
// If no session exists, create one as responder
if (session == null) {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
session = NoiseSession(
peerID = peerID,
isInitiator = false,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
addSession(peerID, session)
}
// Process handshake message
val response = session.processHandshakeMessage(message)
// Check if session is established
if (session.isEstablished()) {
Log.d(TAG, "✅ Session ESTABLISHED with $peerID")
val remoteStaticKey = session.getRemoteStaticPublicKey()
if (remoteStaticKey != null) {
onSessionEstablished?.invoke(peerID, remoteStaticKey)
@Synchronized
fun processHandshakeMessageWithResult(
peerID: String,
message: ByteArray
): NoiseHandshakeProcessingResult {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
var activeSession: NoiseSession? = null
var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null
var establishedSessionToken: ByteArray? = null
var response: ByteArray? = null
try {
val existingCandidate = responderCandidates[peerID]
if (existingCandidate != null) {
activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) {
if (existingCandidate.isInitiatorRole()) {
val shouldYield = localPeerID > peerID
if (!shouldYield) {
Log.d(
TAG,
"Replacement handshake collision with $peerID; keeping initiator role"
)
return NoiseHandshakeProcessingResult(
response = null,
establishedNow = false
)
}
Log.d(
TAG,
"Replacement handshake collision with $peerID; yielding to responder role"
)
}
responderCandidates.remove(peerID, existingCandidate)
existingCandidate.destroy()
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
} else {
existingCandidate
}
isReplacementCandidate = true
} else {
var session = getSession(peerID)
// Collision handling: both sides initiated and we received message 1.
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
if (sessions.remove(peerID, session)) session.destroy()
session = null
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return NoiseHandshakeProcessingResult(response = null, establishedNow = false)
}
}
activeSession = when {
session == null -> {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
session.isEstablished() -> {
Log.d(
TAG,
"Validating replacement handshake for $peerID while preserving active session"
)
isReplacementCandidate = true
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
}
session.isHandshaking() &&
!session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE -> {
// A restarted responder handshake can replace an incomplete session because
// there is no working transport state to preserve.
if (sessions.remove(peerID, session)) session.destroy()
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
else -> session
}
}
return response
val session = activeSession ?: throw NoiseSessionError.InvalidState
response = session.processHandshakeMessage(message)
if (session.isEstablished()) {
val remoteStaticKey = session.getRemoteStaticPublicKey()
?: throw NoiseSessionError.HandshakeFailed
val derivedPeerID = NoisePeerIdentity.derivePeerID(remoteStaticKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStaticKey)) {
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
val sessionToken = session.getHandshakeHash()
?.takeIf { it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } }
?: throw NoiseSessionError.HandshakeFailed
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
}
establishedRemoteKey = remoteStaticKey
establishedSessionToken = sessionToken
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
}
} catch (e: Exception) {
val session = activeSession
if (session != null) {
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
} else {
sessions.remove(peerID, session)
}
session.destroy()
}
Log.e(TAG, "Handshake failed with $peerID: ${e.message}")
sessions.remove(peerID)
onSessionFailed?.invoke(peerID, e)
runCatching { onSessionFailed?.invoke(peerID, e) }
throw e
}
establishedRemoteKey?.let { onSessionEstablished?.invoke(peerID, it) }
return NoiseHandshakeProcessingResult(
response = response,
establishedNow = establishedRemoteKey != null,
authenticatedRemoteStaticKey = establishedRemoteKey?.clone(),
authenticatedSessionToken = establishedSessionToken?.clone()
)
}
private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession = NoiseSession(
peerID = peerID,
isInitiator = isInitiator,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
if (lastActivity == null) return false
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
}
/**
* SIMPLIFIED: Encrypt data
*/
@Synchronized
fun encrypt(data: ByteArray, peerID: String): ByteArray {
val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID")
if (!session.isEstablished()) {
@ -152,11 +326,29 @@ class NoiseSessionManager(
}
return session.encrypt(data)
}
/** Encrypt only if the exact generation that authorized the operation is still active. */
@Synchronized
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray {
val session = getSession(peerID) ?: throw NoiseSessionError.SessionNotFound
if (!session.isEstablished()) throw NoiseSessionError.SessionNotEstablished
val current = authenticatedSession(session) ?: throw NoiseSessionError.SessionNotEstablished
if (current != expectedSession) throw NoiseSessionError.SessionGenerationChanged
return session.encrypt(data)
}
/**
* SIMPLIFIED: Decrypt data
*/
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray {
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray =
decryptWithSession(encryptedData, peerID).plaintext
@Synchronized
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult {
val session = getSession(peerID)
if (session == null) {
Log.e(TAG, "No session found for $peerID when trying to decrypt")
@ -166,7 +358,10 @@ class NoiseSessionManager(
Log.e(TAG, "Session not established with $peerID when trying to decrypt")
throw IllegalStateException("Session not established with $peerID")
}
return session.decrypt(encryptedData)
val plaintext = session.decrypt(encryptedData)
val authenticatedSession = authenticatedSession(session)
?: throw IllegalStateException("Established session for $peerID has no channel binding")
return NoiseDecryptionResult(plaintext, authenticatedSession)
}
/**
@ -188,15 +383,42 @@ class NoiseSessionManager(
/**
* Get remote static public key for a peer (if session established)
*/
fun getRemoteStaticKey(peerID: String): ByteArray? {
return getSession(peerID)?.getRemoteStaticPublicKey()
fun getRemoteStaticKey(peerID: String): ByteArray? =
getAuthenticatedSession(peerID)?.remoteStaticKey
@Synchronized
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? {
val session = getSession(peerID) ?: return null
if (!session.isEstablished()) return null
return authenticatedSession(session)
}
/** Execute a state transition while preventing replacement/removal of the expected session. */
@Synchronized
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean {
val session = getSession(peerID) ?: return false
if (!session.isEstablished()) return false
if (authenticatedSession(session) != expectedSession) return false
return action()
}
/**
* Get handshake hash for channel binding (if session established)
*/
fun getHandshakeHash(peerID: String): ByteArray? {
return getSession(peerID)?.getHandshakeHash()
return getAuthenticatedSession(peerID)?.sessionToken
}
private fun authenticatedSession(session: NoiseSession): AuthenticatedNoiseSession? {
val remoteStaticKey = session.getRemoteStaticPublicKey()?.takeIf { it.size == 32 } ?: return null
val sessionToken = session.getHandshakeHash()?.takeIf {
it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() }
} ?: return null
return AuthenticatedNoiseSession(remoteStaticKey, sessionToken)
}
/**
@ -216,6 +438,7 @@ class NoiseSessionManager(
fun getDebugInfo(): String = buildString {
appendLine("=== Noise Session Manager Debug ===")
appendLine("Active sessions: ${sessions.size}")
appendLine("Responder candidates: ${responderCandidates.size}")
appendLine("")
if (sessions.isNotEmpty()) {
@ -229,9 +452,12 @@ class NoiseSessionManager(
/**
* Shutdown manager and clean up all sessions
*/
@Synchronized
fun shutdown() {
sessions.values.forEach { it.destroy() }
responderCandidates.values.forEach { it.destroy() }
sessions.clear()
responderCandidates.clear()
Log.d(TAG, "Noise session manager shut down")
}
}
@ -244,6 +470,9 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
object SessionNotEstablished : NoiseSessionError("Session not established")
object InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed")
object HandshakeAlreadyInProgress : NoiseSessionError("Handshake already in progress")
object AlreadyEstablished : NoiseSessionError("Session already established")
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
)
}

View File

@ -6,9 +6,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MessageManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
/**
@ -44,7 +42,7 @@ class GeohashMessageHandler(
}
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
scope.launch(Dispatchers.Default) {
scope.launch {
try {
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
@ -59,22 +57,22 @@ class GeohashMessageHandler(
}
}
// Blocked users check (use injected DataManager which has loaded state)
if (dataManager.isGeohashUserBlocked(event.pubkey)) return@launch
// Normalize pubkey to lowercase for consistent blocking and storage
val pubkey = event.pubkey.lowercase()
// Blocked users check (use injected DataManager which has loaded state)
if (dataManager.isGeohashUserBlocked(pubkey)) return@launch
// Update repository (participants, nickname, teleport)
// Update repository on a background-safe path; repository will post updates to LiveData
// Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events
if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) {
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L))
}
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(pubkey, it[1]) }
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(pubkey) }
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
try {
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${pubkey.take(16)}", pubkey)
} catch (_: Exception) { }
// Stop here for presence events - they don't produce chat messages
@ -82,13 +80,13 @@ class GeohashMessageHandler(
// Skip our own events for message emission
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
if (my.publicKeyHex.equals(pubkey, true)) return@launch
val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } &&
event.content.trim().isEmpty()
if (isTeleportPresence) return@launch
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
val senderName = repo.displayNameForNostrPubkeyUI(pubkey)
val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false }
val msg = BitchatMessage(
id = event.id,
@ -96,15 +94,15 @@ class GeohashMessageHandler(
content = event.content,
timestamp = Date(event.createdAt * 1000L),
isRelay = false,
originalSender = repo.displayNameForNostrPubkey(event.pubkey),
senderPeerID = "nostr:${event.pubkey.take(8)}",
originalSender = repo.displayNameForNostrPubkey(pubkey),
senderPeerID = "nostr:${pubkey.take(8)}",
mentions = null,
channel = "#$subscribedGeohash",
powDifficulty = try {
if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null
} catch (_: Exception) { null }
)
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
messageManager.addChannelMessage("geo:$subscribedGeohash", msg)
} catch (e: Exception) {
Log.e(TAG, "onEvent error: ${e.message}")
}

View File

@ -103,7 +103,16 @@ class GeohashRepository(
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
participants[participantId] = lastSeen
// Cap to now: prevents future-timestamped events (clock skew / malicious created_at)
// from pinning lastSeen and blocking subsequent normal heartbeats.
// Also keeps max: relays send events newest-first, so subsequent older events for
// the same user must not overwrite a fresher lastSeen.
val now = Date()
val effective = if (lastSeen.after(now)) now else lastSeen
val existing = participants[participantId]
if (existing == null || effective.after(existing)) {
participants[participantId] = effective
}
if (currentGeohash == geohash) refreshGeohashPeople()
updateReactiveParticipantCounts()
}

View File

@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
*/
@MainThread
class LocationNotesManager private constructor() {
companion object {
private const val TAG = "LocationNotesManager"
private const val MAX_NOTES_IN_MEMORY = 500
@ -27,7 +27,7 @@ class LocationNotesManager private constructor() {
}
}
}
/**
* Note data class matching iOS implementation
*/
@ -94,6 +94,8 @@ class LocationNotesManager private constructor() {
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
/**
* Initialize dependencies
@ -289,6 +291,11 @@ class LocationNotesManager private constructor() {
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
@ -301,7 +308,7 @@ class LocationNotesManager private constructor() {
Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly")
_state.value = State.LOADING
// Retry a few times in case initialization is racing the sheet open
scope.launch {
subscribeRetryJob = scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
delay(300)
@ -342,9 +349,9 @@ class LocationNotesManager private constructor() {
}
// Mark initial load complete after brief delay to allow relay responses
scope.launch {
initialLoadJob = scope.launch {
delay(2000) // Wait 2 seconds for initial batch
if (!_initialLoadComplete.value!!) {
if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
@ -441,6 +448,11 @@ class LocationNotesManager private constructor() {
* Cancel subscription and clear state
*/
fun cancel() {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
@ -453,17 +465,26 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = emptySet()
_state.value = State.IDLE
}
/**
* Cleanup resources
* End the nearby-notes session and discard location-correlated UI state.
* Unlike [cancel], this also clears the target so a later activation can
* safely subscribe to the same building geohash again.
*/
fun cleanup() {
fun stop() {
cancel()
scope.cancel()
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}
/**
* Cleanup resources
*/
fun cleanup() {
stop()
scope.cancel()
}
}

View File

@ -0,0 +1,49 @@
package com.bitchat.android.nostr
internal data class NdrAccountEpoch(
val generation: Long,
val accountPubkeyHex: String
)
/**
* Serializes NDR receive-side mutations against account invalidation.
*
* Panic invalidation waits for a mutation already inside [runIfCurrent], then
* advances the generation before the wipe starts. Old-account jobs can
* therefore neither overlap nor repopulate the fresh post-wipe epoch.
*/
internal class NdrAccountEpochGuard {
private val lock = Any()
private var generation = 0L
private var accountPubkeyHex: String? = null
fun begin(accountPubkeyHex: String): NdrAccountEpoch = synchronized(lock) {
val normalizedPubkeyHex = accountPubkeyHex.lowercase()
generation += 1
this.accountPubkeyHex = normalizedPubkeyHex
NdrAccountEpoch(generation, normalizedPubkeyHex)
}
fun invalidate() = synchronized(lock) {
generation += 1
accountPubkeyHex = null
}
fun isCurrent(epoch: NdrAccountEpoch): Boolean = synchronized(lock) {
isCurrentLocked(epoch)
}
fun runIfCurrent(epoch: NdrAccountEpoch, mutation: () -> Unit): Boolean =
synchronized(lock) {
if (!isCurrentLocked(epoch)) {
false
} else {
mutation()
true
}
}
private fun isCurrentLocked(epoch: NdrAccountEpoch): Boolean =
generation == epoch.generation &&
accountPubkeyHex == epoch.accountPubkeyHex
}

View File

@ -0,0 +1,53 @@
package com.bitchat.android.nostr
internal data class NdrApplicationMessage(
val content: String,
val timestampMs: Long
)
internal object NdrApplicationMessageDecoder {
private const val PROTOCOL_TAG = "ndr-protocol"
private const val PROTOCOL_VALUE = "pairwise-rumor"
private const val VERSION_TAG = "ndr-version"
private const val VERSION_VALUE = "1"
fun decode(
message: NdrDecryptedMessage,
fallbackTimestampMs: Long = System.currentTimeMillis()
): NdrApplicationMessage? {
val plaintext = message.content.trim()
if (!NdrInputPolicy.isWithinEncodedEventLimit(plaintext) ||
!NdrInputPolicy.isPubkeyHex(message.senderPubkeyHex) ||
message.senderDevicePubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
message.conversationOwnerPubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
message.eventId?.let(NdrInputPolicy::isEventIdHex) == false
) return null
// Compatibility with the earliest BitChat NDR prototype, which sent
// the embedded packet directly instead of the v1 pairwise rumor.
if (plaintext.startsWith("bitchat1:")) {
return NdrApplicationMessage(plaintext, fallbackTimestampMs)
}
val event = NostrEvent.fromJsonString(plaintext) ?: return null
if (event.kind != NostrKind.DIRECT_MESSAGE) return null
if (!NdrInputPolicy.isPubkeyHex(event.pubkey)) return null
if (!event.pubkey.equals(message.senderPubkeyHex, ignoreCase = true)) return null
if (event.createdAt <= 0 || event.id.isBlank()) return null
if (!event.id.equals(event.computeEventIdHex(), ignoreCase = true)) return null
if (!NdrInputPolicy.hasBoundedTags(event)) return null
if (!event.hasTag(PROTOCOL_TAG, PROTOCOL_VALUE)) return null
if (!event.hasTag(VERSION_TAG, VERSION_VALUE)) return null
return NdrApplicationMessage(
content = event.content,
timestampMs = event.createdAt.toLong() * 1000L
)
}
private fun NostrEvent.hasTag(name: String, value: String): Boolean {
return tags.any { tag ->
tag.size >= 2 && tag[0] == name && tag[1] == value
}
}
}

View File

@ -0,0 +1,30 @@
package com.bitchat.android.nostr
/**
* Bridges lifecycle events that can make an NDR bootstrap newly eligible.
*
* Authenticated capability resolution happens after the peer-list callback that
* carries the announcement, while a favorite can become mutual without any peer
* update. Both events therefore need an explicit bootstrap trigger.
*/
internal class NdrBootstrapTriggerCoordinator(
private val connectedPeerIDs: () -> List<String>,
private val noiseKeyHexForPeer: (String) -> String?,
private val requestBootstrap: (String) -> Unit
) {
fun onAuthenticatedPolicyResolved(peerID: String) {
requestBootstrap(peerID)
}
fun onFavoriteChanged(noiseKeyHex: String) {
val changedKey = noiseKeyHex.trim()
if (changedKey.isEmpty()) return
connectedPeerIDs()
.distinct()
.filter { peerID ->
noiseKeyHexForPeer(peerID)?.equals(changedKey, ignoreCase = true) == true
}
.forEach(requestBootstrap)
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.model.NdrFeatureGate
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
@ -10,12 +11,24 @@ class NdrNostrService(
private val relayManager: NdrRelayManager,
private val runtimeFactory: NdrSessionManagerFactory,
private val storageDirectoryProvider: () -> String,
private val deviceIdProvider: () -> String
private val deviceIdProvider: () -> String,
private val storageResetter: () -> Unit = {
val storageDirectory = java.io.File(storageDirectoryProvider())
if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) {
throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}")
}
},
private val deviceIdResetter: () -> Unit = {},
private val inviteOwnerResolver: (String) -> String? = Companion::resolveInviteOwnerPubkeyHex
) {
companion object {
private const val TAG = "NdrNostrService"
private const val COMPACT_INVITE_URL_ROOT = "https://b"
private const val NDR_APP_KEYS_KIND = 37368
private const val NDR_APP_KEYS_TYPE = "app_keys_roster_snapshot"
private const val NDR_MESSAGE_KIND = 1060
private const val MAX_BUFFERED_DECRYPTED_MESSAGES = 128
@Volatile
private var INSTANCE: NdrNostrService? = null
@ -27,6 +40,8 @@ class NdrNostrService(
}
private fun create(context: Context): NdrNostrService {
val storageDirectory = context.filesDir.resolve("ndr")
val preferences = context.getSharedPreferences("bitchat_ndr", Context.MODE_PRIVATE)
val relayManager = object : NdrRelayManager {
override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit) {
NostrRelayManager.getInstance(context).subscribe(filter, id, handler)
@ -65,20 +80,53 @@ class NdrNostrService(
relayManager = relayManager,
runtimeFactory = runtimeFactory,
storageDirectoryProvider = {
context.filesDir.resolve("ndr").apply { mkdirs() }.absolutePath
storageDirectory.apply { mkdirs() }.absolutePath
},
deviceIdProvider = {
val prefs = context.getSharedPreferences("bitchat_ndr", Context.MODE_PRIVATE)
prefs.getString("device_id", null) ?: java.util.UUID.randomUUID().toString().also {
prefs.edit().putString("device_id", it).apply()
preferences.getString("device_id", null) ?: java.util.UUID.randomUUID().toString().also {
preferences.edit().putString("device_id", it).apply()
}
},
storageResetter = {
if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) {
throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}")
}
},
deviceIdResetter = {
check(preferences.edit().remove("device_id").commit()) {
"Failed to clear NDR device id"
}
}
)
}
private fun resolveInviteOwnerPubkeyHex(payload: String): String? {
return try {
val invite = if (payload.startsWith("{")) {
uniffi.ndr_ffi.InviteHandle.fromEventJson(payload)
} else {
uniffi.ndr_ffi.InviteHandle.fromUrl(payload)
}
invite.use { it.`getOwnerPubkeyHex`().lowercase() }
} catch (_: Throwable) {
null
}
}
}
@Volatile
var onDecryptedMessage: ((NdrDecryptedMessage) -> Unit)? = null
@Synchronized set(value) {
field = value
if (value != null && NdrFeatureGate.isEnabled()) {
while (bufferedDecryptedMessages.isNotEmpty()) {
value(bufferedDecryptedMessages.removeFirst())
}
}
}
@Volatile
var onOutOfBandPayloadsReady: ((ownerPubkeyHex: String, payloads: List<String>) -> Unit)? = null
@Volatile
private var sessionManager: NdrSessionManager? = null
@ -89,15 +137,31 @@ class NdrNostrService(
@Volatile
private var cachedInviteEventJson: String? = null
@Volatile
private var panicResetBlocked = false
private val activeSubIds = linkedSetOf<String>()
private val pendingInvitesByOwner = linkedMapOf<String, PendingOutOfBandInvite>()
private val bufferedDecryptedMessages = ArrayDeque<NdrDecryptedMessage>()
@get:Synchronized
val isConfigured: Boolean
get() = sessionManager != null
get() = NdrFeatureGate.isEnabled() && sessionManager != null
fun currentInviteEventJson(): String? = cachedInviteEventJson
@Synchronized
fun currentInviteEventJson(): String? =
cachedInviteEventJson.takeIf { NdrFeatureGate.isEnabled() }
@Synchronized
fun configureIfNeeded(identity: NostrIdentity) {
if (!NdrFeatureGate.isEnabled()) {
teardownLocked()
return
}
if (panicResetBlocked) {
Log.e(TAG, "Refusing to configure NDR after an incomplete panic wipe")
return
}
val pubkeyHex = identity.publicKeyHex.lowercase()
if (configuredForPubkeyHex == pubkeyHex && sessionManager != null) {
return
@ -111,20 +175,27 @@ class NdrNostrService(
ourPubkeyHex = pubkeyHex,
ourIdentityPrivkeyHex = identity.privateKeyHex,
deviceId = deviceIdProvider(),
storagePath = storageDirectoryProvider(),
// The FFI storage adapter uses fixed filenames. Namespace them
// by account owner so an identity switch cannot load another
// account's ratchet database.
storagePath = java.io.File(
storageDirectoryProvider(),
pubkeyHex
).absolutePath,
ownerPubkeyHex = null
)
runtime.init()
sessionManager = runtime
drainAndApplyPubSubEventsLocked()
Log.d(TAG, "Configured NDR for ${pubkeyHex.take(8)}...")
} catch (t: Throwable) {
Log.e(TAG, "Failed to configure NDR: ${t.message}")
} catch (_: Throwable) {
Log.e(TAG, "Failed to configure NDR")
teardownLocked()
}
}
@Synchronized
fun hasActiveSession(peerPubkeyHex: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
val runtime = sessionManager ?: return false
return try {
runtime.getActiveSessionState(peerPubkeyHex.lowercase()) != null
@ -133,7 +204,9 @@ class NdrNostrService(
}
}
@Synchronized
fun activeSessionStateJson(peerPubkeyHex: String): String? {
if (!NdrFeatureGate.isEnabled()) return null
val runtime = sessionManager ?: return null
return try {
runtime.getActiveSessionState(peerPubkeyHex.lowercase())
@ -142,68 +215,102 @@ class NdrNostrService(
}
}
@Synchronized
fun sendIfPossible(text: String, peerPubkeyHex: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
val runtime = sessionManager ?: return false
if (!hasActiveSession(peerPubkeyHex)) return false
return try {
val outboundEventIds = runtime.sendText(peerPubkeyHex.lowercase(), text, null)
synchronized(this) {
drainAndApplyPubSubEventsLocked()
}
if (outboundEventIds.isEmpty()) {
Log.d(TAG, "NDR send queued no relay publish for ${peerPubkeyHex.take(8)}...")
}
runtime.sendText(peerPubkeyHex.lowercase(), text, null)
drainAndApplyPubSubEventsLocked()
true
} catch (t: Throwable) {
Log.d(TAG, "NDR send failed: ${t.message}")
synchronized(this) {
drainAndApplyPubSubEventsLocked()
}
} catch (_: Throwable) {
Log.d(TAG, "NDR send failed")
drainAndApplyPubSubEventsLocked()
false
}
}
@Synchronized
fun processOutOfBandEventJson(
eventJson: String,
expectedPeerPubkeyHex: String? = null
): NdrOutOfBandProcessResult {
if (!NdrFeatureGate.isEnabled()) {
return NdrOutOfBandProcessResult(emptyList())
}
val runtime = sessionManager ?: return NdrOutOfBandProcessResult(emptyList())
val trimmedPayload = eventJson.trim()
val expectedPeer = expectedPeerPubkeyHex
?.lowercase()
?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }
?: return NdrOutOfBandProcessResult(emptyList())
if (!NdrInputPolicy.isWithinEncodedEventLimit(trimmedPayload)) {
return NdrOutOfBandProcessResult(emptyList())
}
val inboundInvite = parseOutOfBandInvite(trimmedPayload)
val parsedEventPubkeyHex = NostrEvent.fromJsonString(trimmedPayload)?.pubkey?.lowercase()
val parsedEvent = NostrEvent.fromJsonString(trimmedPayload)
if (parsedEvent != null && !NdrInputPolicy.hasBoundedTags(parsedEvent)) {
return NdrOutOfBandProcessResult(emptyList())
}
var acceptResult: NdrAcceptInviteResult? = null
// Invite payloads carry an owner identity we can bind to the authenticated
// favorite. Other OOB responses may be gift wraps whose outer pubkey is
// intentionally ephemeral, so they must not be compared to the owner key.
val claimedPeer = inboundInvite?.ownerPubkeyHex
if (claimedPeer != null && claimedPeer != expectedPeer) {
Log.w(TAG, "Rejecting OOB event with an authenticated-owner mismatch")
return NdrOutOfBandProcessResult(emptyList())
}
if (inboundInvite != null) {
if (pendingInvitesByOwner.containsKey(expectedPeer)) {
return NdrOutOfBandProcessResult(
outboundPayloads = emptyList(),
sessionLookupPubkeyHex = expectedPeer
)
}
}
try {
when {
inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON -> {
acceptResult = runtime.acceptInviteFromEventJson(trimmedPayload, expectedPeer)
pendingInvitesByOwner.remove(expectedPeer)
}
inboundInvite?.transport == OutOfBandInviteTransport.URL || !trimmedPayload.startsWith("{") -> {
inboundInvite?.transport == OutOfBandInviteTransport.URL -> {
acceptResult = runtime.acceptInviteFromUrl(trimmedPayload, expectedPeer)
pendingInvitesByOwner.remove(expectedPeer)
}
parsedEvent?.kind == NostrKind.GIFT_WRAP -> {
runtime.processOutOfBandResponse(trimmedPayload, expectedPeer)
}
else -> {
runtime.processEvent(trimmedPayload)
Log.w(TAG, "Rejecting non-handshake OOB payload")
}
}
} catch (t: Throwable) {
Log.d(TAG, "Ignoring OOB event: ${t.message}")
} catch (t: NdrSessionNotReadyException) {
if (inboundInvite != null) {
pendingInvitesByOwner[expectedPeer] = PendingOutOfBandInvite(
payload = trimmedPayload,
transport = inboundInvite.transport
)
Log.d(TAG, "Retaining invite until its signed device roster arrives")
} else {
Log.d(TAG, "OOB session is not ready")
}
} catch (_: Throwable) {
Log.d(TAG, "Ignoring invalid OOB event")
}
val outOfBandPublishes = synchronized(this) {
val outOfBandPublishes =
drainAndApplyPubSubEventsLocked(collectOutOfBandPublishes = true)
}
val sessionLookupPubkeyHex = acceptResult?.ownerPubkeyHex?.lowercase()
?: expectedPeer?.takeIf { hasActiveSession(it) }
?: parsedEventPubkeyHex
?: inboundInvite?.senderPubkeyHex
?: expectedPeer
if (inboundInvite != null &&
inboundInvite.transport == OutOfBandInviteTransport.EVENT_JSON &&
outOfBandPublishes.isEmpty() &&
sessionLookupPubkeyHex != null &&
hasActiveSession(sessionLookupPubkeyHex)
) {
preferredInviteOobPayload()?.let {
@ -220,17 +327,61 @@ class NdrNostrService(
)
}
@Synchronized
fun processInboundRelayEvent(event: NostrEvent) {
if (!NdrFeatureGate.isEnabled()) return
val runtime = sessionManager ?: return
if (event.kind != NDR_MESSAGE_KIND && event.kind != NDR_APP_KEYS_KIND) return
if (!NdrInputPolicy.hasBoundedTags(event)) return
val eventJson = event.toJsonString()
if (!NdrInputPolicy.isWithinEncodedEventLimit(eventJson)) return
try {
runtime.processEvent(event.toJsonString())
} catch (t: Throwable) {
Log.d(TAG, "Ignoring relay event ${event.id.take(8)}...: ${t.message}")
runtime.processEvent(eventJson)
} catch (_: Throwable) {
Log.d(TAG, "Ignoring invalid NDR relay event")
drainAndApplyPubSubEventsLocked()
return
}
synchronized(this) {
drainAndApplyPubSubEventsLocked()
retryPendingInviteForRelayEventLocked(runtime, event)
drainAndApplyPubSubEventsLocked()
}
private fun retryPendingInviteForRelayEventLocked(
runtime: NdrSessionManager,
event: NostrEvent
) {
if (event.kind != NDR_APP_KEYS_KIND ||
event.tags.none { tag ->
tag.size >= 2 && tag[0] == "type" && tag[1] == NDR_APP_KEYS_TYPE
}
) return
val ownerPubkeyHex = event.pubkey.lowercase()
val pending = pendingInvitesByOwner[ownerPubkeyHex] ?: return
try {
when (pending.transport) {
OutOfBandInviteTransport.EVENT_JSON ->
runtime.acceptInviteFromEventJson(pending.payload, ownerPubkeyHex)
OutOfBandInviteTransport.URL ->
runtime.acceptInviteFromUrl(pending.payload, ownerPubkeyHex)
}
pendingInvitesByOwner.remove(ownerPubkeyHex)
} catch (_: NdrSessionNotReadyException) {
return
} catch (_: Throwable) {
pendingInvitesByOwner.remove(ownerPubkeyHex)
Log.w(TAG, "Dropping retained invite after roster validation failed")
return
}
val outboundPayloads =
drainAndApplyPubSubEventsLocked(collectOutOfBandPublishes = true)
if (outboundPayloads.isNotEmpty()) {
if (NdrFeatureGate.isEnabled()) {
onOutOfBandPayloadsReady?.invoke(ownerPubkeyHex, outboundPayloads)
}
}
}
@ -243,8 +394,8 @@ class NdrNostrService(
val events = try {
runtime.drainEvents()
} catch (t: Throwable) {
Log.e(TAG, "Failed to drain NDR events: ${t.message}")
} catch (_: Throwable) {
Log.e(TAG, "Failed to drain NDR events")
return emptyList()
}
@ -307,28 +458,65 @@ class NdrNostrService(
}
"decrypted_message" -> {
if (!NdrFeatureGate.isEnabled()) return
val content = event.content ?: return
val senderPubkeyHex = event.senderPubkeyHex ?: return
onDecryptedMessage?.invoke(
NdrDecryptedMessage(
content = content,
senderPubkeyHex = senderPubkeyHex.lowercase(),
eventId = event.eventId,
innerEventJson = content.takeIf { it.trimStart().startsWith("{") }
)
if (!NdrInputPolicy.isWithinEncodedEventLimit(content) ||
!NdrInputPolicy.isPubkeyHex(senderPubkeyHex) ||
event.senderDevicePubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
event.conversationOwnerPubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
event.eventId?.let(NdrInputPolicy::isEventIdHex) == false
) return
val message = NdrDecryptedMessage(
content = content,
senderPubkeyHex = senderPubkeyHex.lowercase(),
senderDevicePubkeyHex = event.senderDevicePubkeyHex?.lowercase(),
conversationOwnerPubkeyHex = event.conversationOwnerPubkeyHex?.lowercase(),
eventId = event.eventId?.lowercase()
)
val callback = onDecryptedMessage
if (callback != null) {
callback(message)
} else {
if (bufferedDecryptedMessages.size >= MAX_BUFFERED_DECRYPTED_MESSAGES) {
bufferedDecryptedMessages.removeFirst()
}
bufferedDecryptedMessages.addLast(message)
}
}
}
}
@Synchronized
private fun teardownLocked() {
activeSubIds.forEach { relayManager.unsubscribe(it) }
activeSubIds.forEach { subId ->
runCatching { relayManager.unsubscribe(subId) }
.onFailure { Log.w(TAG, "Failed to unsubscribe NDR relay filter") }
}
activeSubIds.clear()
pendingInvitesByOwner.clear()
bufferedDecryptedMessages.clear()
cachedInviteEventJson = null
configuredForPubkeyHex = null
sessionManager?.destroy()
val runtime = sessionManager
sessionManager = null
runCatching { runtime?.destroy() }
.onFailure { Log.w(TAG, "Failed to destroy NDR runtime") }
}
@Synchronized
fun resetForPanic(): Boolean {
onDecryptedMessage = null
onOutOfBandPayloadsReady = null
teardownLocked()
val storageCleared = runCatching(storageResetter)
.onFailure { Log.w(TAG, "Failed to delete NDR storage") }
.isSuccess
val deviceIdCleared = runCatching(deviceIdResetter)
.onFailure { Log.w(TAG, "Failed to reset NDR device id") }
.isSuccess
panicResetBlocked = !(storageCleared && deviceIdCleared)
return !panicResetBlocked
}
private fun isDoubleRatchetInviteEvent(event: NostrEvent): Boolean {
@ -347,13 +535,14 @@ class NdrNostrService(
}
private data class ParsedOutOfBandInvite(
val senderPubkeyHex: String,
val ownerPubkeyHex: String,
val transport: OutOfBandInviteTransport
)
fun outOfBandSenderPubkeyHex(payload: String): String? {
return parseOutOfBandInvite(payload.trim())?.senderPubkeyHex
}
private data class PendingOutOfBandInvite(
val payload: String,
val transport: OutOfBandInviteTransport
)
private fun parseOutOfBandInvite(payload: String): ParsedOutOfBandInvite? {
if (payload.isBlank()) return null
@ -361,23 +550,18 @@ class NdrNostrService(
if (payload.startsWith("{")) {
val event = NostrEvent.fromJsonString(payload) ?: return null
if (!isDoubleRatchetInviteEvent(event)) return null
val ownerPubkeyHex = inviteOwnerResolver(payload)?.lowercase() ?: return null
return ParsedOutOfBandInvite(
senderPubkeyHex = event.pubkey.lowercase(),
ownerPubkeyHex = ownerPubkeyHex,
transport = OutOfBandInviteTransport.EVENT_JSON
)
}
return try {
val invite = uniffi.ndr_ffi.InviteHandle.fromUrl(payload)
invite.use {
ParsedOutOfBandInvite(
senderPubkeyHex = it.`getInviterPubkeyHex`().lowercase(),
transport = OutOfBandInviteTransport.URL
)
}
} catch (_: Throwable) {
null
}
val ownerPubkeyHex = inviteOwnerResolver(payload)?.lowercase() ?: return null
return ParsedOutOfBandInvite(
ownerPubkeyHex = ownerPubkeyHex,
transport = OutOfBandInviteTransport.URL
)
}
private fun preferredInviteOobPayload(): String? {
@ -455,7 +639,11 @@ private class UniffiNdrSessionManager(
eventJson: String,
ownerPubkeyHintHex: String?
): NdrAcceptInviteResult {
val result = handle.`acceptInviteFromEventJson`(eventJson, ownerPubkeyHintHex)
val result = try {
handle.`acceptInviteFromEventJson`(eventJson, ownerPubkeyHintHex)
} catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) {
throw NdrSessionNotReadyException(t.message, t)
}
return NdrAcceptInviteResult(
ownerPubkeyHex = result.ownerPubkeyHex,
inviterDevicePubkeyHex = result.inviterDevicePubkeyHex,
@ -468,7 +656,11 @@ private class UniffiNdrSessionManager(
inviteUrl: String,
ownerPubkeyHintHex: String?
): NdrAcceptInviteResult {
val result = handle.`acceptInviteFromUrl`(inviteUrl, ownerPubkeyHintHex)
val result = try {
handle.`acceptInviteFromUrl`(inviteUrl, ownerPubkeyHintHex)
} catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) {
throw NdrSessionNotReadyException(t.message, t)
}
return NdrAcceptInviteResult(
ownerPubkeyHex = result.ownerPubkeyHex,
inviterDevicePubkeyHex = result.inviterDevicePubkeyHex,
@ -481,6 +673,13 @@ private class UniffiNdrSessionManager(
handle.`processEvent`(eventJson)
}
override fun processOutOfBandResponse(
eventJson: String,
expectedOwnerPubkeyHex: String
) {
handle.`processOutOfBandResponse`(eventJson, expectedOwnerPubkeyHex)
}
override fun drainEvents(): List<NdrPubSubEvent> {
return handle.`drainEvents`().map {
NdrPubSubEvent(
@ -489,6 +688,8 @@ private class UniffiNdrSessionManager(
filterJson = it.filterJson,
eventJson = it.eventJson,
senderPubkeyHex = it.senderPubkeyHex,
senderDevicePubkeyHex = it.senderDevicePubkeyHex,
conversationOwnerPubkeyHex = it.conversationOwnerPubkeyHex,
content = it.content,
eventId = it.eventId
)

View File

@ -6,6 +6,8 @@ data class NdrPubSubEvent(
val filterJson: String? = null,
val eventJson: String? = null,
val senderPubkeyHex: String? = null,
val senderDevicePubkeyHex: String? = null,
val conversationOwnerPubkeyHex: String? = null,
val content: String? = null,
val eventId: String? = null
)
@ -13,9 +15,52 @@ data class NdrPubSubEvent(
data class NdrDecryptedMessage(
val content: String,
val senderPubkeyHex: String,
val eventId: String? = null,
val innerEventJson: String? = null
)
val senderDevicePubkeyHex: String? = null,
val conversationOwnerPubkeyHex: String? = null,
val eventId: String? = null
) {
/**
* Iris sets [conversationOwnerPubkeyHex] on a local-sibling copy. The
* authenticated author remains [senderPubkeyHex], while app routing must
* use the remote conversation owner.
*/
val conversationPubkeyHex: String
get() = conversationOwnerPubkeyHex ?: senderPubkeyHex
val isLocalSiblingCopy: Boolean
get() = conversationOwnerPubkeyHex != null
fun isAttributedToLocalAccount(localAccountPubkeyHex: String): Boolean =
!isLocalSiblingCopy ||
senderPubkeyHex.equals(localAccountPubkeyHex, ignoreCase = true)
}
internal object NdrInputPolicy {
const val MAX_ENCODED_EVENT_BYTES = 64 * 1024
private const val MAX_EVENT_TAGS = 64
private const val MAX_EVENT_TAG_VALUES = 16
private const val MAX_EVENT_TAG_VALUE_BYTES = 1024
private val HEX_32 = Regex("^[0-9a-fA-F]{64}$")
fun isPubkeyHex(value: String): Boolean = HEX_32.matches(value)
fun isEventIdHex(value: String): Boolean = HEX_32.matches(value)
fun isWithinEncodedEventLimit(value: String): Boolean =
value.length <= MAX_ENCODED_EVENT_BYTES &&
value.toByteArray(Charsets.UTF_8).size <= MAX_ENCODED_EVENT_BYTES
fun hasBoundedTags(event: NostrEvent): Boolean {
if (event.tags.size > MAX_EVENT_TAGS) return false
return event.tags.all { tag ->
tag.size <= MAX_EVENT_TAG_VALUES &&
tag.all { value ->
value.length <= MAX_EVENT_TAG_VALUE_BYTES &&
value.toByteArray(Charsets.UTF_8).size <= MAX_EVENT_TAG_VALUE_BYTES
}
}
}
}
data class NdrAcceptInviteResult(
val ownerPubkeyHex: String,
@ -29,6 +74,11 @@ data class NdrOutOfBandProcessResult(
val sessionLookupPubkeyHex: String? = null
)
class NdrSessionNotReadyException(
message: String?,
cause: Throwable? = null
) : Exception(message, cause)
interface NdrRelayManager {
fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit)
fun unsubscribe(id: String)
@ -40,6 +90,7 @@ interface NdrSessionManager {
fun acceptInviteFromEventJson(eventJson: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
fun acceptInviteFromUrl(inviteUrl: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
fun processEvent(eventJson: String)
fun processOutOfBandResponse(eventJson: String, expectedOwnerPubkeyHex: String)
fun drainEvents(): List<NdrPubSubEvent>
fun getActiveSessionState(peerPubkeyHex: String): String?
fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong? = null): List<String>

View File

@ -0,0 +1,130 @@
package com.bitchat.android.nostr
import androidx.annotation.MainThread
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Session-scoped consent gate for nearby location notes.
*
* Merely rendering the mesh timeline must not open a building-precision Nostr
* subscription. A subscription is eligible only after an explicit reveal and
* while the app is foregrounded and at least one nearby-notes surface is active.
*/
@MainThread
class NearbyNotesController internal constructor(
private val subscribe: (String) -> Unit,
private val unsubscribe: () -> Unit,
) {
private val _revealed = MutableStateFlow(false)
val revealed: StateFlow<Boolean> = _revealed.asStateFlow()
private var activeHolders = 0
private var locationEnabled = false
private var locationAuthorized = false
private var appForeground = false
private var buildingGeohash: String? = null
private var subscribedGeohash: String? = null
/**
* Unlocks nearby notes for this process session. Deactivation deliberately
* does not reset consent, matching the iOS privacy model.
*/
fun reveal() {
if (_revealed.value) return
_revealed.value = true
reconcileSubscription()
}
/** Holds the subscription while a nearby-notes surface is visible. */
fun activate() {
activeHolders += 1
reconcileSubscription()
}
/** Releases a matching [activate] hold and unsubscribes after the last one. */
fun deactivate() {
activeHolders = (activeHolders - 1).coerceAtLeast(0)
reconcileSubscription()
}
/** Closes the live subscription whenever the process leaves the foreground. */
fun updateAppForeground(isForeground: Boolean) {
appForeground = isForeground
reconcileSubscription()
}
/**
* Updates the privacy-sensitive inputs independently of view activation.
* Permission revocation, location disable, or loss of the building cell
* immediately closes any live subscription.
*/
fun updateAvailability(
locationEnabled: Boolean,
locationAuthorized: Boolean,
buildingGeohash: String?,
) {
this.locationEnabled = locationEnabled
this.locationAuthorized = locationAuthorized
this.buildingGeohash = buildingGeohash
?.trim()
?.lowercase()
?.takeIf { it.isNotEmpty() }
reconcileSubscription()
}
fun offersRevealHint(): Boolean =
!_revealed.value &&
locationEnabled &&
locationAuthorized &&
buildingGeohash != null
private fun reconcileSubscription() {
val target = buildingGeohash.takeIf {
activeHolders > 0 &&
appForeground &&
_revealed.value &&
locationEnabled &&
locationAuthorized
}
if (subscribedGeohash != null && subscribedGeohash != target) {
unsubscribe()
subscribedGeohash = null
}
if (target != null && subscribedGeohash == null) {
subscribe(target)
subscribedGeohash = target
}
}
companion object {
val shared: NearbyNotesController by lazy {
val manager = LocationNotesManager.getInstance()
NearbyNotesController(
subscribe = manager::setGeohash,
unsubscribe = manager::stop,
)
}
}
}
/**
* Building precision is location-notes precision and remains private before a
* reveal. Explicit bookmarks remain eligible because saving one is itself an
* intentional location act.
*/
internal fun geohashesForSampling(
availableChannels: List<GeohashChannel>,
bookmarks: Collection<String>,
notesRevealed: Boolean,
): List<String> = buildSet {
availableChannels
.filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING }
.mapTo(this) { it.geohash }
addAll(bookmarks)
}.toList()

View File

@ -2,19 +2,27 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.NdrFeatureGate
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.MeshDelegateHandler
import com.bitchat.android.ui.PrivateChatManager
import com.bitchat.android.ui.PrivateMessageOrigin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
@ -32,12 +40,16 @@ class NostrDirectMessageHandler(
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
private val ndrService by lazy { NdrNostrService.getInstance(application) }
private val ndrAccountEpochs = NdrAccountEpochGuard()
private val ndrReceiveJobLock = Any()
private var ndrReceiveJob: Job = SupervisorJob(scope.coroutineContext[Job])
// Simple event deduplication
private val processedIds = ArrayDeque<String>()
private val seen = HashSet<String>()
private val max = 2000
@Synchronized
private fun dedupe(id: String): Boolean {
if (seen.contains(id)) return true
seen.add(id)
@ -50,9 +62,37 @@ class NostrDirectMessageHandler(
}
fun configureDoubleRatchet(identity: NostrIdentity) {
if (!NdrFeatureGate.isEnabled()) {
invalidateDoubleRatchetAccount()
ndrService.onDecryptedMessage = null
return
}
val epoch = ndrAccountEpochs.begin(identity.publicKeyHex)
val receiveJob = synchronized(ndrReceiveJobLock) {
ndrReceiveJob.cancel()
SupervisorJob(scope.coroutineContext[Job]).also { ndrReceiveJob = it }
}
// A prior account's callback must not consume and discard pending
// deliveries while the replacement runtime is initialized.
ndrService.onDecryptedMessage = null
ndrService.configureIfNeeded(identity)
ndrService.onDecryptedMessage = { message ->
onDoubleRatchetMessage(message, identity)
ndrService.onDecryptedMessage = callback@{ message ->
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
return@callback
}
val currentIdentity =
NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return@callback
if (!currentIdentity.publicKeyHex.equals(epoch.accountPubkeyHex, ignoreCase = true)) {
return@callback
}
onDoubleRatchetMessage(message, currentIdentity, epoch, receiveJob)
}
}
fun invalidateDoubleRatchetAccount() {
ndrAccountEpochs.invalidate()
synchronized(ndrReceiveJobLock) {
ndrReceiveJob.cancel()
}
}
@ -70,7 +110,8 @@ class NostrDirectMessageHandler(
return@launch
}
val (content, senderPubkey, rumorTimestamp) = decryptResult
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
val senderPubkey = rawSenderPubkey.lowercase()
// If sender is blocked for geohash contexts, drop any events from this pubkey
// Applies to both geohash DMs (geohash != "") and account DMs (geohash == "")
@ -83,37 +124,59 @@ class NostrDirectMessageHandler(
recipientIdentity = identity
)
} catch (e: Exception) {
Log.e(TAG, "onGiftWrap error: ${e.message}")
} catch (_: Exception) {
Log.e(TAG, "Failed to process gift wrap")
}
}
}
private fun onDoubleRatchetMessage(message: NdrDecryptedMessage, identity: NostrIdentity) {
scope.launch(Dispatchers.Default) {
private fun onDoubleRatchetMessage(
message: NdrDecryptedMessage,
identity: NostrIdentity,
epoch: NdrAccountEpoch,
receiveJob: Job
) {
scope.launch(Dispatchers.Default + receiveJob) {
try {
val innerEvent = message.innerEventJson?.let(NostrEvent::fromJsonString)
val dedupeId = innerEvent?.id
?: message.eventId
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
return@launch
}
val dedupeId = message.eventId
?: "${message.senderPubkeyHex}:${message.content.hashCode()}"
if (dedupe(dedupeId)) return@launch
val senderPubkeyHex = innerEvent?.pubkey ?: message.senderPubkeyHex
if (dataManager.isGeohashUserBlocked(senderPubkeyHex)) return@launch
var duplicate = false
if (!ndrAccountEpochs.runIfCurrent(epoch) {
duplicate = dedupe(dedupeId)
}
) return@launch
if (duplicate) return@launch
Log.d(
TAG,
"Received NDR message event=${message.eventId ?: "unknown"} sender=${senderPubkeyHex.take(8)}..."
)
// iris-chat-rs returns a v1 unsigned kind-14 pairwise rumor.
// Bind that rumor to the ratchet-authenticated owner before
// allowing any inner fields into the application.
val applicationMessage =
NdrApplicationMessageDecoder.decode(message) ?: return@launch
val senderPubkey = message.senderPubkeyHex.lowercase()
if (!message.isAttributedToLocalAccount(identity.publicKeyHex)) {
return@launch
}
val conversationPubkey = message.conversationPubkeyHex.lowercase()
if (dataManager.isGeohashUserBlocked(conversationPubkey)) return@launch
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
return@launch
}
processEmbeddedBitChatContent(
content = innerEvent?.content ?: message.content,
senderPubkey = senderPubkeyHex,
timestamp = innerEvent?.let { Date(it.createdAt * 1000L) } ?: Date(),
content = applicationMessage.content,
senderPubkey = senderPubkey,
conversationPubkey = conversationPubkey,
isLocalSiblingCopy = message.isLocalSiblingCopy,
timestamp = Date(applicationMessage.timestampMs),
geohash = "",
recipientIdentity = identity
recipientIdentity = identity,
ndrEpoch = epoch
)
} catch (e: Exception) {
Log.e(TAG, "onDoubleRatchetMessage error: ${e.message}")
} catch (_: Exception) {
Log.e(TAG, "Failed to process double-ratchet message")
}
}
}
@ -123,67 +186,126 @@ class NostrDirectMessageHandler(
senderPubkey: String,
timestamp: Date,
geohash: String,
recipientIdentity: NostrIdentity
recipientIdentity: NostrIdentity,
conversationPubkey: String = senderPubkey,
isLocalSiblingCopy: Boolean = false,
ndrEpoch: NdrAccountEpoch? = null
) {
if (!content.startsWith("bitchat1:")) {
Log.d(TAG, "Ignoring non-embedded Nostr DM content")
return
}
if (!content.startsWith("bitchat1:")) return
val base64Content = content.removePrefix("bitchat1:")
val packetData = base64URLDecode(base64Content) ?: run {
Log.w(TAG, "Failed to base64url-decode embedded BitChat packet")
return
}
val packet = BitchatPacket.fromBinaryData(packetData) ?: run {
Log.w(TAG, "Failed to decode embedded BitChat packet bytes=${packetData.size}")
return
}
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) {
Log.d(TAG, "Ignoring embedded BitChat packet type=${packet.type}")
return
}
val packetData = base64URLDecode(content.removePrefix("bitchat1:")) ?: return
val packet = BitchatPacket.fromBinaryData(packetData) ?: return
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return
val noisePayload = NoisePayload.decode(packet.payload) ?: run {
Log.w(TAG, "Failed to decode embedded Noise payload bytes=${packet.payload.size}")
return
}
val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey)
GeohashAliasRegistry.put(convKey, senderPubkey)
val noisePayload = NoisePayload.decode(packet.payload) ?: return
val convKey = "nostr_${conversationPubkey.take(16)}"
if (!runIfNdrEpochCurrent(ndrEpoch) {
repo.putNostrKeyMapping(convKey, conversationPubkey)
GeohashAliasRegistry.put(convKey, conversationPubkey)
if (geohash.isNotEmpty()) {
repo.setConversationGeohash(convKey, geohash)
GeohashConversationRegistry.set(convKey, geohash)
val cached = repo.getCachedNickname(senderPubkey)
if (cached == null) {
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
repo.cacheNickname(senderPubkey, base)
if (geohash.isNotEmpty()) {
repo.setConversationGeohash(convKey, geohash)
GeohashConversationRegistry.set(convKey, geohash)
if (repo.getCachedNickname(conversationPubkey) == null) {
val base =
repo.displayNameForNostrPubkeyUI(conversationPubkey).substringBefore("#")
repo.cacheNickname(conversationPubkey, base)
}
repo.updateParticipant(geohash, conversationPubkey, timestamp)
}
}
repo.updateParticipant(geohash, senderPubkey, timestamp)
}
) return
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
processNoisePayload(noisePayload, convKey, senderNickname, timestamp, senderPubkey, recipientIdentity)
processNoisePayload(
payload = noisePayload,
conversationID = ContactDirectory.canonicalConversationId(convKey),
senderNickname = repo.displayNameForNostrPubkeyUI(conversationPubkey),
timestamp = timestamp,
senderPubkey = senderPubkey,
conversationPubkey = conversationPubkey,
recipientIdentity = recipientIdentity,
allowAccountNdr = geohash.isEmpty(),
isLocalSiblingCopy = isLocalSiblingCopy,
ndrEpoch = ndrEpoch
)
}
private suspend fun processNoisePayload(
payload: NoisePayload,
convKey: String,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
recipientIdentity: NostrIdentity
conversationPubkey: String,
recipientIdentity: NostrIdentity,
allowAccountNdr: Boolean,
isLocalSiblingCopy: Boolean,
ndrEpoch: NdrAccountEpoch? = null
) {
if (!isNdrEpochCurrent(ndrEpoch)) return
when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: run {
Log.w(TAG, "Failed to decode Nostr private message TLV bytes=${payload.data.size}")
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
if (isLocalSiblingCopy) {
// A sibling device authored this message on our account.
// Show it as sent in the remote peer's thread, without
// acknowledging it, marking it unread, or notifying.
if (FavoriteControlMessage.parse(pm.content) != null) return
val message = BitchatMessage(
id = pm.messageID,
sender = state.getNicknameValue(),
content = pm.content,
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname =
repo.displayNameForNostrPubkeyUI(conversationPubkey),
// Existing Android conversation insertion routes from
// senderPeerID. Use the remote conversation ID here;
// sender nickname and status keep the row outgoing.
senderPeerID = conversationID,
deliveryStatus = DeliveryStatus.Sent
)
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
}
return
}
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
if (!isNdrEpochCurrent(ndrEpoch)) return
handleFavoriteControl(
favoriteControl,
conversationID,
senderNickname,
timestamp,
senderPubkey,
ndrEpoch
)
if (!runIfNdrEpochCurrent(ndrEpoch) {
if (!seenStore.hasDelivered(pm.messageID)) {
sendDeliveryAck(
pm.messageID,
senderPubkey,
recipientIdentity,
allowAccountNdr
)
seenStore.markDelivered(pm.messageID)
}
}
) return
return
}
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
Log.d(TAG, "Processing embedded Nostr private message")
val message = BitchatMessage(
id = pm.messageID,
@ -193,95 +315,236 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
senderPeerID = conversationID,
deliveryStatus =
DeliveryStatus.Delivered(to = state.getNicknameValue(), at = Date())
)
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
val suppressUnread = seenStore.hasRead(pm.messageID)
var messageAccepted = false
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
}
if (!seenStore.hasDelivered(pm.messageID)) {
val nostrTransport = NostrTransport.getInstance(application)
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
if (targetPeerID != null) {
nostrTransport.sendDeliveryAck(pm.messageID, targetPeerID)
} else {
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
}
seenStore.markDelivered(pm.messageID)
}
if (isViewing && !suppressUnread) {
val nostrTransport = NostrTransport.getInstance(application)
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
if (targetPeerID != null) {
nostrTransport.sendReadReceipt(
com.bitchat.android.model.ReadReceipt(pm.messageID),
targetPeerID
runIfNdrEpochCurrent(ndrEpoch) {
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = suppressUnread,
origin = PrivateMessageOrigin.NOSTR
)
} else {
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
messageAccepted = true
}
seenStore.markRead(pm.messageID)
}
if (!messageAccepted) return
if (!runIfNdrEpochCurrent(ndrEpoch) {
if (!seenStore.hasDelivered(pm.messageID)) {
sendDeliveryAck(
pm.messageID,
senderPubkey,
recipientIdentity,
allowAccountNdr
)
seenStore.markDelivered(pm.messageID)
}
if (isViewing && !suppressUnread) {
val nostrTransport = NostrTransport.getInstance(application)
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
.takeIf { allowAccountNdr }
if (targetPeerID != null) {
nostrTransport.sendReadReceipt(
com.bitchat.android.model.ReadReceipt(pm.messageID),
targetPeerID
)
} else {
nostrTransport.sendReadReceiptGeohash(
pm.messageID,
senderPubkey,
recipientIdentity
)
}
seenStore.markRead(pm.messageID)
}
}
) return
}
NoisePayloadType.DELIVERED -> {
if (isLocalSiblingCopy) return
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
runIfNdrEpochCurrent(ndrEpoch) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
}
}
}
NoisePayloadType.READ_RECEIPT -> {
if (isLocalSiblingCopy) return
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
runIfNdrEpochCurrent(ndrEpoch) {
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
}
}
}
NoisePayloadType.FILE_TRANSFER -> {
if (isLocalSiblingCopy) return
// Properly handle encrypted file transfer
val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
val message = BitchatMessage(
id = uniqueMsgId,
sender = senderNickname,
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
var message: BitchatMessage? = null
if (!runIfNdrEpochCurrent(ndrEpoch) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath =
com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
message = BitchatMessage(
id = uniqueMsgId,
sender = senderNickname,
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID
)
}
) return
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
runIfNdrEpochCurrent(ndrEpoch) {
message?.let {
privateChatManager.handleIncomingPrivateMessage(
message = it,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
}
}
}
} else {
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.NDR_EVENT -> Unit // Ignore transport-control payloads in Nostr direct messages
NoisePayloadType.PEER_STATE,
NoisePayloadType.NDR_EVENT -> Unit // Transport controls never arrive inside relay DMs.
}
}
private fun isNdrEpochCurrent(epoch: NdrAccountEpoch?): Boolean =
epoch == null ||
(NdrFeatureGate.isEnabled() && ndrAccountEpochs.isCurrent(epoch))
private fun runIfNdrEpochCurrent(
epoch: NdrAccountEpoch?,
mutation: () -> Unit
): Boolean {
if (epoch == null) {
mutation()
return true
}
if (!NdrFeatureGate.isEnabled()) return false
return ndrAccountEpochs.runIfCurrent(epoch, mutation)
}
private fun sendDeliveryAck(
messageId: String,
senderPubkey: String,
recipientIdentity: NostrIdentity,
allowAccountNdr: Boolean
) {
val nostrTransport = NostrTransport.getInstance(application)
val targetPeerID = resolvePeerIDForNostr(senderPubkey)
.takeIf { allowAccountNdr }
if (targetPeerID != null) {
nostrTransport.sendDeliveryAck(messageId, targetPeerID)
} else {
nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, recipientIdentity)
}
}
private fun resolvePeerIDForNostr(senderPubkey: String): String? {
return try {
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
favorites.findPeerIDForNostrPubkey(senderPubkey)
?: favorites.findNoiseKey(senderPubkey)?.joinToString("") { "%02x".format(it) }
FavoritesPersistenceService.shared.findPeerIDForNostrPubkey(senderPubkey)
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
?.let(ContactIdentityResolver::noiseKeyHex)
} catch (_: Exception) {
null
}
}
private suspend fun handleFavoriteControl(
control: FavoriteControlMessage,
conversationID: String,
senderNickname: String,
timestamp: Date,
senderPubkey: String,
ndrEpoch: NdrAccountEpoch? = null
) {
try {
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
}
var systemMessage: BitchatMessage? = null
if (!runIfNdrEpochCurrent(ndrEpoch) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
noiseKey,
control.isFavorite
)
senderNpub?.let {
FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it)
}
val targetConversationID =
ContactDirectory.canonicalConversationId(conversationID)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val displayName = relationship
?.peerNickname
?.takeUnless { it.equals("Unknown", ignoreCase = true) }
?: senderNickname
val guidance = if (control.isFavorite) {
if (relationship?.isFavorite == true) {
" - mutual! You can continue DMs via Nostr when out of mesh."
} else {
" - favorite back to continue DMs later."
}
} else {
". DMs over Nostr will pause unless you both favorite again."
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
systemMessage = BitchatMessage(
sender = "system",
content = "$displayName $action you$guidance",
timestamp = timestamp,
isRelay = false,
isPrivate = true,
senderPeerID = targetConversationID
)
}
) return
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
systemMessage?.let {
privateChatManager.handleIncomingPrivateMessage(
message = it,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
}
}
private fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")

View File

@ -2,15 +2,16 @@ package com.bitchat.android.nostr
import android.util.Base64
import android.util.Log
import com.bitchat.android.mesh.MeshPacketUtils
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.services.ContactIdentityResolver
import java.util.*
/**
* BitChat-over-Nostr Adapter
* Direct port from iOS implementation for 100% compatibility
*/
object NostrEmbeddedBitChat {
@ -173,26 +174,12 @@ object NostrEmbeddedBitChat {
* Normalize recipient peer ID (matches iOS implementation)
*/
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
try {
val maybeData = hexStringToByteArray(recipientPeerID)
return when (maybeData.size) {
32 -> {
// Treat as Noise static public key; derive peerID from fingerprint
// For now, return first 8 bytes as hex (simplified)
maybeData.take(8).joinToString("") { "%02x".format(it) }
}
8 -> {
// Already an 8-byte peer ID
recipientPeerID
}
else -> {
// Fallback: return as-is (expecting 16 hex chars)
recipientPeerID
}
}
} catch (e: Exception) {
// Fallback: return as-is
return recipientPeerID
val clean = recipientPeerID.trim().lowercase()
return when {
ContactIdentityResolver.isNoiseKeyHex(clean) ->
ContactIdentityResolver.peerIdForNoiseKeyHex(clean) ?: clean
ContactIdentityResolver.isMeshPeerId(clean) -> clean
else -> recipientPeerID
}
}
@ -210,25 +197,6 @@ object NostrEmbeddedBitChat {
/**
* Convert hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
if (hexString.length % 2 != 0) {
return ByteArray(8) // Return 8-byte array filled with zeros
}
val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
private fun hexStringToByteArray(hexString: String): ByteArray =
MeshPacketUtils.hexStringToByteArray(hexString)
}

View File

@ -42,6 +42,33 @@ data class NostrFilter(
limit = limit
)
}
/**
* Create filter for geohash-scoped chat messages only (kind 20000).
* Low-volume; kept subscribed in the background so messages keep arriving.
*/
fun geohashMessages(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
)
}
/**
* Create filter for geohash-scoped presence heartbeats only (kind 20001).
* High-volume firehose (every participant rebroadcasts ~every 60s); only used
* to refresh the participant list, so it is paused while backgrounded.
*/
fun geohashPresence(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.GEOHASH_PRESENCE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
)
}
/**
* Create filter for text notes from specific authors

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import java.security.MessageDigest
import java.security.SecureRandom
@ -131,8 +132,7 @@ object NostrIdentityBridge {
* Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing
* if the candidate is not a valid secp256k1 private key.
*
* Direct port from iOS implementation for 100% compatibility
* OPTIMIZED: Cached for UI responsiveness
* Cached for UI responsiveness.
*/
fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity {
// Check cache first for immediate response
@ -188,12 +188,8 @@ object NostrIdentityBridge {
* Associate a Nostr identity with a Noise public key (for favorites)
*/
fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) {
val stateManager = SecureIdentityStateManager(context)
// We'll use the existing signing key storage mechanism for associations
// For now, we'll store this as a preference since it's just for favorites mapping
// In a full implementation, you'd want a proper association storage system
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateNostrPublicKey(noisePublicKey, nostrPubkey)
Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key")
}
@ -201,9 +197,8 @@ object NostrIdentityBridge {
* Get Nostr public key associated with a Noise public key
*/
fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? {
// This would need proper implementation based on your favorites storage system
// For now, return null as we don't have the full association system
return null
FavoritesPersistenceService.initialize(context)
return FavoritesPersistenceService.shared.findNostrPubkey(noisePublicKey)
}
/**

View File

@ -73,14 +73,24 @@ object NostrProtocol {
}
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
return null
}
// 2. Open the seal
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
?: run {
Log.w(TAG, "❌ Failed to open seal")
return null
}
if (seal.pubkey != rumor.pubkey) {
Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey")
return null
}
Log.v(TAG, "Successfully opened seal")
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
@ -227,7 +237,7 @@ object NostrProtocol {
content = encrypted
)
// Sign with the ephemeral key
// NIP-17 requires the seal to be signed by the sender identity key.
return seal.sign(senderPrivateKey)
}

View File

@ -27,9 +27,18 @@ class NostrSubscriptionManager(
}
}
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
scope.launch {
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit)
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
}
}
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
scope.launch {
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
}
}

View File

@ -2,15 +2,18 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NdrFeatureGate
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Minimal Nostr transport for offline sending
* Direct port from iOS NostrTransport for 100% compatibility
* Nostr transport for offline private messages and receipts.
*/
class NostrTransport(
private val context: Context,
@ -54,11 +57,7 @@ class NostrTransport(
) {
transportScope.launch {
try {
// Resolve favorite by full noise key or by short peerID fallback
var recipientNostrPubkey: String? = null
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for peerID: $to")
@ -73,14 +72,13 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
Log.e(TAG, "NostrTransport: failed to normalize recipient key")
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
return@launch
}
val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex)
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
val recipientPeerIDForEmbed = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findPeerIDForNostrPubkey(recipientNostrPubkey)
@ -101,7 +99,7 @@ class NostrTransport(
Log.e(TAG, "NostrTransport: failed to embed PM packet")
return@launch
}
sendWrappedMessage(
content = embedded,
fallbackRecipientHex = recipientHex,
@ -138,10 +136,7 @@ class NostrTransport(
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
val recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
@ -158,7 +153,7 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
scheduleNextReadAck()
return@launch
@ -177,7 +172,7 @@ class NostrTransport(
scheduleNextReadAck()
return@launch
}
sendWrappedMessage(
content = ack,
fallbackRecipientHex = recipientHex,
@ -205,10 +200,7 @@ class NostrTransport(
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
@ -221,11 +213,11 @@ class NostrTransport(
return@launch
}
val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}"
val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub)
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -242,7 +234,7 @@ class NostrTransport(
Log.e(TAG, "NostrTransport: failed to embed favorite notification")
return@launch
}
sendWrappedMessage(
content = embedded,
fallbackRecipientHex = recipientHex,
@ -259,10 +251,7 @@ class NostrTransport(
fun sendDeliveryAck(messageID: String, to: String) {
transportScope.launch {
try {
var recipientNostrPubkey: String? = null
// Try to resolve from favorites persistence service
recipientNostrPubkey = resolveNostrPublicKey(to)
val recipientNostrPubkey = resolveNostrPublicKey(to)
if (recipientNostrPubkey == null) {
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
@ -277,7 +266,7 @@ class NostrTransport(
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
val recipientHex = normalizeNostrPubkeyToHex(recipientNostrPubkey)
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
if (recipientHex == null) {
return@launch
}
@ -294,7 +283,7 @@ class NostrTransport(
Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack")
return@launch
}
sendWrappedMessage(
content = ack,
fallbackRecipientHex = recipientHex,
@ -444,22 +433,59 @@ class NostrTransport(
}
// MARK: - Helper Methods
private fun sendWrappedMessage(
content: String,
fallbackRecipientHex: String,
senderIdentity: NostrIdentity,
ndrRecipientHex: String = fallbackRecipientHex
): Boolean {
if (NdrFeatureGate.isEnabled()) {
ndrService.configureIfNeeded(senderIdentity)
if (ndrService.sendIfPossible(content, ndrRecipientHex)) {
return true
}
}
NostrProtocol.createPrivateMessage(
content = content,
recipientPubkey = fallbackRecipientHex,
senderIdentity = senderIdentity
).forEach { event ->
NostrRelayManager.registerPendingGiftWrap(event.id)
NostrRelayManager.getInstance(context).sendEvent(event)
}
return false
}
private fun resolveNdrRecipientHex(target: String, fallbackRecipientHex: String): String {
if (!NdrFeatureGate.isEnabled()) return fallbackRecipientHex
return try {
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
val relationship = favorites.getFavoriteStatus(target) ?: return fallbackRecipientHex
favorites.findNdrSessionPubkeyHex(relationship.peerNoisePublicKey)
?: fallbackRecipientHex
} catch (_: Exception) {
fallbackRecipientHex
}
}
/**
* Resolve Nostr public key for a peer ID
*/
private fun resolveNostrPublicKey(peerID: String): String? {
try {
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
ContactDirectory.resolve(peerID).nostrPubkey?.let { return it }
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
// 2) Legacy path: resolve by noise public key association
val noiseKey = hexStringToByteArray(peerID)
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peerID) ?: return null
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
}
// 3) Prefix match on noiseHex from 16-hex peerID
if (peerID.length == 16) {
if (ContactIdentityResolver.isMeshPeerId(peerID)) {
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
return fallbackStatus?.peerNostrPublicKey
}
@ -470,77 +496,6 @@ class NostrTransport(
return null
}
}
private fun sendWrappedMessage(
content: String,
fallbackRecipientHex: String,
senderIdentity: NostrIdentity,
ndrRecipientHex: String = fallbackRecipientHex
): Boolean {
ndrService.configureIfNeeded(senderIdentity)
if (ndrService.sendIfPossible(content, ndrRecipientHex)) {
Log.d(TAG, "NostrTransport: sent via NDR to ${ndrRecipientHex.take(8)}...")
return true
}
val giftWraps = NostrProtocol.createPrivateMessage(
content = content,
recipientPubkey = fallbackRecipientHex,
senderIdentity = senderIdentity
)
giftWraps.forEach { event ->
Log.d(TAG, "NostrTransport: sending fallback giftWrap id=${event.id.take(16)}...")
NostrRelayManager.getInstance(context).sendEvent(event)
}
return false
}
private fun resolveNdrRecipientHex(target: String, fallbackRecipientHex: String): String {
val favoriteRelationship = resolveFavoriteRelationship(target) ?: return fallbackRecipientHex
return com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findNdrSessionPubkeyHex(favoriteRelationship.peerNoisePublicKey)
?: fallbackRecipientHex
}
private fun resolveFavoriteRelationship(target: String): com.bitchat.android.favorites.FavoriteRelationship? {
val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared
return try {
when {
target.length == 16 && target.matches(Regex("^[0-9a-fA-F]+$")) -> {
favorites.getFavoriteStatus(target)
}
target.length == 64 && target.matches(Regex("^[0-9a-fA-F]+$")) -> {
favorites.getFavoriteStatus(hexStringToByteArray(target))
}
else -> null
}
} catch (_: Exception) {
null
}
}
private fun normalizeNostrPubkeyToHex(npubOrHex: String): String? {
return try {
if (npubOrHex.startsWith("npub1")) {
val (hrp, data) = Bech32.decode(npubOrHex)
if (hrp != "npub") return null
data.joinToString("") { "%02x".format(it) }
} else {
npubOrHex.lowercase()
}
} catch (_: Exception) {
null
}
}
/**
* Convert full hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun cleanup() {
transportScope.cancel()

View File

@ -26,6 +26,7 @@ fun BluetoothCheckScreen(
status: BluetoothStatus,
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
isLoading: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
@ -39,13 +40,15 @@ fun BluetoothCheckScreen(
BluetoothDisabledContent(
onEnableBluetooth = onEnableBluetooth,
onRetry = onRetry,
onSkip = onSkip,
colorScheme = colorScheme,
isLoading = isLoading
)
}
BluetoothStatus.NOT_SUPPORTED -> {
BluetoothNotSupportedContent(
colorScheme = colorScheme
colorScheme = colorScheme,
onSkip = onSkip
)
}
BluetoothStatus.ENABLED -> {
@ -61,6 +64,7 @@ fun BluetoothCheckScreen(
private fun BluetoothDisabledContent(
onEnableBluetooth: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit,
colorScheme: ColorScheme,
isLoading: Boolean
) {
@ -77,7 +81,7 @@ private fun BluetoothDisabledContent(
)
Text(
text = stringResource(R.string.bluetooth_required),
text = stringResource(R.string.bluetooth_recommended),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@ -141,20 +145,17 @@ private fun BluetoothDisabledContent(
)
}
//Since we are automatically checking bluetooth state -- commented
// OutlinedButton(
// onClick = onRetry,
// modifier = Modifier.fillMaxWidth()
// ) {
// Text(
// text = "Check Again",
// style = MaterialTheme.typography.bodyMedium.copy(
// fontFamily = FontFamily.Monospace
// ),
// modifier = Modifier.padding(vertical = 4.dp)
// )
// }
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.skip),
style = MaterialTheme.typography.labelLarge.copy(
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
)
}
}
}
}
@ -162,7 +163,8 @@ private fun BluetoothDisabledContent(
@Composable
private fun BluetoothNotSupportedContent(
colorScheme: ColorScheme
colorScheme: ColorScheme,
onSkip: () -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent(
textAlign = TextAlign.Center
)
}
Button(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.secondary
)
) {
Text(text = stringResource(R.string.continue_btn))
}
}
}

View File

@ -271,6 +271,7 @@ class OnboardingCoordinator(
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NEARBY_WIFI") -> "Nearby WiFi Devices (for WiFi Aware)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
}

View File

@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
PermissionType.OTHER -> Icons.Filled.Settings
}

View File

@ -23,6 +23,42 @@ class PermissionManager(private val context: Context) {
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private fun shouldRequireWifiAwarePermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
val enabled = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
} catch (_: Exception) {
false
}
if (!enabled) return false
return try {
com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
} catch (_: Exception) {
false
}
}
/**
* Runtime permissions for the WiFi Aware transport, version-gated because neither exists
* at minSdk 26 requesting an unknown permission comes back permanently denied.
*
* ACCESS_LOCAL_NETWORK is defensive: Android 17 gates local network access, and the
* transport reaches peers over link-local IPv6 sockets. It is granted separately from
* NEARBY_WIFI_DEVICES but shares its permission group, so the two prompt only once.
*/
fun wifiAwarePermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
// API 37 == Android 17; no named VERSION_CODES constant is available yet.
if (Build.VERSION.SDK_INT >= 37) {
permissions.add(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
return permissions
}
/**
* Check if this is the first time the user is launching the app
*/
@ -69,6 +105,11 @@ class PermissionManager(private val context: Context) {
Manifest.permission.ACCESS_FINE_LOCATION
))
// WiFi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
if (shouldRequireWifiAwarePermission()) {
permissions.addAll(wifiAwarePermissions())
}
// Notification permission intentionally excluded to keep it optional
return permissions
@ -209,6 +250,20 @@ class PermissionManager(private val context: Context) {
)
)
// WiFi Aware category (Android 13+)
if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = wifiAwarePermissions()
categories.add(
PermissionCategory(
type = PermissionType.WIFI_AWARE,
description = "Enable WiFi Aware to discover and connect to nearby bitchat users over WiFi.",
permissions = wifiAwarePermissions,
isGranted = wifiAwarePermissions.all { isPermissionGranted(it) },
systemDescription = "Allow bitchat to discover nearby WiFi devices"
)
)
}
if (needsBackgroundLocationPermission()) {
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
categories.add(
@ -308,6 +363,7 @@ enum class PermissionType(val nameValue: String) {
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
WIFI_AWARE("WiFi Aware"),
BATTERY_OPTIMIZATION("Battery Optimization"),
OTHER("Other")
}

View File

@ -36,7 +36,7 @@ object SpecialRecipients {
/**
* Binary packet format - 100% backward compatible with iOS version
*
* Header (13 bytes for v1, 15 bytes for v2):
* Header (14 bytes for v1, 16 bytes for v2):
* - Version: 1 byte
* - Type: 1 byte
* - TTL: 1 byte
@ -79,8 +79,8 @@ data class BitchatPacket(
ttl = ttl
)
fun toBinaryData(): ByteArray? {
return BinaryProtocol.encode(this)
fun toBinaryData(padding: Boolean = true): ByteArray? {
return BinaryProtocol.encode(this, padding = padding)
}
/**
@ -178,8 +178,8 @@ data class BitchatPacket(
* Binary Protocol implementation - supports v1 and v2, backward compatible
*/
object BinaryProtocol {
private const val HEADER_SIZE_V1 = 13
private const val HEADER_SIZE_V2 = 15
private const val HEADER_SIZE_V1 = 14
private const val HEADER_SIZE_V2 = 16
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
@ -198,7 +198,7 @@ object BinaryProtocol {
}
}
fun encode(packet: BitchatPacket): ByteArray? {
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
try {
// Try to compress payload if beneficial
var payload = packet.payload
@ -255,6 +255,10 @@ object BinaryProtocol {
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
return null
}
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
@ -306,11 +310,13 @@ object BinaryProtocol {
buffer.rewind()
buffer.get(result)
// Apply padding to standard block sizes for traffic analysis resistance
val optimalSize = MessagePadding.optimalBlockSize(result.size)
val paddedData = MessagePadding.pad(result, optimalSize)
// Apply padding if requested (iOS-compatible: selective padding for privacy)
if (padding) {
val optimalSize = MessagePadding.optimalBlockSize(result.size)
return MessagePadding.pad(result, optimalSize)
}
return paddedData
return result
} catch (e: Exception) {
Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}")

View File

@ -3,7 +3,7 @@ package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
@ -39,7 +39,7 @@ object AppShutdownCoordinator {
fun requestFullShutdownAndKill(
app: Application,
mesh: BluetoothMeshService?,
mesh: MeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit

View File

@ -38,24 +38,20 @@ class MeshForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
// On API >= 26, avoid background-service start restrictions by using startForegroundService
// only when we can actually post a notification (Android 13+ requires runtime notif permission)
val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true)
val hasNotifPerm = hasNotificationPermissionStatic(context)
// Only launch as an FGS when onStartCommand can promote immediately.
val shouldStartForeground = shouldStartAsForeground(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (bgEnabled && hasNotifPerm) {
if (shouldStartForeground) {
context.startForegroundService(intent)
} else {
// Do not attempt to start a background service from headless context without notif permission
// or when background is disabled, to avoid BackgroundServiceStartNotAllowedException.
android.util.Log.i(
"MeshForegroundService",
"Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)"
"Not starting service on API>=26 (shouldStartForeground=$shouldStartForeground)"
)
}
} else {
if (bgEnabled) {
if (MeshServicePreferences.isBackgroundEnabled(true)) {
context.startService(intent)
} else {
android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)")
@ -69,12 +65,10 @@ class MeshForegroundService : Service() {
*/
fun onNotificationPermissionGranted(context: Context) {
// If background is enabled and permission now granted, start/promo service
val hasNotifPerm = hasNotificationPermissionStatic(context)
if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return
if (!shouldStartAsForeground(context)) return
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Safe now that we can show a notification
context.startForegroundService(intent)
} else {
context.startService(intent)
@ -115,6 +109,8 @@ class MeshForegroundService : Service() {
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
get() = MeshServiceHolder.unifiedMeshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@ -134,6 +130,7 @@ class MeshForegroundService : Service() {
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -147,7 +144,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
@ -165,7 +162,7 @@ class MeshForegroundService : Service() {
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
mesh = meshService,
mesh = unifiedMeshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
@ -178,7 +175,7 @@ class MeshForegroundService : Service() {
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
val n = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(n)
isInForeground = true
} else {
@ -193,7 +190,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
val notification = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(notification)
isInForeground = true
}
@ -226,6 +223,21 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() {
if (isShuttingDown) return
try {
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
}
val bleEnabled = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true)
} catch (_: Exception) {
true
}
if (!bleEnabled) {
try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { }
return
}
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
@ -241,7 +253,7 @@ class MeshForegroundService : Service() {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0
val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
@ -261,6 +273,14 @@ class MeshForegroundService : Service() {
return hasBluetoothPermissions() && hasNotificationPermission()
}
private fun getUnifiedActivePeerCount(): Int {
return try {
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
} catch (_: Exception) {
0
}
}
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&

View File

@ -2,6 +2,10 @@ package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.UnifiedMeshService
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.GossipSyncManager
/**
* Process-wide holder to share a single BluetoothMeshService instance
@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
@Volatile
var sharedGossipSyncManager: GossipSyncManager? = null
private set
private val activeGossipOwners = mutableSetOf<String>()
@Synchronized
fun setGossipManager(
mgr: GossipSyncManager,
signer: (BitchatPacket) -> BitchatPacket
) {
val previous = sharedGossipSyncManager
if (previous !== mgr) {
try { previous?.stop() } catch (_: Exception) { }
}
sharedGossipSyncManager = mgr
mgr.delegate = TransportGossipDelegate(signer)
if (activeGossipOwners.isNotEmpty()) {
mgr.start()
}
}
@Synchronized
fun startSharedGossip(owner: String) {
val wasIdle = activeGossipOwners.isEmpty()
activeGossipOwners.add(owner)
if (wasIdle) {
sharedGossipSyncManager?.start()
}
}
@Synchronized
fun stopSharedGossip(owner: String) {
activeGossipOwners.remove(owner)
if (activeGossipOwners.isEmpty()) {
sharedGossipSyncManager?.stop()
}
}
private class TransportGossipDelegate(
private val signer: (BitchatPacket) -> BitchatPacket
) : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
return signer(packet)
}
}
@Volatile
var meshService: BluetoothMeshService? = null
private set
@Volatile
var unifiedMeshService: UnifiedMeshService? = null
private set
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
@ -31,18 +94,35 @@ object MeshServiceHolder {
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
unifiedMeshService = null
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
unifiedMeshService = null
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
unifiedMeshService = null
return created
}
@Synchronized
fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
val bluetooth = getOrCreate(context)
val existing = unifiedMeshService
if (existing != null) {
existing.refreshDelegates()
return existing
}
val created = UnifiedMeshService(context.applicationContext, bluetooth)
unifiedMeshService = created
android.util.Log.i(TAG, "Created new UnifiedMeshService")
return created
}
@ -50,11 +130,16 @@ object MeshServiceHolder {
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
unifiedMeshService = null
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
sharedGossipSyncManager = null
activeGossipOwners.clear()
meshService = null
unifiedMeshService = null
}
}

View File

@ -0,0 +1,192 @@
package com.bitchat.android.service
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.toHexString
import java.security.MessageDigest
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
/**
* Central bridge for routing packets between different transport layers
* (e.g., Bluetooth LE <-> Wi-Fi Aware).
*
* Allows a packet received on one transport to be seamlessly relayed
* to all other active transports, effectively bridging separate meshes.
*/
object TransportBridgeService {
private const val TAG = "TransportBridgeService"
private const val MAX_SEEN_PACKETS = 4096
private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L
/**
* Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
* to receive bridged packets.
*/
interface TransportLayer {
/**
* Send a packet out via this transport.
*/
fun send(packet: RoutedPacket)
/**
* Send a packet to a specific peer via this transport (optional).
*/
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
}
private val transports = ConcurrentHashMap<String, TransportLayer>()
private val seenPackets = Collections.synchronizedMap(
object : LinkedHashMap<String, Long>(MAX_SEEN_PACKETS, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > MAX_SEEN_PACKETS
}
}
)
/**
* Register a transport layer to receive bridged packets.
* @param id Unique identifier (e.g., "BLE", "WIFI")
* @param layer The transport implementation
*/
fun register(id: String, layer: TransportLayer) {
Log.i(TAG, "Registering transport layer: $id")
transports[id] = layer
}
/**
* Unregister a transport layer.
*/
fun unregister(id: String) {
Log.i(TAG, "Unregistering transport layer: $id")
transports.remove(id)
}
/**
* Broadcast a packet from a specific source transport to ALL other registered transports.
*
* @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE").
* The packet will NOT be sent back to this source.
* @param packet The packet to bridge.
*/
fun broadcast(sourceId: String, packet: RoutedPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
// Prepared private-media fragments must remain the admitted plan when
// crossing transports, but relay TTL still has to advance on every
// hop. TTL is excluded from the signature and does not affect size.
val forwarded = packet.copy(
packet = forwardedPacket,
preparedPackets = packet.preparedPackets?.map { prepared ->
prepared.copy(ttl = forwardedPacket.ttl)
}
)
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
targets.forEach { (id, layer) ->
try {
layer.send(forwarded)
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
}
}
}
/**
* Send a packet to a specific peer across all other transports.
*/
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
targets.forEach { (id, layer) ->
try {
layer.sendToPeer(peerID, forwardedPacket)
} catch (e: Exception) {
Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
}
}
}
/**
* Send a locally originated packet to every active transport without applying relay TTL
* handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
* intentionally zero on the first radio hop.
*/
fun broadcastFromLocal(packet: RoutedPacket) {
val targets = transports.toMap()
if (targets.isEmpty()) return
targets.forEach { (id, layer) ->
try {
layer.send(packet)
} catch (e: Exception) {
Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
}
}
}
/**
* Send a locally originated packet directly to a peer on every active transport.
*/
fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
val targets = transports.toMap()
if (targets.isEmpty()) return
targets.forEach { (id, layer) ->
try {
layer.sendToPeer(peerID, packet)
} catch (e: Exception) {
Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
}
}
}
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
return null
}
val key = "$kind:${logicalPacketId(packet)}"
val now = System.currentTimeMillis()
synchronized(seenPackets) {
pruneSeen(now)
val previous = seenPackets[key]
if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
return null
}
seenPackets[key] = now
}
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
}
private fun pruneSeen(now: Long) {
val iterator = seenPackets.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value > SEEN_PACKET_TTL_MS) {
iterator.remove()
}
}
}
private fun logicalPacketId(packet: BitchatPacket): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(packet.type.toByte())
digest.update(packet.senderID)
packet.recipientID?.let { digest.update(it) }
digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
digest.update(packet.payload)
packet.route?.forEach { digest.update(it) }
packet.signature?.let { digest.update(it) }
return digest.digest().toHexString()
}
}

View File

@ -13,6 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
private val seenPublicMessageKeys = mutableSetOf<String>()
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
@ -30,13 +34,63 @@ object AppStateStore {
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) {
_peers.value = ids
synchronized(this) {
_peers.value = ids.distinct()
}
}
fun setTransportPeers(transportId: String, ids: List<String>) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
publishTransportPeersLocked()
}
}
fun clearTransportPeers(transportId: String) {
synchronized(this) {
peerIdsByTransport.remove(transportId)
publishTransportPeersLocked()
}
}
private fun publishTransportPeersLocked() {
_peers.value = peerIdsByTransport.values
.asSequence()
.flatten()
.distinct()
.toList()
}
/**
* Record the set of direct (single-hop) peers reachable over a given transport. Each transport
* (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every
* transport can gossip the same complete neighbor list under our shared node identity.
*/
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
synchronized(this) {
directPeerIdsByTransport[transportId] = ids.toSet()
}
}
fun clearTransportDirectPeers(transportId: String) {
synchronized(this) {
directPeerIdsByTransport.remove(transportId)
}
}
/** Union of direct peers across all transports. */
fun getDirectPeers(): Set<String> {
synchronized(this) {
return directPeerIdsByTransport.values.flatten().toSet()
}
}
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
val publicKey = publicMessageKey(msg)
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
seenMessageIds.add(msg.id)
seenPublicMessageKeys.add(publicKey)
_publicMessages.value = _publicMessages.value + msg
}
}
@ -45,10 +99,11 @@ object AppStateStore {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
val list = (map[conversationID] ?: emptyList()) + msg
map[conversationID] = list
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
@ -85,6 +140,57 @@ object AppStateStore {
}
}
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
if (keysToMerge.isEmpty()) return
synchronized(this) {
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val map = _privateMessages.value.toMutableMap()
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
val targetIds = targetList.map { it.id }.toMutableSet()
var changed = false
keysToMerge.distinct().forEach { key ->
val canonicalKey = ContactDirectory.canonicalConversationId(key)
if (canonicalKey == targetConversationID) {
val messages = map.remove(key)
if (messages != null) {
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) targetList.add(message)
}
}
return@forEach
}
if (key == targetConversationID) return@forEach
val messages = map.remove(key) ?: return@forEach
changed = true
messages.forEach { message ->
if (targetIds.add(message.id)) {
targetList.add(message)
}
}
}
if (changed) {
if (targetList.isEmpty()) {
map.remove(targetConversationID)
} else {
map[targetConversationID] = targetList
}
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
}
}
}
fun canonicalizePrivateChats() {
synchronized(this) {
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
if (canonical != _privateMessages.value) {
_privateMessages.value = canonical
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
@ -100,10 +206,24 @@ object AppStateStore {
fun clear() {
synchronized(this) {
seenMessageIds.clear()
seenPublicMessageKeys.clear()
peerIdsByTransport.clear()
directPeerIdsByTransport.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
}
}
private fun publicMessageKey(msg: BitchatMessage): String {
val sender = msg.senderPeerID ?: msg.sender
return listOf(
sender,
msg.timestamp.time.toString(),
msg.type.name,
msg.channel ?: "",
msg.content
).joinToString("\u001F")
}
}

View File

@ -0,0 +1,201 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.GeohashAliasRegistry
object ContactDirectory {
data class ContactResolution(
val conversationID: String,
val meshPeerID: String?,
val noisePublicKey: ByteArray?,
val nostrPubkey: String?,
val displayName: String?,
val isMutualFavorite: Boolean
) {
val noiseKeyHex: String? get() = noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
}
@Volatile
private var appContext: Context? = null
@Volatile
private var meshProvider: (() -> MeshService?)? = null
fun initialize(context: Context, meshProvider: () -> MeshService?) {
appContext = context.applicationContext
this.meshProvider = meshProvider
}
fun isContactConversationID(value: String): Boolean =
ContactIdentityResolver.isContactConversationId(value)
fun canonicalConversationId(peerOrConversationID: String): String {
val value = peerOrConversationID.trim()
if (ContactIdentityResolver.isContactConversationId(value)) return value.lowercase()
noiseKeyForAlias(value)?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
favoriteForMeshPeerID(value)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
if (ContactIdentityResolver.isNostrAlias(value)) {
nostrPubkeyHexForAlias(value)?.let { pubHex ->
findFavoriteByNostrHex(pubHex)?.peerNoisePublicKey?.let {
return ContactIdentityResolver.contactConversationIdForNoiseKey(it)
}
}
}
return value
}
fun resolve(peerOrConversationID: String): ContactResolution {
val conversationID = canonicalConversationId(peerOrConversationID)
val contactFingerprint = ContactIdentityResolver.fingerprintFromContactConversationId(conversationID)
val favorite = contactFingerprint?.let { findFavoriteByFingerprint(it) }
val noiseKey = when {
favorite != null -> favorite.peerNoisePublicKey
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
else -> null
}
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
?: peerOrConversationID.takeIf { ContactIdentityResolver.isMeshPeerId(it) && isMeshPeerConnected(it) }
return ContactResolution(
conversationID = conversationID,
meshPeerID = liveMeshPeerID,
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
nostrPubkey = favorite?.peerNostrPublicKey,
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
isMutualFavorite = favorite?.isMutual == true
)
}
fun canonicalizePrivateChats(
chats: Map<String, List<BitchatMessage>>
): Map<String, List<BitchatMessage>> {
if (chats.isEmpty()) return chats
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
chats.forEach { (key, messages) ->
val canonical = canonicalConversationId(key)
val list = merged.getOrPut(canonical) { mutableListOf() }
list.addAll(messages)
}
return merged.mapValues { (_, messages) ->
messages
.distinctBy { it.id }
.sortedWith(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
}
}
fun aliasesForConversation(peerOrConversationID: String): Set<String> {
val resolution = resolve(peerOrConversationID)
val aliases = mutableSetOf<String>()
aliases.add(peerOrConversationID)
aliases.add(resolution.conversationID)
resolution.meshPeerID?.let { aliases.add(it) }
resolution.noiseKeyHex?.let { aliases.add(it) }
resolution.nostrPubkey
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
?.let { aliases.add(it) }
return aliases
}
private fun noiseKeyForAlias(value: String): ByteArray? {
if (ContactIdentityResolver.isNoiseKeyHex(value)) {
return ContactIdentityResolver.bytesFromHex(value)
}
if (ContactIdentityResolver.isMeshPeerId(value)) {
meshProvider?.invoke()?.getPeerInfo(value)?.noisePublicKey?.let { return it }
cachedNoiseKey(value)?.let { return it }
}
return null
}
private fun cachedNoiseKey(peerID: String): ByteArray? {
val context = appContext ?: return null
return try {
SecureIdentityStateManager(context)
.getCachedNoiseKey(peerID)
?.let { ContactIdentityResolver.bytesFromHex(it) }
} catch (_: Exception) {
null
}
}
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
private fun findFavoriteByFingerprint(fingerprint: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
ContactIdentityResolver.fingerprintHex(it.peerNoisePublicKey).equals(fingerprint, ignoreCase = true)
}
} catch (_: Exception) {
null
}
private fun findFavoriteByNostrHex(pubHex: String): FavoriteRelationship? =
try {
FavoritesPersistenceService.shared.getAllRelationships().firstOrNull {
it.peerNostrPublicKey
?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) }
?.equals(pubHex, ignoreCase = true) == true
}
} catch (_: Exception) {
null
}
private fun nostrPubkeyHexForAlias(alias: String): String? {
GeohashAliasRegistry.get(alias)?.let { return it.lowercase() }
val prefix = alias.removePrefix("nostr_").removePrefix("nostr:")
if (prefix.isBlank()) return null
return try {
FavoritesPersistenceService.shared.getAllRelationships()
.asSequence()
.mapNotNull { it.peerNostrPublicKey?.let { npub -> ContactIdentityResolver.nostrPubkeyHex(npub) } }
.firstOrNull { it.startsWith(prefix, ignoreCase = true) }
} catch (_: Exception) {
null
}
}
private fun findLiveMeshPeerForFingerprint(fingerprint: String): String? {
val mesh = meshProvider?.invoke() ?: return null
return mesh.getPeerNicknames().keys.firstOrNull { peerID ->
val info = mesh.getPeerInfo(peerID)
val noiseKey = info?.noisePublicKey ?: cachedNoiseKey(peerID)
noiseKey != null &&
ContactIdentityResolver.fingerprintHex(noiseKey).equals(fingerprint, ignoreCase = true) &&
info?.isConnected == true
}
}
private fun isMeshPeerConnected(peerID: String): Boolean =
try {
meshProvider?.invoke()?.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}

View File

@ -0,0 +1,83 @@
package com.bitchat.android.services
import com.bitchat.android.nostr.Bech32
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
object ContactIdentityResolver {
private const val CONTACT_PREFIX = "contact_"
private val meshPeerIdRegex = Regex("^[0-9a-fA-F]{16}$")
private val noiseKeyRegex = Regex("^[0-9a-fA-F]{64}$")
private val fingerprintRegex = Regex("^[0-9a-fA-F]{64}$")
fun isMeshPeerId(value: String): Boolean = meshPeerIdRegex.matches(value)
fun isNoiseKeyHex(value: String): Boolean = noiseKeyRegex.matches(value)
fun isNostrAlias(value: String): Boolean =
value.startsWith("nostr_") || value.startsWith("nostr:")
fun noiseKeyHex(noisePublicKey: ByteArray): String = noisePublicKey.hexEncodedString()
fun fingerprintHex(noisePublicKey: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(noisePublicKey)
return digest.hexEncodedString()
}
fun contactConversationIdForNoiseKey(noisePublicKey: ByteArray): String =
CONTACT_PREFIX + fingerprintHex(noisePublicKey)
fun contactConversationIdForFingerprint(fingerprint: String): String? =
fingerprint
.takeIf { fingerprintRegex.matches(it) }
?.let { CONTACT_PREFIX + it.lowercase() }
fun isContactConversationId(value: String): Boolean =
value.startsWith(CONTACT_PREFIX) &&
fingerprintRegex.matches(value.removePrefix(CONTACT_PREFIX))
fun fingerprintFromContactConversationId(value: String): String? =
value
.takeIf { isContactConversationId(it) }
?.removePrefix(CONTACT_PREFIX)
?.lowercase()
fun peerIdForNoiseKey(noisePublicKey: ByteArray): String =
fingerprintHex(noisePublicKey).take(16)
fun peerIdForNoiseKeyHex(noiseKeyHex: String): String? =
bytesFromHex(noiseKeyHex)
?.takeIf { it.size == 32 }
?.let { peerIdForNoiseKey(it) }
fun bytesFromHex(hex: String): ByteArray? {
val clean = hex.trim()
if (clean.length % 2 != 0) return null
if (!clean.matches(Regex("^[0-9a-fA-F]+$"))) return null
return clean.dataFromHexString()
}
fun nostrPubkeyHex(value: String): String? {
val clean = value.trim()
if (clean.startsWith("npub1", ignoreCase = true)) {
return try {
val (hrp, data) = Bech32.decode(clean.lowercase())
if (hrp == "npub" && data.size == 32) data.hexEncodedString() else null
} catch (_: Exception) {
null
}
}
return clean
.takeIf { noiseKeyRegex.matches(it) }
?.lowercase()
}
fun npubFromHex(hex: String): String? =
bytesFromHex(hex)
?.takeIf { it.size == 32 }
?.let { Bech32.encode("npub", it) }
fun nostrAliasForPubkey(value: String): String? =
nostrPubkeyHex(value)?.let { "nostr_${it.take(16)}" }
}

View File

@ -8,28 +8,25 @@ object ConversationAliasResolver {
selectedPeerID: String,
connectedPeers: List<String>,
meshNoiseKeyForPeer: (String) -> ByteArray?,
meshHasPeer: (String) -> Boolean,
nostrPubHexForAlias: (String) -> String?,
findNoiseKeyForNostr: (String) -> ByteArray?
): String {
var peer = selectedPeerID
try {
if (peer.startsWith("nostr_")) {
if (ContactIdentityResolver.isNostrAlias(peer)) {
val pubHex = nostrPubHexForAlias(peer)
if (pubHex != null) {
val noiseKey = findNoiseKeyForNostr(pubHex)
if (noiseKey != null) {
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
// Prefer a connected mesh peer that matches this noise key
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
peer = meshPeer ?: noiseHex
}
}
} else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) {
// Peer is full noise key hex: upgrade to active mesh peer if available
val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} else if (ContactIdentityResolver.isNoiseKeyHex(peer)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(peer) ?: return peer
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
@ -38,7 +35,7 @@ object ConversationAliasResolver {
}
}
} catch (_: Exception) { /* no-op */ }
return peer
return ContactDirectory.canonicalConversationId(peer)
}
fun unifyChatsIntoPeer(
@ -47,11 +44,17 @@ object ConversationAliasResolver {
keysToMerge: List<String>
) {
if (keysToMerge.isEmpty()) return
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
val mergeKeys = (keysToMerge + targetPeerID)
.flatMap { ContactDirectory.aliasesForConversation(it) }
.distinct()
AppStateStore.unifyPrivateChatsIntoPeer(targetConversationID, mergeKeys)
val currentChats = state.getPrivateChatsValue().toMutableMap()
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
val targetList = currentChats[targetConversationID]?.toMutableList() ?: mutableListOf()
var didMerge = false
keysToMerge.distinct().forEach { key ->
if (key == targetPeerID) return@forEach
mergeKeys.forEach { key ->
if (key == targetConversationID) return@forEach
val list = currentChats[key]
if (!list.isNullOrEmpty()) {
targetList.addAll(list)
@ -60,27 +63,28 @@ object ConversationAliasResolver {
}
}
if (didMerge) {
// Preserve arrival order; do not sort by timestamp
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
currentChats[targetConversationID] = targetList
.distinctBy { it.id }
.sortedBy { it.timestamp.time }
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats))
// Move unread flags
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
var hadUnread = false
keysToMerge.forEach { key -> if (unread.remove(key)) hadUnread = true }
if (hadUnread) unread.add(targetPeerID)
mergeKeys.forEach { key -> if (unread.remove(key)) hadUnread = true }
if (hadUnread) unread.add(targetConversationID)
state.setUnreadPrivateMessages(unread)
// Switch selection if currently viewing an alias that got merged
val selected = state.getSelectedPrivateChatPeerValue()
if (selected != null && keysToMerge.contains(selected)) {
state.setSelectedPrivateChatPeer(targetPeerID)
if (selected != null && mergeKeys.contains(selected)) {
state.setSelectedPrivateChatPeer(targetConversationID)
}
// Switch sheet peer if currently viewing an alias that got merged
val sheetPeer = state.getPrivateChatSheetPeerValue()
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetPeerID)
if (sheetPeer != null && mergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetConversationID)
}
}
}

View File

@ -2,23 +2,31 @@ package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
/**
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
*/
class MessageRouter private constructor(
private val context: Context,
private var mesh: BluetoothMeshService,
private var mesh: MeshService,
private val nostr: NostrTransport
) {
enum class RouteResult {
MESH,
NOSTR,
QUEUED,
DROPPED
}
companion object {
private const val TAG = "MessageRouter"
@Volatile private var INSTANCE: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val nostr = NostrTransport.getInstance(context)
@ -46,54 +54,58 @@ class MessageRouter private constructor(
override fun onFavoriteChanged(noiseKeyHex: String) {
flushOutboxFor(noiseKeyHex)
// Also try 16-hex short id commonly used in UI if any client used that
val shortId = noiseKeyHex.take(16)
flushOutboxFor(shortId)
ContactIdentityResolver.peerIdForNoiseKeyHex(noiseKeyHex)?.let { flushOutboxFor(it) }
}
override fun onAllCleared() {
// Nothing special; leave queued items until routing becomes possible
}
}
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult {
val resolution = ContactDirectory.resolve(toPeerID)
val conversationID = resolution.conversationID
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}")
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
if (recipientHex != null) {
// Resolve the conversation's source geohash, so we can send from anywhere
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
// If repository knows the source geohash, pass it so NostrTransport derives the correct identity
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
return
return RouteResult.NOSTR
}
return RouteResult.DROPPED
}
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
if (hasMesh && hasEstablished) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID)
return RouteResult.NOSTR
} else {
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}")
val q = outbox.getOrPut(conversationID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
mesh.initiateNoiseHandshake(toPeerID)
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}")
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
return RouteResult.QUEUED
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) {
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
val nostrTarget = resolution.noiseKeyHex ?: toPeerID
if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing READ via mesh to ${meshTarget.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, meshTarget, mesh.getPeerNicknames()[meshTarget] ?: mesh.myPeerID)
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
nostr.sendReadReceipt(receipt, toPeerID)
nostr.sendReadReceipt(receipt, nostrTarget)
}
}
@ -107,50 +119,48 @@ class MessageRouter private constructor(
return
}
}
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
nostr.sendDeliveryAck(messageID, toPeerID)
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (!(meshTarget != null && (mesh.getPeerInfo(meshTarget)?.isConnected == true) && mesh.hasEstablishedSession(meshTarget))) {
nostr.sendDeliveryAck(messageID, resolution.noiseKeyHex ?: toPeerID)
}
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
val resolution = ContactDirectory.resolve(toPeerID)
val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) {
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
mesh.sendPrivateMessage(content, toPeerID, nickname)
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget
mesh.sendPrivateMessage(content, meshTarget, nickname, null)
} else {
nostr.sendFavoriteNotification(toPeerID, isFavorite)
nostr.sendFavoriteNotification(resolution.noiseKeyHex ?: toPeerID, isFavorite)
}
}
// Flush any queued messages for a specific peerID
fun flushOutboxFor(peerID: String) {
val queued = outbox[peerID] ?: return
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}")
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
iterator.remove()
continue
}
}
val canNostr = canSendViaNostr(peerID)
if (hasMesh) {
mesh.sendPrivateMessage(content, peerID, nickname, messageID)
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
iterator.remove()
} else if (canNostr) {
nostr.sendPrivateMessage(content, peerID, nickname, messageID)
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
iterator.remove()
}
}
if (queued.isEmpty()) {
outbox.remove(conversationID)
outbox.remove(peerID)
}
}
@ -162,14 +172,15 @@ class MessageRouter private constructor(
private fun canSendViaNostr(peerID: String): Boolean {
return try {
// Full Noise key hex
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKey = hexToBytes(peerID)
val resolution = ContactDirectory.resolve(peerID)
if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true
val target = resolution.noiseKeyHex ?: peerID
if (ContactIdentityResolver.isNoiseKeyHex(target)) {
val noiseKey = ContactIdentityResolver.bytesFromHex(target) ?: return false
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
// Ephemeral 16-hex mesh ID: resolve via prefix match in favorites
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} else if (ContactIdentityResolver.isMeshPeerId(target)) {
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(target)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else {
false
@ -177,19 +188,21 @@ class MessageRouter private constructor(
} catch (_: Exception) { false }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = if (hex.length % 2 == 0) hex else "0$hex"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun isConnected(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
private fun isReady(service: MeshService, peerID: String): Boolean {
return try {
mesh.getPeerNicknames().keys.firstOrNull { pid ->
val info = mesh.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
}
} catch (_: Exception) { null }
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
// Called when mesh peer list changes; attempt to flush any matching outbox entries
@ -197,7 +210,7 @@ class MessageRouter private constructor(
peers.forEach { pid ->
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
@ -207,7 +220,7 @@ class MessageRouter private constructor(
fun onSessionEstablished(peerID: String) {
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}

View File

@ -43,21 +43,29 @@ object GCSFilter {
targetFpr: Double
): Params {
val p = deriveP(targetFpr)
var nCap = estimateMaxElementsForSize(maxBytes, p)
val n = ids.size.coerceAtMost(nCap)
val selected = ids.take(n)
// Map to [0, M)
val m = (n.toLong() shl p)
val mapped = selected.map { id -> (h64(id) % m) }.sorted()
val nCap = estimateMaxElementsForSize(maxBytes, p)
var trimmedN = ids.size.coerceAtMost(nCap)
var finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L)
var selected = ids.take(trimmedN)
var mapped = selected.map { id ->
val v = h64(id) % finalM
if (v == 0L) 1L else v
}.distinct().sorted()
var encoded = encode(mapped, p)
// If estimate was too optimistic, trim until it fits
var trimmedN = n
while (encoded.size > maxBytes && trimmedN > 0) {
trimmedN = (trimmedN * 9) / 10 // drop 10%
val mapped2 = mapped.take(trimmedN)
encoded = encode(mapped2, p)
finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L)
selected = ids.take(trimmedN)
mapped = selected.map { id ->
val v = h64(id) % finalM
if (v == 0L) 1L else v
}.distinct().sorted()
encoded = encode(mapped, p)
}
val finalM = (trimmedN.toLong() shl p)
return Params(p = p, m = finalM, data = encoded)
}
@ -96,7 +104,7 @@ object GCSFilter {
return false
}
private fun h64(id16: ByteArray): Long {
internal fun h64(id16: ByteArray): Long {
val md = MessageDigest.getInstance("SHA-256")
md.update(id16)
val d = md.digest()

View File

@ -169,14 +169,9 @@ class GossipSyncManager(
// Decode GCS into sorted set for membership checks
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
fun mightContain(id: ByteArray): Boolean {
val v = (GCSFilter.run {
// reuse hashing method from GCSFilter
val md = java.security.MessageDigest.getInstance("SHA-256");
md.update(id); val d = md.digest();
var x = 0L; for (i in 0 until 8) { x = (x shl 8) or (d[i].toLong() and 0xFF) }
(x and 0x7fff_ffff_ffff_ffffL) % request.m
})
return GCSFilter.contains(sorted, v)
val v = GCSFilter.h64(id) % request.m
val nonZeroV = if (v == 0L) 1L else v
return GCSFilter.contains(sorted, nonZeroV)
}
// 1) Announcements: send latest per peerID if remote doesn't have them

View File

@ -1,40 +1,91 @@
package com.bitchat.android.ui
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.hotspot.HotspotActivity
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.util.UniversalApkManager
/**
* Feature row for displaying app capabilities
@ -452,6 +503,358 @@ fun AboutSheet(
}
} else null
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// === Prepare App for Sharing Section ===
val apkViewModel: ApkDownloadViewModel = viewModel()
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
val apkStatus = apkUiState.apkStatus
val downloadProgress = apkUiState.downloadProgress
// Handle one-shot effects (navigation, toasts, share intents)
LaunchedEffect(Unit) {
apkViewModel.onEvent(ApkUiEvent.CheckStatus)
apkViewModel.effect.collect { effect ->
when (effect) {
is ApkUiEffect.NavigateToHotspot -> {
val intent = Intent(context, HotspotActivity::class.java)
intent.putExtra(HotspotActivity.EXTRA_APK_PATH, effect.apkPath)
context.startActivity(intent)
}
is ApkUiEffect.ShareApk -> {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(Intent.EXTRA_STREAM, effect.apkUri)
clipData = android.content.ClipData.newRawUri("", effect.apkUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, effect.chooserTitle).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(chooser)
}
is ApkUiEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
// Prepare App for Sharing Row
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) {
apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (apkStatus is ApkPreparationStatus.Ready) {
Icons.Default.Share
} else {
Icons.Default.CloudDownload
},
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = if (apkStatus is ApkPreparationStatus.Ready) {
stringResource(R.string.prepare_apk_ready_title)
} else {
stringResource(R.string.prepare_apk_title)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = when (val status = apkStatus) {
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
is ApkPreparationStatus.Ready -> {
val source = if (status.source == UniversalApkManager.ApkSource.INSTALLED) {
stringResource(R.string.prepare_apk_source_installed)
} else {
stringResource(R.string.prepare_apk_source_github)
}
stringResource(R.string.prepare_apk_status_ready) +
"${status.version}${status.sizeMB} MB\n$source"
}
is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
is ApkPreparationStatus.Error -> status.message
},
style = MaterialTheme.typography.bodySmall,
color = when (apkStatus) {
is ApkPreparationStatus.Error -> colorScheme.error
is ApkPreparationStatus.Resumable -> colorScheme.primary
is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary
else -> colorScheme.onSurface.copy(alpha = 0.6f)
},
lineHeight = 16.sp
)
}
// Action buttons
when (apkStatus) {
is ApkPreparationStatus.Downloading -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
is ApkPreparationStatus.Ready -> {
if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
}
}
is ApkPreparationStatus.UpdateAvailable -> {
androidx.compose.material3.IconButton(
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
}
else -> {}
}
}
// Prepare Dialog
if (apkUiState.showPrepareDialog) {
val status = apkStatus
val sizeMB: Int? = when (status) {
is ApkPreparationStatus.NotDownloaded -> status.sizeMB
is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB
else -> null
}
AlertDialog(
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
title = {
Text(
text = if (status is ApkPreparationStatus.UpdateAvailable) {
stringResource(R.string.prepare_apk_update_dialog_title)
} else {
stringResource(R.string.prepare_apk_dialog_title)
},
style = MaterialTheme.typography.titleLarge
)
},
text = {
Text(
text = if (status is ApkPreparationStatus.UpdateAvailable) {
stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion)
} else if (sizeMB != null) {
stringResource(R.string.prepare_apk_dialog_message, sizeMB)
} else {
stringResource(R.string.prepare_apk_dialog_message_unknown_size)
},
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(onClick = {
apkViewModel.onEvent(ApkUiEvent.ConfirmDownload)
}) {
Text(stringResource(R.string.prepare_apk_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }) {
Text(stringResource(R.string.cancel))
}
},
containerColor = colorScheme.surface
)
}
// Delete Dialog
if (apkUiState.showDeleteDialog) {
val sizeMB = (apkStatus as? ApkPreparationStatus.Ready)?.sizeMB ?: 0
AlertDialog(
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) },
title = {
Text(
text = stringResource(R.string.prepare_apk_delete_confirm),
style = MaterialTheme.typography.titleLarge
)
},
text = {
Text(
text = stringResource(R.string.prepare_apk_delete_message, sizeMB),
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(
onClick = {
apkViewModel.onEvent(ApkUiEvent.ConfirmDelete)
},
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { apkViewModel.onEvent(ApkUiEvent.DismissDeleteDialog) }) {
Text(stringResource(R.string.cancel))
}
},
containerColor = colorScheme.surface
)
}
// Show sharing rows only when APK is ready
val canShareAPK = apkStatus is ApkPreparationStatus.Ready ||
apkStatus is ApkPreparationStatus.UpdateAvailable
AnimatedVisibility(
visible = canShareAPK,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// === Share via Hotspot Row ===
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
apkViewModel.onEvent(ApkUiEvent.HotspotShareClicked)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.hotspot_share_via),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = stringResource(R.string.hotspot_share_via_subtitle),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = colorScheme.onSurface.copy(alpha = 0.4f),
modifier = Modifier.size(20.dp)
)
}
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// === Share via Bluetooth/Email Row (Fallback) ===
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { apkViewModel.onEvent(ApkUiEvent.AppShareClicked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.hotspot_share_other),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = stringResource(R.string.hotspot_share_other_subtitle),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 16.sp
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = colorScheme.onSurface.copy(alpha = 0.4f),
modifier = Modifier.size(20.dp)
)
}
// APK Share Dialog
ApkShareExplanationDialog(
show = apkUiState.showShareApkDialog,
onConfirm = {
apkViewModel.onEvent(ApkUiEvent.ConfirmAppShare)
},
onDismiss = { apkViewModel.onEvent(ApkUiEvent.DismissShareDialog) }
)
}
}
}
}
@ -741,3 +1144,91 @@ fun PasswordPromptDialog(
)
}
}
/**
* Dialog explaining APK sharing feature before sharing
*/
@Composable
private fun ApkShareExplanationDialog(
show: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
if (show) {
val colorScheme = MaterialTheme.colorScheme
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = stringResource(R.string.share_apk_title),
style = MaterialTheme.typography.titleLarge,
color = colorScheme.onSurface
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.share_apk_explanation),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
// Info box with receiver instructions
Surface(
color = colorScheme.primaryContainer.copy(alpha = 0.3f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = stringResource(R.string.share_apk_receiver_instructions),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.8f),
lineHeight = 18.sp
)
}
}
}
},
confirmButton = {
Button(onClick = onConfirm) {
Text(
text = stringResource(R.string.share_apk_confirm),
style = MaterialTheme.typography.bodyMedium
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
}
},
containerColor = colorScheme.surface,
tonalElevation = 8.dp
)
}
}

View File

@ -0,0 +1,341 @@
package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// --- State ---
sealed class ApkPreparationStatus {
object Loading : ApkPreparationStatus()
data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus()
data class Ready(
val version: String,
val sizeMB: Int,
val source: UniversalApkManager.ApkSource
) : ApkPreparationStatus()
data class UpdateAvailable(
val currentVersion: String,
val newVersion: String,
val newSizeMB: Int
) : ApkPreparationStatus()
object Downloading : ApkPreparationStatus()
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
data class Error(val message: String) : ApkPreparationStatus()
}
data class ApkUiState(
val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
val downloadProgress: Int = 0,
val showPrepareDialog: Boolean = false,
val showDeleteDialog: Boolean = false,
val showShareApkDialog: Boolean = false
)
// --- Events (UI → ViewModel) ---
sealed class ApkUiEvent {
object CheckStatus : ApkUiEvent()
object PrepareRowClicked : ApkUiEvent()
object ConfirmDownload : ApkUiEvent()
object DismissPrepareDialog : ApkUiEvent()
object DeleteClicked : ApkUiEvent()
object ConfirmDelete : ApkUiEvent()
object DismissDeleteDialog : ApkUiEvent()
object HotspotShareClicked : ApkUiEvent()
object AppShareClicked : ApkUiEvent()
object ConfirmAppShare : ApkUiEvent()
object DismissShareDialog : ApkUiEvent()
object CancelDownload : ApkUiEvent()
}
// --- Effects (ViewModel → UI, one-shot) ---
sealed class ApkUiEffect {
data class NavigateToHotspot(val apkPath: String) : ApkUiEffect()
data class ShareApk(val apkUri: android.net.Uri, val chooserTitle: String) : ApkUiEffect()
data class ShowToast(val message: String) : ApkUiEffect()
}
/**
* ViewModel for APK download/status/share logic following MVI pattern.
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
*/
class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "ApkDownloadVM"
}
private val apkManager = UniversalApkManager(application)
private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
private val _state = MutableStateFlow(ApkUiState())
val state: StateFlow<ApkUiState> = _state.asStateFlow()
private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
init {
observeDownloader()
}
fun onEvent(event: ApkUiEvent) {
when (event) {
is ApkUiEvent.CheckStatus -> checkStatus()
is ApkUiEvent.PrepareRowClicked -> onPrepareRowClicked()
is ApkUiEvent.ConfirmDownload -> onConfirmDownload()
is ApkUiEvent.DismissPrepareDialog -> _state.update { it.copy(showPrepareDialog = false) }
is ApkUiEvent.DeleteClicked -> _state.update { it.copy(showDeleteDialog = true) }
is ApkUiEvent.ConfirmDelete -> onConfirmDelete()
is ApkUiEvent.DismissDeleteDialog -> _state.update { it.copy(showDeleteDialog = false) }
is ApkUiEvent.HotspotShareClicked -> onHotspotShareClicked()
is ApkUiEvent.AppShareClicked -> _state.update { it.copy(showShareApkDialog = true) }
is ApkUiEvent.ConfirmAppShare -> onConfirmAppShare()
is ApkUiEvent.DismissShareDialog -> _state.update { it.copy(showShareApkDialog = false) }
is ApkUiEvent.CancelDownload -> onCancelDownload()
}
}
private fun onPrepareRowClicked() {
when (_state.value.apkStatus) {
is ApkPreparationStatus.NotDownloaded,
is ApkPreparationStatus.UpdateAvailable,
is ApkPreparationStatus.Error -> {
_state.update { it.copy(showPrepareDialog = true) }
}
is ApkPreparationStatus.Resumable -> {
startDownload()
}
else -> {}
}
}
private fun onConfirmDownload() {
_state.update { it.copy(showPrepareDialog = false) }
startDownload()
}
private fun onConfirmDelete() {
_state.update { it.copy(showDeleteDialog = false) }
downloader.cancelDownload()
apkManager.deleteCachedApk()
checkStatus()
}
private fun onHotspotShareClicked() {
val apkFile = apkManager.getCachedApk()
if (apkFile != null) {
viewModelScope.launch {
_effect.send(ApkUiEffect.NavigateToHotspot(apkFile.absolutePath))
}
} else {
sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
}
}
private fun onConfirmAppShare() {
_state.update { it.copy(showShareApkDialog = false) }
viewModelScope.launch(Dispatchers.IO) {
try {
val apkFile = apkManager.getCachedApk()
if (apkFile == null || !apkFile.exists()) {
sendToast(getString(R.string.apk_not_ready_please_prepare_it_first))
return@launch
}
val context = getApplication<Application>()
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
_effect.send(
ApkUiEffect.ShareApk(
apkUri = uri,
chooserTitle = getString(R.string.share_apk_chooser_title)
)
)
} catch (e: Exception) {
Log.e(TAG, "Error preparing APK share", e)
sendToast(getString(R.string.share_apk_error))
}
}
}
private fun onCancelDownload() {
downloader.cancelDownload()
checkStatus()
}
private fun startDownload() {
val partial = apkManager.getPartialDownloadProgress()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
downloadProgress = partial ?: 0
)
}
downloader.startDownload()
}
private fun checkStatus() {
viewModelScope.launch {
// WorkManager is the source of truth for active work. A queued or
// newly started job legitimately has no partial file yet, so never
// infer that it is orphaned from cache contents.
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) {
return@launch
}
val resolvedStatus = resolveApkStatus()
_state.update { current ->
if (current.apkStatus is ApkPreparationStatus.Downloading) {
current
} else {
current.copy(apkStatus = resolvedStatus)
}
}
}
}
private fun observeDownloader() {
viewModelScope.launch {
downloader.downloadState.collect { downloadState ->
when (downloadState) {
is ApkDownloader.DownloadState.Idle -> {
// Don't overwrite — status set by checkStatus()
}
is ApkDownloader.DownloadState.Downloading -> {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
downloadProgress = downloadState.progressPercent
)
}
}
is ApkDownloader.DownloadState.Success -> {
val info = apkManager.getCachedApkInfo()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = downloadState.version,
sizeMB = downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB
),
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
_state.update {
if (downloadState.resumablePercent != null) {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
),
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(apkStatus = ApkPreparationStatus.Error(downloadState.message))
}
}
}
}
}
}
}
private fun sendToast(message: String) {
viewModelScope.launch {
_effect.send(ApkUiEffect.ShowToast(message))
}
}
private fun getString(resId: Int): String {
return getApplication<Application>().getString(resId)
}
private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) {
try {
val updateStatus = apkManager.checkForUpdate()
when (updateStatus) {
is UniversalApkManager.UpdateStatus.NotDownloaded -> {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkPreparationStatus.Resumable(
progressPercent = partial,
message = getString(R.string.prepare_apk_download_interrupted)
)
} else {
ApkPreparationStatus.NotDownloaded(
sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
}
is UniversalApkManager.UpdateStatus.UpToDate -> {
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
)
} else {
ApkPreparationStatus.Error("Cached APK info not found")
}
}
is UniversalApkManager.UpdateStatus.UpdateAvailable -> {
ApkPreparationStatus.UpdateAvailable(
currentVersion = updateStatus.currentVersion,
newVersion = updateStatus.latestRelease.versionName,
newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
)
}
is UniversalApkManager.UpdateStatus.Error -> {
// A cached artifact stays shareable even when the update
// check fails or the release lags the installed version.
val info = apkManager.getCachedApkInfo()
if (info != null) {
ApkPreparationStatus.Ready(
version = info.version,
sizeMB = (info.size / 1024 / 1024).toInt(),
source = info.source
)
} else {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkPreparationStatus.Resumable(
progressPercent = partial,
message = getString(R.string.prepare_apk_download_interrupted)
)
} else {
ApkPreparationStatus.Error(updateStatus.message)
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking APK status", e)
ApkPreparationStatus.Error(
e.message ?: getString(R.string.prepare_apk_error_github)
)
}
}
}

View File

@ -26,10 +26,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
/**
* Header components for ChatScreen
@ -356,16 +356,18 @@ private fun MainHeader(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
onSingleClick = onTitleClick,
onTripleClick = onTripleTitleClick
)
BitChatBrandButton(
onClick = onTitleClick,
onTripleClick = onTripleTitleClick,
contentDescription = stringResource(R.string.cd_open_about),
)
Text(
text = "/",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary,
)
Spacer(modifier = Modifier.width(2.dp))
NicknameEditor(
@ -444,7 +446,7 @@ private fun MainHeader(
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
isConnected = isConnected,

View File

@ -12,19 +12,35 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.zIndex
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
/**
@ -59,6 +75,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@ -85,6 +102,67 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val nearbyNotesController = remember { NearbyNotesController.shared }
val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nearbyNotes by remember { LocationNotesManager.getInstance() }
.notes
.collectAsStateWithLifecycle()
val buildingGeohash = availableLocationChannels
.firstOrNull { it.level == GeohashChannelLevel.BUILDING }
?.geohash
val isMeshTimeline =
currentChannel == null &&
selectedLocationChannel is ChannelID.Mesh &&
selectedPrivatePeer == null &&
privateChatSheetPeer == null
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
val lifecycle = processLifecycleOwner.lifecycle
val observer = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(false)
}
}
lifecycle.addObserver(observer)
nearbyNotesController.updateAppForeground(
lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
)
onDispose {
lifecycle.removeObserver(observer)
nearbyNotesController.updateAppForeground(false)
}
}
DisposableEffect(
isMeshTimeline,
locationEnabled,
locationPermissionState,
buildingGeohash,
nearbyNotesController,
) {
nearbyNotesController.updateAvailability(
locationEnabled = locationEnabled,
locationAuthorized =
locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED,
buildingGeohash = buildingGeohash,
)
if (isMeshTimeline) nearbyNotesController.activate()
onDispose {
if (isMeshTimeline) nearbyNotesController.deactivate()
}
}
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
@ -130,58 +208,91 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
// Messages area - takes up available space, will compress when keyboard appears
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
Column(modifier = Modifier.weight(1f)) {
if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) {
NearbyNotesStrip(
noteCount = nearbyNotes.size,
onClick = { showLocationNotesSheet = true },
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
}
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.fillMaxSize(),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (
selectedLocationChannel is ChannelID.Location &&
hashSuffix.isNotEmpty()
) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length),
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
},
)
if (
displayMessages.isEmpty() &&
isMeshTimeline &&
!nearbyNotesRevealed &&
locationEnabled &&
locationPermissionState ==
LocationChannelManager.PermissionState.AUTHORIZED &&
buildingGeohash != null
) {
NearbyNotesRevealHint(
onClick = nearbyNotesController::reveal,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
// Input area - stays at bottom
// Bridge file share from lower-level input to ViewModel
androidx.compose.runtime.LaunchedEffect(Unit) {
@ -252,7 +363,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = { showLocationNotesSheet = true }
onLocationNotesClick = {
nearbyNotesController.reveal()
showLocationNotesSheet = true
}
)
// Divider under header - positioned after status bar + header height
@ -344,6 +458,95 @@ fun ChatScreen(viewModel: ChatViewModel) {
showMeshPeerListSheet = showMeshPeerListSheet,
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
)
legacyPrivateMediaConsent?.let { request ->
AlertDialog(
onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) },
title = { Text(stringResource(com.bitchat.android.R.string.private_media_legacy_title)) },
text = {
Text(
stringResource(
com.bitchat.android.R.string.private_media_legacy_body,
request.fileName,
request.recipientNickname,
request.warning
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.approveLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(com.bitchat.android.R.string.private_media_legacy_send_once))
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
}
@Composable
private fun NearbyNotesRevealHint(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val actionLabel = stringResource(R.string.nearby_notes_reveal)
TextButton(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 24.dp)
.semantics { contentDescription = actionLabel },
) {
Text(
text = "📍 $actionLabel",
modifier = Modifier.clearAndSetSemantics { },
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
@Composable
private fun NearbyNotesStrip(
noteCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "📍 " + if (noteCount == 1) {
stringResource(R.string.nearby_notes_one)
} else {
stringResource(R.string.nearby_notes_many, noteCount)
},
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
)
}
}
}
@Composable

Some files were not shown because too many files have changed in this diff Show More