mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Merge upstream main into Android NDR branch
This commit is contained in:
commit
50256acaf6
35
.github/workflows/android-build.yml
vendored
35
.github/workflows/android-build.yml
vendored
@ -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
|
||||
|
||||
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@ -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
3
.gitignore
vendored
@ -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
4
.gitmodules
vendored
Normal 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
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@ -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 { *; }
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
<!-- Wi‑Fi / Wi‑Fi 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 Wi‑Fi operations (including Aware and Wi‑Fi P2P) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Android 17+ gates local network access; Wi‑Fi 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 Wi‑Fi 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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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 Wi‑Fi 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)
|
||||
|
||||
@ -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 Wi‑Fi 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) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
323
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal file
323
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
684
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal file
684
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
503
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal file
503
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
126
app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
Normal file
126
app/src/main/java/com/bitchat/android/hotspot/QrCodeGenerator.kt
Normal 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(":", "\\:")
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1126
app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
Normal file
1126
app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
Normal file
File diff suppressed because it is too large
Load Diff
22
app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
Normal file
22
app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
67
app/src/main/java/com/bitchat/android/mesh/MeshService.kt
Normal file
67
app/src/main/java/com/bitchat/android/mesh/MeshService.kt
Normal 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()
|
||||
}
|
||||
33
app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
Normal file
33
app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
Normal 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 = ""
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
473
app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
Normal file
473
app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
@ -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("-", "+")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 Wi‑Fi Devices (for Wi‑Fi Aware)"
|
||||
permission.contains("NOTIFICATION") -> "Notifications"
|
||||
else -> permission.substringAfterLast(".")
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 Wi‑Fi 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
|
||||
))
|
||||
|
||||
// Wi‑Fi 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) {
|
||||
)
|
||||
)
|
||||
|
||||
// Wi‑Fi Aware category (Android 13+)
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
val wifiAwarePermissions = wifiAwarePermissions()
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.WIFI_AWARE,
|
||||
description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.",
|
||||
permissions = wifiAwarePermissions,
|
||||
isGranted = wifiAwarePermissions.all { isPermissionGranted(it) },
|
||||
systemDescription = "Allow bitchat to discover nearby Wi‑Fi 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("Wi‑Fi Aware"),
|
||||
BATTERY_OPTIMIZATION("Battery Optimization"),
|
||||
OTHER("Other")
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)}" }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
341
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal file
341
app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user