mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge remote-tracking branch 'origin/main' into enhancement/apk-sharing
# Conflicts: # app/src/main/AndroidManifest.xml # gradle.properties
This commit is contained in:
commit
d4f4a89fad
@ -1,6 +1,7 @@
|
||||
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)
|
||||
}
|
||||
@ -85,11 +86,8 @@ 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
|
||||
@ -107,6 +105,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android dependencies
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@ -170,7 +174,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)
|
||||
@ -178,3 +182,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"
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,6 +29,8 @@
|
||||
<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" />
|
||||
|
||||
|
||||
@ -1,416 +1,442 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay0.gfcom.info,13.6992,100.694
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr-02.yakihonne.com:443,1.32123,103.695
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
ithurtswhenip.ee,51.5072,-0.127586
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.decentralia.fr,49.4282,10.9796
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
nostr.openhoofd.nl,51.5717,3.70417
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.satlantis.io,40.8054,-74.0241
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relayone.geektank.ai:443,39.1008,-94.5811
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
r.0kb.io,32.789,-96.7989
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.mom,50.4754,12.3683
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.snowbla.de:443,60.1699,24.9384
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub,39.1585,-94.5728
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
pool.libernet.app,43.6532,-79.3832
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
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
|
||||
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
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
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
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
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
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.notoshi.win,13.7829,100.546
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
satsage.xyz,37.3986,-121.964
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
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-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
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
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.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
|
||||
|
||||
|
@ -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,15 +2,15 @@ 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)
|
||||
@ -84,7 +84,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>()
|
||||
|
||||
@ -95,35 +94,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,
|
||||
peerNostrPublicKey = normalizedNpub,
|
||||
peerNickname = "Unknown",
|
||||
isFavorite = false,
|
||||
theyFavoritedUs = false,
|
||||
@ -139,11 +158,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 {
|
||||
@ -152,33 +174,32 @@ 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 }
|
||||
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
|
||||
}?.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]
|
||||
|
||||
@ -210,7 +231,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) {
|
||||
@ -228,6 +249,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()
|
||||
@ -240,15 +262,15 @@ 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
|
||||
}?.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
|
||||
}
|
||||
|
||||
@ -292,7 +314,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) {
|
||||
@ -318,6 +345,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) } }
|
||||
}
|
||||
@ -325,14 +353,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 */
|
||||
@ -348,7 +368,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,
|
||||
peerNickname = relationship.peerNickname,
|
||||
isFavorite = relationship.isFavorite,
|
||||
@ -360,7 +380,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,
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
@ -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,6 +93,12 @@ 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
|
||||
@ -323,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 || !isBleTransportEnabled()) return
|
||||
|
||||
packetBroadcaster.broadcastPacket(
|
||||
fun broadcastPacket(routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
|
||||
return packetBroadcaster.broadcastPacket(
|
||||
routed,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
@ -359,6 +370,17 @@ 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
|
||||
@ -501,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)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Tracks all Bluetooth connections and handles cleanup
|
||||
@ -31,6 +32,7 @@ class BluetoothConnectionTracker(
|
||||
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>()
|
||||
private val peerBindingLock = Any()
|
||||
|
||||
/**
|
||||
* Consolidated device connection information
|
||||
@ -42,7 +44,9 @@ 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()
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
@ -73,7 +77,11 @@ class BluetoothConnectionTracker(
|
||||
*/
|
||||
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
||||
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
||||
connectedDevices[deviceAddress] = deviceConn
|
||||
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
|
||||
@ -83,7 +91,20 @@ class BluetoothConnectionTracker(
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,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
|
||||
@ -233,13 +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)
|
||||
}
|
||||
firstAnnounceSeen.remove(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
|
||||
|
||||
@ -532,6 +532,7 @@ class BluetoothGattClientManager(
|
||||
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() {
|
||||
@ -553,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
|
||||
@ -583,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)
|
||||
|
||||
@ -602,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")
|
||||
}
|
||||
|
||||
@ -644,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) }}")
|
||||
@ -657,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")
|
||||
|
||||
@ -14,6 +14,7 @@ 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
|
||||
@ -43,6 +44,7 @@ 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
|
||||
@ -124,6 +126,7 @@ class BluetoothGattServerManager(
|
||||
// Ensure server is closed if present
|
||||
gattServer?.close()
|
||||
gattServer = null
|
||||
serverLinkIDs.clear()
|
||||
Log.i(TAG, "GATT server stopped (already inactive)")
|
||||
return
|
||||
}
|
||||
@ -145,6 +148,7 @@ class BluetoothGattServerManager(
|
||||
// Close GATT server
|
||||
gattServer?.close()
|
||||
gattServer = null
|
||||
serverLinkIDs.clear()
|
||||
|
||||
Log.i(TAG, "GATT server stopped")
|
||||
}
|
||||
@ -178,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
|
||||
@ -185,7 +191,8 @@ class BluetoothGattServerManager(
|
||||
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
|
||||
device = device,
|
||||
rssi = rssi,
|
||||
isClient = false
|
||||
isClient = false,
|
||||
linkID = linkID
|
||||
)
|
||||
connectionTracker.addDeviceConnection(device.address, deviceConn)
|
||||
|
||||
@ -198,9 +205,12 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.i(TAG, "Server: Device disconnected ${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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -236,11 +246,25 @@ class BluetoothGattServerManager(
|
||||
|
||||
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
|
||||
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.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) }}")
|
||||
|
||||
@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.protocol.MessagePadding
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
@ -19,6 +21,7 @@ import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
|
||||
@ -40,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothMeshService"
|
||||
private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
|
||||
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
}
|
||||
|
||||
@ -50,6 +54,58 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
|
||||
private val peerManager = PeerManager()
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
scope = serviceScope,
|
||||
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
|
||||
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
|
||||
sendState = ::sendAuthenticatedPeerState,
|
||||
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
|
||||
)
|
||||
}
|
||||
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
|
||||
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
|
||||
peerStateStatusProvider = authenticatedPeerState::status,
|
||||
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
|
||||
) }
|
||||
private val privateMediaPreparer by lazy {
|
||||
PrivateMediaTransferPreparer(
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
ttl = MAX_TTL,
|
||||
policyProvider = privateMediaSecurity::sendPolicy,
|
||||
encrypt = { plaintext, peerID, authenticatedSession ->
|
||||
try {
|
||||
PrivateMediaEncryptionResult.Success(
|
||||
encryptionService.encryptForSession(
|
||||
plaintext,
|
||||
peerID,
|
||||
authenticatedSession
|
||||
)
|
||||
)
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: Exception) {
|
||||
PrivateMediaEncryptionResult.Failed
|
||||
}
|
||||
},
|
||||
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
@ -70,10 +126,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
var delegate: BluetoothMeshDelegate? = null
|
||||
|
||||
// Coroutines
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var announceJob: Job? = null
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
private val provisionalBleClaims =
|
||||
ConcurrentHashMap<String, AuthenticatedBleLinkPolicy.Claim>()
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
@ -127,10 +184,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.broadcastPacket(routed)
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
val queued = connectionManager.broadcastPacket(routed)
|
||||
if (!queued) return false
|
||||
TransportBridgeService.broadcast("BLE", routed)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
@ -201,6 +260,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
provisionalBleClaims.remove(peerID)
|
||||
authenticatedPeerState.clear(peerID)
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
// Remove from mesh graph topology to prevent routing through stale peers
|
||||
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
|
||||
@ -217,7 +278,34 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// SecurityManager delegate for key exchange notifications
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
override fun onKeyExchangeCompleted(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
authenticatedSessionToken: ByteArray,
|
||||
directRelayAddress: String?,
|
||||
ingressLinkID: String?
|
||||
) {
|
||||
authenticatedPeerState.onSessionAuthenticated(
|
||||
peerID,
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
val expectedClaim = provisionalBleClaims.remove(peerID)
|
||||
if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) {
|
||||
val authenticatedClaim = checkNotNull(expectedClaim)
|
||||
if (connectionManager.bindPeerIfCurrent(
|
||||
authenticatedClaim.deviceAddress,
|
||||
authenticatedClaim.linkID,
|
||||
peerID
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID")
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { }
|
||||
} else {
|
||||
Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress")
|
||||
}
|
||||
}
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
@ -249,6 +337,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
|
||||
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
|
||||
}
|
||||
|
||||
// StoreForwardManager delegates
|
||||
@ -297,10 +388,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
override fun updatePeerInfoFromVerifiedAnnouncement(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities?): Boolean {
|
||||
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
nickname,
|
||||
noisePublicKey,
|
||||
signingPublicKey,
|
||||
isVerified,
|
||||
capabilities
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Packet operations
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
// Sign the packet before broadcasting
|
||||
@ -325,13 +423,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return securityManager.encryptForPeer(data, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
|
||||
override fun decryptFromPeer(
|
||||
encryptedData: ByteArray,
|
||||
senderPeerID: String
|
||||
): com.bitchat.android.noise.NoiseDecryptionResult? {
|
||||
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
|
||||
}
|
||||
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
|
||||
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
|
||||
}
|
||||
|
||||
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
|
||||
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
|
||||
|
||||
// Noise protocol operations
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
@ -375,30 +479,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerIDBinding(newPeerID: String, nickname: String,
|
||||
publicKey: ByteArray, previousPeerID: String?) {
|
||||
|
||||
Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...")
|
||||
// Update peer mapping in the PeerManager for peer ID rotation support
|
||||
peerManager.addOrUpdatePeer(newPeerID, nickname)
|
||||
|
||||
// Store fingerprint for the peer via centralized fingerprint manager
|
||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||
|
||||
// Index existing Nostr mapping by the new peerID if we have it
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// If there was a previous peer ID, remove it to avoid duplicates
|
||||
previousPeerID?.let { oldPeerID ->
|
||||
peerManager.removePeer(oldPeerID)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...")
|
||||
override fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
) {
|
||||
authenticatedPeerState.receive(peerID, state, authenticatedSession)
|
||||
}
|
||||
|
||||
// Message operations
|
||||
@ -492,35 +579,48 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleAnnounce(routed: RoutedPacket) {
|
||||
serviceScope.launch {
|
||||
// Process the announce
|
||||
val isFirst = messageHandler.handleAnnounce(routed)
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
if (result !is AnnounceHandlingResult.Accepted) return false
|
||||
|
||||
// Map device address -> peerID based on TTL (max TTL = direct neighbor)
|
||||
// Matches iOS logic: any announce with max TTL on a link defines the direct peer
|
||||
val deviceAddress = routed.relayAddress
|
||||
val pid = routed.peerID
|
||||
if (deviceAddress != null && pid != null) {
|
||||
// Check if this is a direct connection (MAX TTL)
|
||||
// Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
|
||||
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (isDirect) {
|
||||
// Bind or rebind this device address to the announcing peer
|
||||
connectionManager.addressPeerMap[deviceAddress] = pid
|
||||
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
|
||||
|
||||
// Mark as directly connected - refresh UI state
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
// Initial sync for this direct peer
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
|
||||
val deviceAddress = routed.relayAddress
|
||||
val pid = routed.peerID
|
||||
val linkID = routed.ingressLinkID
|
||||
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
val alreadyAuthenticated = deviceAddress != null &&
|
||||
pid != null &&
|
||||
connectionManager.addressPeerMap[deviceAddress] == pid
|
||||
if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) {
|
||||
try {
|
||||
val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)
|
||||
registerProvisionalBleClaim(pid, claim)
|
||||
val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true)
|
||||
if (handshakeData != null) {
|
||||
val handshake = signPacketBeforeBroadcast(
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(pid),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = handshakeData,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
)
|
||||
if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) {
|
||||
provisionalBleClaims.remove(pid, claim)
|
||||
Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress")
|
||||
}
|
||||
} else {
|
||||
provisionalBleClaims.remove(pid, claim)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID))
|
||||
Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}")
|
||||
}
|
||||
// Track for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
@ -578,7 +678,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// BluetoothConnectionManager delegates
|
||||
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||
override fun onPacketReceived(
|
||||
packet: BitchatPacket,
|
||||
peerID: String,
|
||||
device: android.bluetooth.BluetoothDevice?,
|
||||
ingressLinkID: String
|
||||
) {
|
||||
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||
try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||
@ -589,7 +694,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
myPeerID = myPeerID
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||
packetProcessor.processPacket(
|
||||
RoutedPacket(packet, peerID, device?.address, ingressLinkID = ingressLinkID)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||
@ -609,25 +716,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
|
||||
override fun onDeviceDisconnected(
|
||||
device: android.bluetooth.BluetoothDevice,
|
||||
linkID: String?
|
||||
) {
|
||||
Log.d(TAG, "Device disconnected: ${device.address}")
|
||||
val addr = device.address
|
||||
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
|
||||
val peer = connectionManager.addressPeerMap[addr]
|
||||
// ConnectionTracker has already removed the address mapping; be defensive either way
|
||||
connectionManager.addressPeerMap.remove(addr)
|
||||
clearProvisionalBleClaimsForLink(addr, linkID)
|
||||
|
||||
// refresh peer list on disconnect.
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
if (peer != null) {
|
||||
// Verbose debug: device disconnected
|
||||
try {
|
||||
val nick = peerManager.getPeerNickname(peer) ?: "unknown"
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
.logPeerDisconnection(peer, nick, addr)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
// ConnectionTracker already removes an authenticated mapping only when this exact
|
||||
// link is still current. Do not remove by reusable address here: this may be a late
|
||||
// disconnect callback from a replaced GATT connection.
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
@ -638,6 +740,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerProvisionalBleClaim(
|
||||
peerID: String,
|
||||
claim: AuthenticatedBleLinkPolicy.Claim
|
||||
) {
|
||||
provisionalBleClaims[peerID] = claim
|
||||
serviceScope.launch {
|
||||
delay(BLE_AUTHENTICATION_TIMEOUT_MS)
|
||||
if (provisionalBleClaims.remove(peerID, claim)) {
|
||||
Log.d(TAG, "Expired provisional BLE authentication claim for $peerID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) {
|
||||
if (linkID == null) return
|
||||
provisionalBleClaims.entries.removeIf { (_, claim) ->
|
||||
claim.deviceAddress == deviceAddress && claim.linkID == linkID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service
|
||||
@ -788,6 +910,32 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
/**
|
||||
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
|
||||
*/
|
||||
private fun sendAuthenticatedPeerState(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
): Boolean {
|
||||
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
|
||||
val ciphertext = securityManager.encryptForPeer(
|
||||
plaintext,
|
||||
peerID,
|
||||
authenticatedSession
|
||||
) ?: return false
|
||||
val packet = BitchatPacket(
|
||||
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
if (signed.signature?.size != 64) return false
|
||||
broadcastRoutedPacket(RoutedPacket(signed))
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
|
||||
@ -820,70 +968,65 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file as an encrypted private message using Noise protocol
|
||||
*/
|
||||
/** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */
|
||||
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
|
||||
|
||||
serviceScope.launch {
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
// Encode the file packet as TLV
|
||||
val filePayload = file.encode()
|
||||
if (filePayload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes")
|
||||
|
||||
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
|
||||
data = filePayload
|
||||
)
|
||||
|
||||
// Encrypt the payload using Noise
|
||||
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
|
||||
if (encrypted == null) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
val packet = BitchatPacket(
|
||||
version = if (encrypted.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
)
|
||||
|
||||
// Sign and send the encrypted packet
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(filePayload)
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
// No session - initiate handshake but don't queue file
|
||||
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
val payload = file.encode() ?: return
|
||||
when (val prepared = prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
sha256Hex(payload),
|
||||
allowLegacyFallback = false
|
||||
)) {
|
||||
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent ->
|
||||
Log.w(TAG, "Private media requires explicit one-shot legacy consent")
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
PrivateMediaPreparation.AwaitingPeerState -> Unit
|
||||
is PrivateMediaPreparation.Rejected ->
|
||||
Log.w(TAG, "Private media blocked: ${prepared.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: com.bitchat.android.model.BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation {
|
||||
return when (val outcome = privateMediaPreparer.prepare(
|
||||
recipientPeerID = recipientPeerID,
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
file = file,
|
||||
allowLegacyFallback = allowLegacyFallback
|
||||
)) {
|
||||
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
|
||||
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
|
||||
PrivateMediaBuildOutcome.NeedsHandshake ->
|
||||
PrivateMediaPreparation.NeedsHandshake
|
||||
PrivateMediaBuildOutcome.AwaitingPeerState ->
|
||||
PrivateMediaPreparation.AwaitingPeerState
|
||||
is PrivateMediaBuildOutcome.Rejected ->
|
||||
PrivateMediaPreparation.Rejected(outcome.reason)
|
||||
is PrivateMediaBuildOutcome.Ready -> {
|
||||
val built = outcome.built
|
||||
val routed = RoutedPacket(
|
||||
packet = built.packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = built.fragments
|
||||
)
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
|
||||
if (!isActive || terminated || !isBleTransportEnabled()) {
|
||||
false
|
||||
} else {
|
||||
broadcastRoutedPacket(routed)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
|
||||
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -952,9 +1095,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
// This was causing self-notifications - iOS doesn't do this
|
||||
// The UI handles showing sent messages through its own message sending logic
|
||||
// The UI handles sent messages through its own sending path.
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
|
||||
@ -964,8 +1105,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
// The UI will handle showing the message in the chat interface
|
||||
// The UI handles sent messages through its own sending path.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1099,7 +1239,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||
@ -1162,7 +1302,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||
@ -1403,24 +1543,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
/**
|
||||
* Sign packet before broadcasting using our signing private key
|
||||
*/
|
||||
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.joinToString("") { byte -> "%02x".format(byte) }
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
|
||||
myPeerID,
|
||||
destination
|
||||
)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map(::hexStringToByteArray),
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
/** Private media must never fall back to an unsigned packet. */
|
||||
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
|
||||
val routed = applyRouteIfAvailable(packet)
|
||||
val signingBytes = routed.toBinaryDataForSigning() ?: return null
|
||||
val signature = encryptionService.signData(signingBytes) ?: return null
|
||||
return routed.copy(signature = signature)
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
// Optionally compute and attach a source route for addressed packets
|
||||
val withRoute = try {
|
||||
val rec = packet.recipientID
|
||||
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val dest = rec.joinToString("") { b -> "%02x".format(b) }
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
|
||||
if (path != null && path.size >= 3) {
|
||||
// Exclude first (sender) and last (recipient); only intermediates
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
|
||||
packet.copy(route = hopsBytes, version = 2u)
|
||||
} else packet.copy(route = null)
|
||||
} else packet
|
||||
} catch (_: Exception) { packet }
|
||||
val withRoute = applyRouteIfAvailable(packet)
|
||||
|
||||
// Get the canonical packet data for signing (without signature)
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||
|
||||
@ -136,8 +136,8 @@ class BluetoothPacketBroadcaster(
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
fragmentingSender.send(routed, "BLE broadcast") { packet ->
|
||||
): Boolean {
|
||||
return fragmentingSender.send(routed, "BLE broadcast") { packet ->
|
||||
broadcastSinglePacket(packet, gattServer, characteristic)
|
||||
true
|
||||
}
|
||||
@ -163,6 +163,28 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@ -51,97 +51,123 @@ class FragmentManager {
|
||||
* Create fragments from a large packet - 100% iOS Compatible
|
||||
* Matches iOS sendFragmentedPacket() implementation exactly
|
||||
*/
|
||||
fun createFragments(packet: BitchatPacket): 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 <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
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
|
||||
|
||||
// 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()
|
||||
}
|
||||
// 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)
|
||||
|
||||
@ -31,14 +31,20 @@ class FragmentingPacketSender(
|
||||
sendSingle: (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val transferId = transferIdFor(routed)
|
||||
val packets = packetsForTransport(routed.packet) ?: return false
|
||||
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))
|
||||
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)
|
||||
@ -57,7 +63,11 @@ class FragmentingPacketSender(
|
||||
if (!isActive) return@launch
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
|
||||
val fragment = routed.copy(packet = packet, transferId = transferId)
|
||||
val fragment = routed.copy(
|
||||
packet = packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = null
|
||||
)
|
||||
val delivered = try {
|
||||
sendSingle(fragment)
|
||||
} catch (e: Exception) {
|
||||
@ -98,7 +108,17 @@ class FragmentingPacketSender(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun packetsForTransport(packet: BitchatPacket): List<BitchatPacket>? {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
@ -41,8 +43,8 @@ class MeshCore(
|
||||
) {
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
val announcementNicknameProvider: (() -> String?)? = null,
|
||||
@ -51,6 +53,57 @@ class MeshCore(
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
scope = scope,
|
||||
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
|
||||
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
|
||||
sendState = ::sendAuthenticatedPeerState,
|
||||
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
|
||||
)
|
||||
}
|
||||
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
|
||||
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
|
||||
peerStateStatusProvider = authenticatedPeerState::status,
|
||||
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
|
||||
) }
|
||||
private val privateMediaPreparer by lazy {
|
||||
PrivateMediaTransferPreparer(
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
ttl = maxTtl,
|
||||
policyProvider = privateMediaSecurity::sendPolicy,
|
||||
encrypt = { plaintext, peerID, authenticatedSession ->
|
||||
try {
|
||||
PrivateMediaEncryptionResult.Success(
|
||||
encryptionService.encryptForSession(
|
||||
plaintext,
|
||||
peerID,
|
||||
authenticatedSession
|
||||
)
|
||||
)
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} catch (_: Exception) {
|
||||
PrivateMediaEncryptionResult.Failed
|
||||
}
|
||||
},
|
||||
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
@ -117,8 +170,20 @@ class MeshCore(
|
||||
packetProcessor.shutdown()
|
||||
}
|
||||
|
||||
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
|
||||
fun processIncoming(
|
||||
packet: BitchatPacket,
|
||||
peerID: String?,
|
||||
relayAddress: String?,
|
||||
ingressLinkID: String? = null
|
||||
) {
|
||||
packetProcessor.processPacket(
|
||||
RoutedPacket(
|
||||
packet = packet,
|
||||
peerID = peerID,
|
||||
relayAddress = relayAddress,
|
||||
ingressLinkID = ingressLinkID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sendFromBridge(packet: RoutedPacket) {
|
||||
@ -150,6 +215,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
authenticatedPeerState.clear(peerID)
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
@ -157,7 +223,26 @@ class MeshCore(
|
||||
}
|
||||
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
override fun onKeyExchangeCompleted(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
authenticatedSessionToken: ByteArray,
|
||||
directRelayAddress: String?,
|
||||
ingressLinkID: String?
|
||||
) {
|
||||
authenticatedPeerState.onSessionAuthenticated(
|
||||
peerID,
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
if (directRelayAddress != null && ingressLinkID != null) {
|
||||
hooks.onDirectNoiseAuthenticated?.invoke(
|
||||
peerID,
|
||||
directRelayAddress,
|
||||
ingressLinkID,
|
||||
authenticatedRemoteStaticKey
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
@ -180,6 +265,9 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
|
||||
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
|
||||
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
|
||||
}
|
||||
|
||||
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
|
||||
@ -225,14 +313,22 @@ class MeshCore(
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
override fun updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
isVerified: Boolean,
|
||||
capabilities: com.bitchat.android.model.PeerCapabilities?
|
||||
): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
nickname,
|
||||
noisePublicKey,
|
||||
signingPublicKey,
|
||||
isVerified,
|
||||
capabilities
|
||||
)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
@ -256,7 +352,10 @@ class MeshCore(
|
||||
return securityManager.encryptForPeer(data, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
|
||||
override fun decryptFromPeer(
|
||||
encryptedData: ByteArray,
|
||||
senderPeerID: String
|
||||
): com.bitchat.android.noise.NoiseDecryptionResult? {
|
||||
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
|
||||
}
|
||||
|
||||
@ -264,6 +363,9 @@ class MeshCore(
|
||||
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
|
||||
}
|
||||
|
||||
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
|
||||
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
|
||||
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
@ -280,17 +382,12 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerIDBinding(
|
||||
newPeerID: String,
|
||||
nickname: String,
|
||||
publicKey: ByteArray,
|
||||
previousPeerID: String?
|
||||
override fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
) {
|
||||
peerManager.addOrUpdatePeer(newPeerID, nickname)
|
||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||
previousPeerID?.let { peerManager.removePeer(it) }
|
||||
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
|
||||
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
|
||||
authenticatedPeerState.receive(peerID, state, authenticatedSession)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
@ -352,12 +449,12 @@ class MeshCore(
|
||||
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleAnnounce(routed: RoutedPacket) {
|
||||
scope.launch {
|
||||
val isFirst = messageHandler.handleAnnounce(routed)
|
||||
hooks.onAnnounceProcessed?.invoke(routed, isFirst)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
if (result !is AnnounceHandlingResult.Accepted) return false
|
||||
hooks.onAnnounceProcessed?.invoke(routed, result.isFirst)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
return true
|
||||
}
|
||||
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
@ -430,6 +527,32 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAuthenticatedPeerState(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
): Boolean {
|
||||
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
|
||||
val ciphertext = securityManager.encryptForPeer(
|
||||
plaintext,
|
||||
peerID,
|
||||
authenticatedSession
|
||||
) ?: return false
|
||||
val packet = BitchatPacket(
|
||||
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
if (signed.signature?.size != 64) return false
|
||||
dispatchGlobal(RoutedPacket(signed))
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
try {
|
||||
val payload = file.encode() ?: return
|
||||
@ -455,31 +578,64 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
try {
|
||||
scope.launch {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
val tlv = file.encode() ?: return@launch
|
||||
val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
|
||||
val enc = encryptionService.encrypt(np, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = if (enc.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(tlv)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
val payload = file.encode() ?: return
|
||||
when (val prepared = prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
MeshPacketUtils.sha256Hex(payload),
|
||||
allowLegacyFallback = false
|
||||
)) {
|
||||
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent ->
|
||||
Log.w("MeshCore", "Private media requires explicit one-shot legacy consent")
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending")
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
PrivateMediaPreparation.AwaitingPeerState -> Unit
|
||||
is PrivateMediaPreparation.Rejected ->
|
||||
Log.w("MeshCore", "Private media blocked: ${prepared.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation {
|
||||
return when (val outcome = privateMediaPreparer.prepare(
|
||||
recipientPeerID = recipientPeerID,
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
file = file,
|
||||
allowLegacyFallback = allowLegacyFallback
|
||||
)) {
|
||||
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
|
||||
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
|
||||
PrivateMediaBuildOutcome.NeedsHandshake ->
|
||||
PrivateMediaPreparation.NeedsHandshake
|
||||
PrivateMediaBuildOutcome.AwaitingPeerState ->
|
||||
PrivateMediaPreparation.AwaitingPeerState
|
||||
is PrivateMediaBuildOutcome.Rejected ->
|
||||
PrivateMediaPreparation.Rejected(outcome.reason)
|
||||
is PrivateMediaBuildOutcome.Ready -> {
|
||||
val built = outcome.built
|
||||
val routed = RoutedPacket(
|
||||
packet = built.packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = built.fragments
|
||||
)
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
|
||||
if (!isActive) {
|
||||
false
|
||||
} else {
|
||||
dispatchGlobal(routed)
|
||||
true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -600,7 +756,7 @@ class MeshCore(
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@ -621,7 +777,7 @@ class MeshCore(
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@ -742,6 +898,44 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh replacement handshake on one exact direct transport generation.
|
||||
* This authenticates provisional transport claims without broadcasting the challenge or
|
||||
* accidentally sending it through a socket that later reused the same alias.
|
||||
*/
|
||||
fun initiateNoiseHandshakeOnLink(
|
||||
peerID: String,
|
||||
relayAddress: String,
|
||||
ingressLinkID: String
|
||||
): Boolean {
|
||||
return try {
|
||||
val handshakeData = encryptionService.initiateHandshake(
|
||||
peerID,
|
||||
replaceEstablished = true
|
||||
) ?: return false
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = handshakeData,
|
||||
ttl = maxTtl
|
||||
)
|
||||
transport.sendPacketToLink(
|
||||
relayAddress,
|
||||
ingressLinkID,
|
||||
signPacketBeforeBroadcast(packet)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"MeshCore",
|
||||
"Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
|
||||
|
||||
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
@ -806,28 +1000,42 @@ class MeshCore(
|
||||
encryptionService.clearPersistentIdentity()
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
|
||||
myPeerID,
|
||||
destination
|
||||
)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
|
||||
val routed = applyRouteIfAvailable(packet)
|
||||
val signingBytes = routed.toBinaryDataForSigning() ?: return null
|
||||
val signature = encryptionService.signData(signingBytes) ?: return null
|
||||
return routed.copy(signature = signature)
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = applyRouteIfAvailable(packet)
|
||||
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
|
||||
@ -13,6 +13,8 @@ interface MeshDelegate {
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveVerifyResponse(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
|
||||
|
||||
@ -21,6 +21,12 @@ interface MeshService {
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
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()
|
||||
|
||||
@ -13,6 +13,16 @@ interface MeshTransport {
|
||||
|
||||
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
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
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.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
@ -11,7 +12,11 @@ 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
|
||||
@ -55,18 +60,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")
|
||||
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")
|
||||
@ -84,7 +89,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)
|
||||
@ -102,7 +107,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
|
||||
@ -141,6 +146,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
|
||||
@ -216,10 +234,14 @@ 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
|
||||
|
||||
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
|
||||
// during identity learning, or later signed messages cannot be verified.
|
||||
@ -227,28 +249,29 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
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 false
|
||||
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)
|
||||
@ -258,10 +281,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
|
||||
@ -275,22 +309,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)
|
||||
@ -299,7 +326,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -438,14 +465,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) {
|
||||
@ -549,24 +588,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 {
|
||||
@ -576,8 +610,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",
|
||||
@ -604,7 +637,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)
|
||||
@ -614,15 +654,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?
|
||||
|
||||
@ -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,6 +125,84 @@ 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
|
||||
|
||||
@ -125,6 +220,9 @@ class PeerManager {
|
||||
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(
|
||||
@ -135,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
|
||||
@ -147,7 +248,8 @@ class PeerManager {
|
||||
val shouldNotify = when {
|
||||
isNewPeer && isVerified -> true
|
||||
wasVerified != isVerified -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged ||
|
||||
capabilitiesChanged || announcementStateChanged -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ 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.noise.NoiseSession
|
||||
@ -91,7 +92,7 @@ class UnifiedMeshService(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val content = FavoriteControlMessage.encode(isFavorite, myNpub)
|
||||
val nickname = getPeerNicknames()[peerID] ?: peerID
|
||||
if (hasEstablishedSession(peerID)) {
|
||||
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
|
||||
@ -129,6 +130,41 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
@ -323,6 +359,10 @@ class UnifiedMeshService(
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
delegate?.didResolvePrivateMediaPolicy(peerID)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
|
||||
@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,12 +23,23 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u);
|
||||
FILE_TRANSFER(0x20u),
|
||||
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
|
||||
PEER_STATE(0x21u);
|
||||
|
||||
|
||||
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,67 @@
|
||||
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)
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
@ -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,9 +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)
|
||||
sessionManager.initiateHandshake(peerID, replaceEstablished)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}")
|
||||
null
|
||||
@ -194,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
|
||||
@ -234,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
|
||||
@ -251,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
|
||||
|
||||
@ -382,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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -451,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
|
||||
|
||||
@ -3,6 +3,42 @@ 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
|
||||
*/
|
||||
@ -16,9 +52,13 @@ class NoiseSessionManager(
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
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
|
||||
@ -29,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")
|
||||
}
|
||||
|
||||
@ -45,16 +87,18 @@ 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")
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
||||
*/
|
||||
fun initiateHandshake(peerID: String): ByteArray? {
|
||||
@Synchronized
|
||||
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
|
||||
Log.d(TAG, "initiateHandshake($peerID)")
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
@ -62,8 +106,20 @@ class NoiseSessionManager(
|
||||
if (existing != null) {
|
||||
when {
|
||||
existing.isEstablished() -> {
|
||||
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
|
||||
return null
|
||||
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)) {
|
||||
@ -94,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
|
||||
}
|
||||
}
|
||||
@ -103,62 +159,156 @@ class NoiseSessionManager(
|
||||
* Handle incoming handshake message
|
||||
*/
|
||||
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
|
||||
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
|
||||
|
||||
try {
|
||||
var session = getSession(peerID)
|
||||
return processHandshakeMessageWithResult(peerID, message).response
|
||||
}
|
||||
|
||||
// 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")
|
||||
removeSession(peerID)
|
||||
session = null
|
||||
@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 {
|
||||
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
|
||||
return null
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
val session = activeSession ?: throw NoiseSessionError.InvalidState
|
||||
|
||||
response = session.processHandshakeMessage(message)
|
||||
|
||||
if (session.isEstablished()) {
|
||||
Log.d(TAG, "✅ Session ESTABLISHED with $peerID")
|
||||
val remoteStaticKey = session.getRemoteStaticPublicKey()
|
||||
if (remoteStaticKey != null) {
|
||||
onSessionEstablished?.invoke(peerID, remoteStaticKey)
|
||||
?: 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")
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
} 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
|
||||
@ -168,6 +318,7 @@ class NoiseSessionManager(
|
||||
/**
|
||||
* 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()) {
|
||||
@ -175,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")
|
||||
@ -189,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -211,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -239,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()) {
|
||||
@ -252,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")
|
||||
}
|
||||
}
|
||||
@ -268,4 +471,8 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
|
||||
object InvalidState : NoiseSessionError("Session in invalid state")
|
||||
object HandshakeFailed : NoiseSessionError("Handshake failed")
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,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,6 +2,8 @@ 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
|
||||
@ -9,10 +11,13 @@ 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.launch
|
||||
@ -99,8 +104,9 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(convKey)
|
||||
|
||||
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||
processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onGiftWrap error: ${e.message}")
|
||||
@ -110,7 +116,7 @@ class NostrDirectMessageHandler(
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: NoisePayload,
|
||||
convKey: String,
|
||||
conversationID: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
senderPubkey: String,
|
||||
@ -119,9 +125,20 @@ class NostrDirectMessageHandler(
|
||||
when (payload.type) {
|
||||
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
|
||||
val favoriteControl = FavoriteControlMessage.parse(pm.content)
|
||||
if (favoriteControl != null) {
|
||||
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
seenStore.markDelivered(pm.messageID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val message = BitchatMessage(
|
||||
id = pm.messageID,
|
||||
sender = senderNickname,
|
||||
@ -130,15 +147,19 @@ class NostrDirectMessageHandler(
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = convKey,
|
||||
senderPeerID = conversationID,
|
||||
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
|
||||
)
|
||||
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
|
||||
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
message = message,
|
||||
suppressUnread = suppressUnread,
|
||||
origin = PrivateMessageOrigin.NOSTR
|
||||
)
|
||||
}
|
||||
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
@ -156,13 +177,13 @@ class NostrDirectMessageHandler(
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
|
||||
}
|
||||
}
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
|
||||
}
|
||||
}
|
||||
NoisePayloadType.FILE_TRANSFER -> {
|
||||
@ -180,18 +201,80 @@ class NostrDirectMessageHandler(
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = convKey
|
||||
senderPeerID = conversationID
|
||||
)
|
||||
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
message = message,
|
||||
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 -> Unit // Ignore verification payloads in Nostr direct messages
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleFavoriteControl(
|
||||
control: FavoriteControlMessage,
|
||||
conversationID: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "$displayName $action you$guidance",
|
||||
timestamp = timestamp,
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
senderPeerID = targetConversationID
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
message = systemMessage,
|
||||
suppressUnread = true,
|
||||
origin = PrivateMessageOrigin.NOSTR
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,15 +2,17 @@ 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.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,
|
||||
@ -53,11 +55,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")
|
||||
@ -72,20 +70,12 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
|
||||
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
|
||||
val recipientPeerIDForEmbed = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.findPeerIDForNostrPubkey(recipientNostrPubkey)
|
||||
@ -115,6 +105,7 @@ class NostrTransport(
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
@ -147,10 +138,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}")
|
||||
@ -167,15 +155,8 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
@ -201,6 +182,7 @@ class NostrTransport(
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
@ -224,10 +206,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")
|
||||
@ -240,16 +219,12 @@ 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)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
@ -273,6 +248,7 @@ class NostrTransport(
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
@ -285,10 +261,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")
|
||||
@ -303,11 +276,8 @@ class NostrTransport(
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
@ -331,6 +301,7 @@ class NostrTransport(
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
@ -482,16 +453,17 @@ class NostrTransport(
|
||||
*/
|
||||
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
|
||||
}
|
||||
@ -503,14 +475,6 @@ class NostrTransport(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
@ -39,6 +39,26 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@ -87,7 +107,7 @@ class PermissionManager(private val context: Context) {
|
||||
|
||||
// Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
permissions.addAll(wifiAwarePermissions())
|
||||
}
|
||||
|
||||
// Notification permission intentionally excluded to keep it optional
|
||||
@ -232,7 +252,7 @@ class PermissionManager(private val context: Context) {
|
||||
|
||||
// Wi‑Fi Aware category (Android 13+)
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
val wifiAwarePermissions = wifiAwarePermissions()
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.WIFI_AWARE,
|
||||
|
||||
@ -75,7 +75,15 @@ object TransportBridgeService {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val forwarded = packet.copy(packet = forwardedPacket)
|
||||
// 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}")
|
||||
|
||||
|
||||
@ -99,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,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
|
||||
|
||||
@ -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,6 +2,7 @@ package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
@ -14,6 +15,13 @@ class MessageRouter private constructor(
|
||||
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
|
||||
@ -46,53 +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 = isConnected(mesh, toPeerID)
|
||||
if (isReady(mesh, toPeerID)) {
|
||||
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)}…")
|
||||
if (hasMesh) 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 (isReady(mesh, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,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 && mesh.hasEstablishedSession(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)) {
|
||||
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, null)
|
||||
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()
|
||||
val hasMesh = isReady(mesh, 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 = resolvePeerForNoiseHex(peerID, mesh)
|
||||
if (meshPeer != null && isReady(mesh, 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)
|
||||
}
|
||||
}
|
||||
@ -161,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
|
||||
@ -176,11 +188,6 @@ 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
|
||||
@ -198,22 +205,12 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
|
||||
return try {
|
||||
service.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = service.getPeerInfo(pid)
|
||||
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// Called when mesh peer list changes; attempt to flush any matching outbox entries
|
||||
fun onPeersUpdated(peers: List<String>) {
|
||||
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) }
|
||||
}
|
||||
@ -223,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) }
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.meshServiceFacade,
|
||||
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
|
||||
|
||||
@ -3,13 +3,9 @@ package com.bitchat.android.ui
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
@ -50,9 +46,7 @@ fun formatMessageAsAnnotatedString(
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
@ -117,7 +111,14 @@ fun formatMessageAsAnnotatedString(
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
||||
appendIOSFormattedContent(
|
||||
builder,
|
||||
message.content,
|
||||
message.mentions,
|
||||
currentUserNickname,
|
||||
baseColor,
|
||||
isSelf,
|
||||
)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
// Timestamp (and optional PoW badge)
|
||||
@ -156,6 +157,108 @@ fun formatMessageAsAnnotatedString(
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sender label used by the two-row text-message layout.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = senderWeight
|
||||
)
|
||||
)
|
||||
builder.append("@")
|
||||
val nicknameStart = builder.length
|
||||
builder.append(truncateNickname(baseName))
|
||||
val nicknameEnd = builder.length
|
||||
if (!isSelf) {
|
||||
builder.addStringAnnotation(
|
||||
tag = "nickname_click",
|
||||
annotation = message.originalSender ?: message.sender,
|
||||
start = nicknameStart,
|
||||
end = nicknameEnd
|
||||
)
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = senderWeight
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the compact timestamp and optional proof-of-work label.
|
||||
*/
|
||||
fun formatTextMessageMetadata(
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
)
|
||||
builder.append(timeFormatter.format(message.timestamp))
|
||||
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the message body while retaining mention, URL and geohash styling.
|
||||
*/
|
||||
fun formatTextMessageBody(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
|
||||
appendIOSFormattedContent(
|
||||
builder = builder,
|
||||
content = message.content,
|
||||
mentions = message.mentions,
|
||||
currentUserNickname = currentUserNickname,
|
||||
baseColor = accentColor,
|
||||
isSelf = isSelf,
|
||||
contentColor = colorScheme.onSurface
|
||||
)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||
*/
|
||||
@ -169,9 +272,7 @@ fun formatMessageHeaderAnnotatedString(
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
@ -338,7 +439,7 @@ private fun appendIOSFormattedContent(
|
||||
currentUserNickname: String,
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
isDark: Boolean
|
||||
contentColor: Color = baseColor,
|
||||
) {
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
@ -416,7 +517,7 @@ private fun appendIOSFormattedContent(
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
@ -476,7 +577,7 @@ private fun appendIOSFormattedContent(
|
||||
"hashtag" -> {
|
||||
// Render general hashtags like normal content
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
@ -530,7 +631,7 @@ private fun appendIOSFormattedContent(
|
||||
} else {
|
||||
// Fallback: treat as normal text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
@ -547,7 +648,7 @@ private fun appendIOSFormattedContent(
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
|
||||
@ -22,16 +22,14 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@ -67,6 +65,14 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun approveLegacyPrivateMedia(requestId: String) {
|
||||
mediaSendingManager.approveLegacyPrivateMedia(requestId)
|
||||
}
|
||||
|
||||
fun cancelLegacyPrivateMedia(requestId: String) {
|
||||
mediaSendingManager.cancelLegacyPrivateMedia(requestId)
|
||||
}
|
||||
|
||||
fun getCurrentNpub(): String? {
|
||||
return try {
|
||||
NostrIdentityBridge
|
||||
@ -123,7 +129,12 @@ class ChatViewModel(
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
|
||||
private val mediaSendingManager = MediaSendingManager(
|
||||
state,
|
||||
messageManager,
|
||||
channelManager,
|
||||
viewModelScope
|
||||
) { mesh }
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
@ -184,6 +195,8 @@ class ChatViewModel(
|
||||
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
|
||||
mediaSendingManager.legacyPrivateMediaConsent
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
@ -205,6 +218,8 @@ class ChatViewModel(
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
ContactDirectory.initialize(getApplication()) { mesh }
|
||||
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
|
||||
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
|
||||
@ -220,14 +235,14 @@ class ChatViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||
// Replace with store snapshot
|
||||
state.setPrivateChats(byPeer)
|
||||
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
|
||||
state.setPrivateChats(canonicalChats)
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
byPeer.forEach { (peer, list) ->
|
||||
canonicalChats.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
}
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
@ -402,17 +417,18 @@ class ChatViewModel(
|
||||
|
||||
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
||||
if (success) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
// Notify notification manager about current private chat
|
||||
setCurrentPrivateChatPeer(peerID)
|
||||
setCurrentPrivateChatPeer(conversationID)
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
clearNotificationsForSender(peerID)
|
||||
clearNotificationsForSender(conversationID)
|
||||
|
||||
// Persistently mark all messages in this conversation as read so Nostr fetches
|
||||
// after app restarts won't re-mark them as unread.
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val chats = state.getPrivateChatsValue()
|
||||
val messages = chats[peerID] ?: emptyList()
|
||||
val messages = chats[conversationID] ?: emptyList()
|
||||
messages.forEach { msg ->
|
||||
try { seen.markRead(msg.id) } catch (_: Exception) { }
|
||||
}
|
||||
@ -466,12 +482,11 @@ class ChatViewModel(
|
||||
} else {
|
||||
// Resolve to a canonical mesh peer if needed
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = targetKey,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
selectedPeerID = targetKey,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
canonical ?: targetKey
|
||||
}
|
||||
@ -510,21 +525,19 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
var selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
|
||||
if (selectedPeer != null) {
|
||||
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
|
||||
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeer = ContactDirectory.canonicalConversationId(
|
||||
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = selectedPeer,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, mesh)
|
||||
@ -543,9 +556,11 @@ class ChatViewModel(
|
||||
state.getNicknameValue(),
|
||||
mesh.myPeerID
|
||||
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||
// Route via MessageRouter (mesh when connected+established, else Nostr)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
|
||||
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
val route = router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) {
|
||||
messageManager.updateMessageDeliveryStatus(messageId, com.bitchat.android.model.DeliveryStatus.Sent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if we're in a location channel
|
||||
@ -609,25 +624,23 @@ class ChatViewModel(
|
||||
var noiseKey: ByteArray? = null
|
||||
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||
|
||||
// Case 1: Live mesh peer with known info
|
||||
val peerInfo = mesh.getPeerInfo(peerID)
|
||||
if (peerInfo?.noisePublicKey != null) {
|
||||
noiseKey = peerInfo.noisePublicKey
|
||||
nickname = peerInfo.nickname
|
||||
} else {
|
||||
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
try {
|
||||
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
// Prefer nickname from favorites store if available
|
||||
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
|
||||
if (rel != null) nickname = rel.peerNickname
|
||||
} catch (_: Exception) { }
|
||||
} else if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
|
||||
noiseKey = ContactIdentityResolver.bytesFromHex(peerID)
|
||||
val rel = noiseKey?.let {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it)
|
||||
}
|
||||
if (rel != null) nickname = rel.peerNickname
|
||||
} else {
|
||||
val contact = ContactDirectory.resolve(peerID)
|
||||
noiseKey = contact.noisePublicKey
|
||||
contact.displayName?.let { nickname = it }
|
||||
}
|
||||
|
||||
if (noiseKey != null) {
|
||||
// Determine current favorite state from DataManager using fingerprint
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
|
||||
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
|
||||
@ -638,7 +651,6 @@ class ChatViewModel(
|
||||
isFavorite = isNowFavorite
|
||||
)
|
||||
|
||||
// Send favorite notification via mesh or Nostr with our npub if available
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), mesh)
|
||||
@ -845,7 +857,8 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
fun showPrivateChatSheet(peerID: String) {
|
||||
state.setPrivateChatSheetPeer(peerID)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
state.setPrivateChatSheetPeer(conversationID)
|
||||
}
|
||||
|
||||
fun hidePrivateChatSheet() {
|
||||
@ -921,6 +934,10 @@ class ChatViewModel(
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyResponse(peerID, payload)
|
||||
}
|
||||
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
mediaSendingManager.retryPendingPrivateMedia(peerID)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@ -934,12 +951,14 @@ class ChatViewModel(
|
||||
return meshDelegateHandler.isFavorite(peerID)
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
fun panicClearAllData() {
|
||||
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
|
||||
|
||||
// A pending one-shot downgrade confirmation must not survive panic or
|
||||
// become actionable against the fresh post-wipe identity.
|
||||
mediaSendingManager.clearPendingPrivateMediaConsent()
|
||||
|
||||
// Clear all UI managers
|
||||
messageManager.clearAllMessages()
|
||||
|
||||
@ -34,6 +34,8 @@ import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import com.bitchat.android.nostr.geohashesForSampling
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@ -61,6 +63,7 @@ fun LocationChannelsSheet(
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
|
||||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
@ -534,9 +537,13 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
|
||||
// Sampling management: update sampling when channels/bookmarks change
|
||||
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||||
LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
|
||||
if (isPresented) {
|
||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||
val geohashes = geohashesForSampling(
|
||||
availableChannels = availableChannels,
|
||||
bookmarks = bookmarks,
|
||||
notesRevealed = notesRevealed,
|
||||
)
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
viewModel.endGeohashSampling()
|
||||
|
||||
@ -32,6 +32,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Calendar
|
||||
@ -58,12 +59,15 @@ fun LocationNotesSheet(
|
||||
// Managers
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val nearbyNotesController = remember { NearbyNotesController.shared }
|
||||
|
||||
// State
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
|
||||
val count = notes.size
|
||||
@ -94,15 +98,24 @@ fun LocationNotesSheet(
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
|
||||
// Effect to set geohash when sheet opens
|
||||
LaunchedEffect(geohash) {
|
||||
notesManager.setGeohash(geohash)
|
||||
}
|
||||
|
||||
// Cleanup when sheet closes
|
||||
DisposableEffect(Unit) {
|
||||
// Opening the notes sheet is an explicit reveal. The balanced hold lets
|
||||
// the mesh timeline keep the shared subscription alive after dismissal.
|
||||
DisposableEffect(
|
||||
geohash,
|
||||
locationEnabled,
|
||||
permissionState,
|
||||
nearbyNotesController,
|
||||
) {
|
||||
nearbyNotesController.updateAvailability(
|
||||
locationEnabled = locationEnabled,
|
||||
locationAuthorized =
|
||||
permissionState == LocationChannelManager.PermissionState.AUTHORIZED,
|
||||
buildingGeohash = geohash,
|
||||
)
|
||||
nearbyNotesController.activate()
|
||||
nearbyNotesController.reveal()
|
||||
onDispose {
|
||||
notesManager.cancel()
|
||||
nearbyNotesController.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@ -18,7 +19,6 @@ import kotlin.random.Random
|
||||
*/
|
||||
private enum class CharacterAnimationState {
|
||||
ENCRYPTED, // Showing random encrypted characters
|
||||
DECRYPTING, // Transitioning to final character
|
||||
FINAL // Showing final decrypted character
|
||||
}
|
||||
|
||||
@ -69,205 +69,111 @@ object PoWMiningTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced message display that shows matrix animation during PoW mining
|
||||
* Formats message like a normal message but animates only the content portion
|
||||
* Shows the active PoW animation inside the same two-row layout used by static text messages.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageWithMatrixAnimation(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isAnimating = shouldAnimateMessage(message.id)
|
||||
|
||||
if (isAnimating) {
|
||||
// During animation: Show formatted message with animated content
|
||||
AnimatedMessageDisplay(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
// After animation: Show complete normal message using existing formatter
|
||||
val annotatedText = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = modifier,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true
|
||||
)
|
||||
}
|
||||
AnimatedMessageDisplay(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display message with proper formatting but animated content
|
||||
* Uses IDENTICAL layout structure as normal message for pixel-perfect alignment
|
||||
* Animates only the body content; sender, metadata, gestures, and spacing remain stable.
|
||||
*/
|
||||
@Composable
|
||||
private fun AnimatedMessageDisplay(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
modifier: Modifier = Modifier
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Get the animated content text
|
||||
var animatedContent by remember(message.content) { mutableStateOf(message.content) }
|
||||
val isAnimating = shouldAnimateMessage(message.id)
|
||||
var animatedContent by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content)
|
||||
}
|
||||
|
||||
// Character-by-character animation state like the JavaScript version
|
||||
var characterStates by remember(message.content) {
|
||||
var characterStates by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content.map { char ->
|
||||
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
|
||||
})
|
||||
}
|
||||
|
||||
// Update animated content when animation state changes
|
||||
LaunchedEffect(isAnimating, message.content) {
|
||||
if (isAnimating && message.content.isNotEmpty()) {
|
||||
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
||||
|
||||
// Start character animations with staggered delays (like JS version)
|
||||
message.content.forEachIndexed { index, targetChar ->
|
||||
if (targetChar != ' ') { // Skip spaces
|
||||
launch {
|
||||
delay(index * 50L) // Stagger start like JS version
|
||||
|
||||
// Animate this character indefinitely in a loop
|
||||
while (true) {
|
||||
// Animate with random characters
|
||||
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
||||
// Generate random encrypted character for this position
|
||||
val newContent = animatedContent.toCharArray()
|
||||
if (index < newContent.size) {
|
||||
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
||||
animatedContent = String(newContent)
|
||||
}
|
||||
|
||||
delay(100L) // Change character every 100ms like JS
|
||||
|
||||
// Random chance to reveal (10% like JS version)
|
||||
if (Random.nextFloat() < 0.1f) {
|
||||
// Reveal the final character
|
||||
val finalContent = animatedContent.toCharArray()
|
||||
if (index < finalContent.size) {
|
||||
finalContent[index] = targetChar
|
||||
animatedContent = String(finalContent)
|
||||
}
|
||||
|
||||
// Mark as revealed
|
||||
val finalStates = characterStates.toMutableList()
|
||||
finalStates[index] = CharacterAnimationState.FINAL
|
||||
characterStates = finalStates
|
||||
break
|
||||
}
|
||||
LaunchedEffect(message.id, message.content) {
|
||||
if (message.content.isEmpty()) return@LaunchedEffect
|
||||
|
||||
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
||||
|
||||
// Start character animations with staggered delays (like JS version).
|
||||
message.content.forEachIndexed { index, targetChar ->
|
||||
if (targetChar != ' ') {
|
||||
launch {
|
||||
delay(index * 50L)
|
||||
|
||||
while (true) {
|
||||
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
||||
val newContent = animatedContent.toCharArray()
|
||||
if (index < newContent.size) {
|
||||
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
||||
animatedContent = String(newContent)
|
||||
}
|
||||
|
||||
delay(100L)
|
||||
|
||||
if (Random.nextFloat() < 0.1f) {
|
||||
val finalContent = animatedContent.toCharArray()
|
||||
if (index < finalContent.size) {
|
||||
finalContent[index] = targetChar
|
||||
animatedContent = String(finalContent)
|
||||
}
|
||||
|
||||
val finalStates = characterStates.toMutableList()
|
||||
finalStates[index] = CharacterAnimationState.FINAL
|
||||
characterStates = finalStates
|
||||
break
|
||||
}
|
||||
|
||||
// Keep revealed for 2 seconds, then fade back to encrypted (like JS)
|
||||
delay(2000L)
|
||||
|
||||
// Reset back to encrypted for next cycle
|
||||
val resetStates = characterStates.toMutableList()
|
||||
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
||||
characterStates = resetStates
|
||||
}
|
||||
|
||||
delay(2000L)
|
||||
|
||||
val resetStates = characterStates.toMutableList()
|
||||
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
||||
characterStates = resetStates
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not animating, show final content
|
||||
animatedContent = message.content
|
||||
characterStates = message.content.map { CharacterAnimationState.FINAL }
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temporary message with animated content for formatting
|
||||
val animatedMessage = message.copy(content = animatedContent)
|
||||
|
||||
// Use formatting function without timestamp during animation
|
||||
val annotatedText = if (isAnimating) {
|
||||
formatMessageAsAnnotatedStringWithoutTimestamp(
|
||||
message = animatedMessage,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
} else {
|
||||
formatMessageAsAnnotatedString(
|
||||
message = animatedMessage,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
}
|
||||
|
||||
// Use IDENTICAL Text composable structure as normal message
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = modifier,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format message without timestamp and PoW badge for animation phase
|
||||
* Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge
|
||||
*/
|
||||
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme
|
||||
): AnnotatedString {
|
||||
// Get the full formatted text first
|
||||
val timeFormatter = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
|
||||
val fullText = formatMessageAsAnnotatedString(
|
||||
TextMessageLayout(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
bodyContent = animatedContent,
|
||||
)
|
||||
|
||||
// Find and remove the timestamp and PoW badge at the end
|
||||
val text = fullText.text
|
||||
val timestampPattern = """ \[\d{2}:\d{2}:\d{2}].*$""".toRegex() // Matches " [HH:mm:ss] 12b" or just " [HH:mm:ss]"
|
||||
val match = timestampPattern.find(text)
|
||||
|
||||
return if (match != null) {
|
||||
// Remove timestamp and PoW portion
|
||||
val endIndex = match.range.first
|
||||
AnnotatedString(
|
||||
text = text.substring(0, endIndex),
|
||||
spanStyles = fullText.spanStyles.filter { it.end <= endIndex },
|
||||
paragraphStyles = fullText.paragraphStyles.filter { it.end <= endIndex }
|
||||
)
|
||||
} else {
|
||||
fullText
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,26 @@ import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||
import java.util.Date
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class LegacyPrivateMediaConsentRequest(
|
||||
val requestId: String,
|
||||
val recipientNickname: String,
|
||||
val fileName: String,
|
||||
val warning: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles media file sending operations (voice notes, images, generic files)
|
||||
@ -16,6 +34,8 @@ class MediaSendingManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val channelManager: ChannelManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val getMeshService: () -> MeshService
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
@ -24,35 +44,78 @@ class MediaSendingManager(
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
|
||||
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
|
||||
// Track in-flight transfer progress: transferId -> messageId and reverse
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
private val messageTransferMap = mutableMapOf<String, String>()
|
||||
private val pendingConsentLock = Any()
|
||||
private val _legacyPrivateMediaConsent = MutableStateFlow<LegacyPrivateMediaConsentRequest?>(null)
|
||||
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
|
||||
_legacyPrivateMediaConsent.asStateFlow()
|
||||
|
||||
private data class PendingPrivateMedia(
|
||||
val request: LegacyPrivateMediaConsentRequest,
|
||||
val peerID: String,
|
||||
val filePacket: BitchatFilePacket,
|
||||
val filePath: String,
|
||||
val messageType: BitchatMessageType,
|
||||
val transferId: String
|
||||
)
|
||||
|
||||
private var pendingPrivateMedia: PendingPrivateMedia? = null
|
||||
|
||||
private data class PendingAutomaticPrivateMedia(
|
||||
val requestId: String,
|
||||
val peerID: String,
|
||||
val filePacket: BitchatFilePacket,
|
||||
val filePath: String,
|
||||
val messageType: BitchatMessageType,
|
||||
val transferId: String,
|
||||
val allowLegacyFallback: Boolean
|
||||
)
|
||||
|
||||
private var pendingAutomaticPrivateMedia: PendingAutomaticPrivateMedia? = null
|
||||
private var evaluatingAutomaticRequestId: String? = null
|
||||
private var automaticRetryRequestedFor: String? = null
|
||||
private var pendingAutomaticTimeoutRequestId: String? = null
|
||||
|
||||
/**
|
||||
* Send a voice note (audio file)
|
||||
*/
|
||||
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
try {
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "audio/mp4",
|
||||
content = file.readBytes()
|
||||
)
|
||||
private suspend fun sendVoiceNoteAsync(
|
||||
toPeerIDOrNull: String?,
|
||||
channelOrNull: String?,
|
||||
filePath: String
|
||||
) {
|
||||
try {
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "audio/mp4",
|
||||
content = file.readBytes()
|
||||
)
|
||||
} ?: return
|
||||
|
||||
if (toPeerIDOrNull != null) {
|
||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
|
||||
@ -68,26 +131,38 @@ class MediaSendingManager(
|
||||
* Send an image file
|
||||
*/
|
||||
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
try {
|
||||
Log.d(TAG, "🔄 Starting image send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "image/jpeg",
|
||||
content = file.readBytes()
|
||||
)
|
||||
private suspend fun sendImageNoteAsync(
|
||||
toPeerIDOrNull: String?,
|
||||
channelOrNull: String?,
|
||||
filePath: String
|
||||
) {
|
||||
try {
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
Log.d(TAG, "🔄 Starting image send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "image/jpeg",
|
||||
content = file.readBytes()
|
||||
)
|
||||
} ?: return
|
||||
|
||||
if (toPeerIDOrNull != null) {
|
||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
|
||||
@ -106,49 +181,67 @@ class MediaSendingManager(
|
||||
* Send a generic file
|
||||
*/
|
||||
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
scope.launch {
|
||||
sendFileNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendFileNoteAsync(
|
||||
toPeerIDOrNull: String?,
|
||||
channelOrNull: String?,
|
||||
filePath: String
|
||||
) {
|
||||
try {
|
||||
Log.d(TAG, "🔄 Starting file send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
Log.d(TAG, "🔄 Starting file send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
// Use the real MIME type based on extension; fallback to octet-stream
|
||||
val mimeType = try {
|
||||
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
|
||||
} catch (_: Exception) {
|
||||
"application/octet-stream"
|
||||
}
|
||||
Log.d(TAG, "🏷️ MIME type: $mimeType")
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Try to preserve the original file name if our copier prefixed it earlier
|
||||
val originalName = run {
|
||||
val name = file.name
|
||||
val base = name.substringBeforeLast('.')
|
||||
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
|
||||
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
|
||||
stripped + ext
|
||||
}
|
||||
Log.d(TAG, "📝 Original filename: $originalName")
|
||||
// Use the real MIME type based on extension; fallback to octet-stream
|
||||
val mimeType = try {
|
||||
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
|
||||
} catch (_: Exception) {
|
||||
"application/octet-stream"
|
||||
}
|
||||
Log.d(TAG, "🏷️ MIME type: $mimeType")
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = originalName,
|
||||
fileSize = file.length(),
|
||||
mimeType = mimeType,
|
||||
content = file.readBytes()
|
||||
)
|
||||
// Try to preserve the original file name if our copier prefixed it earlier
|
||||
val originalName = run {
|
||||
val name = file.name
|
||||
val base = name.substringBeforeLast('.')
|
||||
val ext = name.substringAfterLast('.', "").let {
|
||||
if (it.isNotBlank()) ".${it}" else ""
|
||||
}
|
||||
val stripped = Regex("^send_\\d+_(.+)$")
|
||||
.matchEntire(base)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?: base
|
||||
stripped + ext
|
||||
}
|
||||
Log.d(TAG, "📝 Original filename: $originalName")
|
||||
|
||||
BitchatFilePacket(
|
||||
fileName = originalName,
|
||||
fileSize = file.length(),
|
||||
mimeType = mimeType,
|
||||
content = file.readBytes()
|
||||
)
|
||||
} ?: return
|
||||
Log.d(TAG, "📦 Created file packet successfully")
|
||||
|
||||
val messageType = when {
|
||||
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
|
||||
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
|
||||
filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
|
||||
filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
|
||||
else -> BitchatMessageType.File
|
||||
}
|
||||
|
||||
@ -168,26 +261,324 @@ class MediaSendingManager(
|
||||
/**
|
||||
* Send a file privately (encrypted)
|
||||
*/
|
||||
private fun sendPrivateFile(
|
||||
private suspend fun sendPrivateFile(
|
||||
toPeerID: String,
|
||||
filePacket: BitchatFilePacket,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType
|
||||
) {
|
||||
val payload = filePacket.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return
|
||||
}
|
||||
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
|
||||
?: run {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
|
||||
|
||||
val transferId = sha256Hex(payload)
|
||||
val contentHash = sha256Hex(filePacket.content)
|
||||
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload) to sha256Hex(filePacket.content)
|
||||
}
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…")
|
||||
|
||||
val pending = PendingAutomaticPrivateMedia(
|
||||
requestId = UUID.randomUUID().toString(),
|
||||
peerID = toPeerID,
|
||||
filePacket = filePacket,
|
||||
filePath = filePath,
|
||||
messageType = messageType,
|
||||
transferId = transferId,
|
||||
allowLegacyFallback = false
|
||||
)
|
||||
if (!reserveAutomaticPending(pending)) {
|
||||
addPrivateMediaSystemMessage(
|
||||
toPeerID,
|
||||
"Private media was not sent because another secure media send is still pending."
|
||||
)
|
||||
return
|
||||
}
|
||||
evaluateAutomaticPending(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume consent exactly once, then re-run policy and final-packet
|
||||
* admission. A capability pin appearing while the dialog was open upgrades
|
||||
* this send to encrypted rather than forcing the legacy path.
|
||||
*/
|
||||
fun approveLegacyPrivateMedia(requestId: String) {
|
||||
scope.launch {
|
||||
approveLegacyPrivateMediaAsync(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun approveLegacyPrivateMediaAsync(requestId: String) {
|
||||
val pending = consumePendingConsent(requestId) ?: return
|
||||
val automatic = PendingAutomaticPrivateMedia(
|
||||
requestId = UUID.randomUUID().toString(),
|
||||
peerID = pending.peerID,
|
||||
filePacket = pending.filePacket,
|
||||
filePath = pending.filePath,
|
||||
messageType = pending.messageType,
|
||||
transferId = pending.transferId,
|
||||
allowLegacyFallback = true
|
||||
)
|
||||
if (!reserveAutomaticPending(automatic)) {
|
||||
addPrivateMediaSystemMessage(
|
||||
pending.peerID,
|
||||
"Private media was not sent because another secure media send is still pending."
|
||||
)
|
||||
return
|
||||
}
|
||||
evaluateAutomaticPending(automatic)
|
||||
}
|
||||
|
||||
fun cancelLegacyPrivateMedia(requestId: String) {
|
||||
consumePendingConsent(requestId)
|
||||
}
|
||||
|
||||
fun clearPendingPrivateMediaConsent() {
|
||||
synchronized(pendingConsentLock) {
|
||||
pendingPrivateMedia = null
|
||||
pendingAutomaticPrivateMedia = null
|
||||
evaluatingAutomaticRequestId = null
|
||||
automaticRetryRequestedFor = null
|
||||
pendingAutomaticTimeoutRequestId = null
|
||||
_legacyPrivateMediaConsent.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry the exact first-send intent after peer-state proof or watchdog resolution. */
|
||||
fun retryPendingPrivateMedia(peerID: String) {
|
||||
scope.launch { retryPendingPrivateMediaOnScope(peerID) }
|
||||
}
|
||||
|
||||
private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
|
||||
val pending = synchronized(pendingConsentLock) {
|
||||
pendingAutomaticPrivateMedia
|
||||
?.takeIf { it.peerID == peerID }
|
||||
} ?: return
|
||||
evaluateAutomaticPending(pending)
|
||||
}
|
||||
|
||||
private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
|
||||
val acquired = synchronized(pendingConsentLock) {
|
||||
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) {
|
||||
return@synchronized false
|
||||
}
|
||||
if (evaluatingAutomaticRequestId == pending.requestId) {
|
||||
automaticRetryRequestedFor = pending.requestId
|
||||
return@synchronized false
|
||||
}
|
||||
evaluatingAutomaticRequestId = pending.requestId
|
||||
true
|
||||
}
|
||||
if (!acquired) return
|
||||
|
||||
while (true) {
|
||||
val preparation = try {
|
||||
withContext(mediaWorkDispatcher) {
|
||||
meshService.prepareFilePrivate(
|
||||
recipientPeerID = pending.peerID,
|
||||
file = pending.filePacket,
|
||||
transferId = pending.transferId,
|
||||
allowLegacyFallback = pending.allowLegacyFallback
|
||||
)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
PrivateMediaPreparation.Rejected(
|
||||
error.message ?: "Secure private-media preparation failed"
|
||||
)
|
||||
}
|
||||
val stillCurrent = synchronized(pendingConsentLock) {
|
||||
pendingAutomaticPrivateMedia?.requestId == pending.requestId
|
||||
}
|
||||
if (stillCurrent) handlePrivatePreparation(preparation, pending)
|
||||
|
||||
val rerun = synchronized(pendingConsentLock) {
|
||||
if (evaluatingAutomaticRequestId == pending.requestId) {
|
||||
evaluatingAutomaticRequestId = null
|
||||
}
|
||||
val requested = automaticRetryRequestedFor == pending.requestId &&
|
||||
pendingAutomaticPrivateMedia?.requestId == pending.requestId
|
||||
if (automaticRetryRequestedFor == pending.requestId) {
|
||||
automaticRetryRequestedFor = null
|
||||
}
|
||||
if (requested) evaluatingAutomaticRequestId = pending.requestId
|
||||
requested
|
||||
}
|
||||
if (!rerun) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePrivatePreparation(
|
||||
preparation: PrivateMediaPreparation,
|
||||
pending: PendingAutomaticPrivateMedia
|
||||
) {
|
||||
when (preparation) {
|
||||
is PrivateMediaPreparation.Ready -> {
|
||||
clearAutomaticPending(pending.requestId)
|
||||
commitPreparedPrivateFile(
|
||||
preparation,
|
||||
pending.peerID,
|
||||
pending.filePath,
|
||||
pending.messageType,
|
||||
pending.transferId
|
||||
)
|
||||
}
|
||||
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent -> {
|
||||
clearAutomaticPending(pending.requestId)
|
||||
if (pending.allowLegacyFallback) {
|
||||
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
|
||||
addPrivateMediaSystemMessage(
|
||||
pending.peerID,
|
||||
"Private media was not sent because its security policy changed."
|
||||
)
|
||||
return
|
||||
}
|
||||
val nickname = try {
|
||||
meshService.getPeerNicknames()[pending.peerID]
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: pending.peerID.take(8)
|
||||
val request = LegacyPrivateMediaConsentRequest(
|
||||
requestId = UUID.randomUUID().toString(),
|
||||
recipientNickname = nickname,
|
||||
fileName = pending.filePacket.fileName,
|
||||
warning = preparation.warning
|
||||
)
|
||||
synchronized(pendingConsentLock) {
|
||||
if (pendingPrivateMedia != null) {
|
||||
Log.w(TAG, "A legacy private-media consent prompt is already pending")
|
||||
return
|
||||
}
|
||||
pendingPrivateMedia = PendingPrivateMedia(
|
||||
request,
|
||||
pending.peerID,
|
||||
pending.filePacket,
|
||||
pending.filePath,
|
||||
pending.messageType,
|
||||
pending.transferId
|
||||
)
|
||||
_legacyPrivateMediaConsent.value = request
|
||||
}
|
||||
}
|
||||
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
ensureAutomaticPendingTimeout(pending)
|
||||
Log.i(TAG, "Private media needs a Noise handshake; retaining first-send intent")
|
||||
try {
|
||||
meshService.initiateNoiseHandshake(pending.peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
PrivateMediaPreparation.AwaitingPeerState -> {
|
||||
ensureAutomaticPendingTimeout(pending)
|
||||
Log.i(TAG, "Private media is waiting for authenticated peer state; first-send intent retained")
|
||||
}
|
||||
|
||||
is PrivateMediaPreparation.Rejected -> {
|
||||
clearAutomaticPending(pending.requestId)
|
||||
Log.w(TAG, "Private media not sent: ${preparation.reason}")
|
||||
addPrivateMediaSystemMessage(
|
||||
pending.peerID,
|
||||
"Private media was not sent: ${preparation.reason}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reserveAutomaticPending(pending: PendingAutomaticPrivateMedia): Boolean =
|
||||
synchronized(pendingConsentLock) {
|
||||
if (pendingAutomaticPrivateMedia != null) return@synchronized false
|
||||
pendingAutomaticPrivateMedia = pending
|
||||
true
|
||||
}
|
||||
|
||||
private fun ensureAutomaticPendingTimeout(pending: PendingAutomaticPrivateMedia) {
|
||||
val shouldStart = synchronized(pendingConsentLock) {
|
||||
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId ||
|
||||
pendingAutomaticTimeoutRequestId == pending.requestId
|
||||
) return@synchronized false
|
||||
pendingAutomaticTimeoutRequestId = pending.requestId
|
||||
true
|
||||
}
|
||||
if (!shouldStart) return
|
||||
scope.launch {
|
||||
delay(PENDING_PRIVATE_MEDIA_TIMEOUT_MS)
|
||||
val expired = synchronized(pendingConsentLock) {
|
||||
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) false
|
||||
else {
|
||||
pendingAutomaticPrivateMedia = null
|
||||
if (automaticRetryRequestedFor == pending.requestId) {
|
||||
automaticRetryRequestedFor = null
|
||||
}
|
||||
if (pendingAutomaticTimeoutRequestId == pending.requestId) {
|
||||
pendingAutomaticTimeoutRequestId = null
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
if (expired) {
|
||||
addPrivateMediaSystemMessage(
|
||||
pending.peerID,
|
||||
"Private media was not sent because secure session setup timed out."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAutomaticPending(requestId: String) {
|
||||
synchronized(pendingConsentLock) {
|
||||
if (pendingAutomaticPrivateMedia?.requestId == requestId) {
|
||||
pendingAutomaticPrivateMedia = null
|
||||
}
|
||||
if (automaticRetryRequestedFor == requestId) automaticRetryRequestedFor = null
|
||||
if (pendingAutomaticTimeoutRequestId == requestId) {
|
||||
pendingAutomaticTimeoutRequestId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPrivateMediaSystemMessage(peerID: String, text: String) {
|
||||
messageManager.addPrivateMessageNoUnread(
|
||||
peerID,
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
senderPeerID = peerID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun consumePendingConsent(requestId: String): PendingPrivateMedia? {
|
||||
return synchronized(pendingConsentLock) {
|
||||
val pending = pendingPrivateMedia
|
||||
if (pending?.request?.requestId != requestId) return@synchronized null
|
||||
pendingPrivateMedia = null
|
||||
_legacyPrivateMediaConsent.value = null
|
||||
pending
|
||||
}
|
||||
}
|
||||
|
||||
private fun commitPreparedPrivateFile(
|
||||
preparation: PrivateMediaPreparation.Ready,
|
||||
toPeerID: String,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType,
|
||||
transferId: String
|
||||
) {
|
||||
if (preparation.transfer.transferId != transferId) {
|
||||
Log.e(TAG, "Prepared private-media transfer ID changed; send aborted")
|
||||
return
|
||||
}
|
||||
|
||||
val msg = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
|
||||
id = UUID.randomUUID().toString().uppercase(),
|
||||
sender = state.getNicknameValue() ?: "me",
|
||||
content = filePath,
|
||||
type = messageType,
|
||||
@ -197,43 +588,54 @@ class MediaSendingManager(
|
||||
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
|
||||
senderPeerID = meshService.myPeerID
|
||||
)
|
||||
|
||||
|
||||
// Preparation already built and admitted the exact final packet. Map
|
||||
// progress before commit so the first asynchronous event cannot race us.
|
||||
messageManager.addPrivateMessage(toPeerID, msg)
|
||||
|
||||
synchronized(transferMessageMap) {
|
||||
transferMessageMap[transferId] = msg.id
|
||||
messageTransferMap[msg.id] = transferId
|
||||
}
|
||||
|
||||
// Seed progress so delivery icons render for media
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
msg.id,
|
||||
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
|
||||
)
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
|
||||
meshService.sendFilePrivate(toPeerID, filePacket)
|
||||
Log.d(TAG, "✅ File send completed successfully")
|
||||
|
||||
if (!preparation.transfer.commit()) {
|
||||
messageManager.removeMessageById(msg.id)
|
||||
synchronized(transferMessageMap) {
|
||||
transferMessageMap.remove(transferId)
|
||||
messageTransferMap.remove(msg.id)
|
||||
}
|
||||
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
|
||||
addPrivateMediaSystemMessage(
|
||||
toPeerID,
|
||||
"Private media was not sent because the prepared transfer could not be committed."
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file publicly (broadcast or channel)
|
||||
*/
|
||||
private fun sendPublicFile(
|
||||
private suspend fun sendPublicFile(
|
||||
channelOrNull: String?,
|
||||
filePacket: BitchatFilePacket,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType
|
||||
) {
|
||||
val payload = filePacket.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
|
||||
return
|
||||
}
|
||||
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
|
||||
?: run {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
|
||||
|
||||
val transferId = sha256Hex(payload)
|
||||
val contentHash = sha256Hex(filePacket.content)
|
||||
|
||||
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload) to sha256Hex(filePacket.content)
|
||||
}
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…")
|
||||
|
||||
@ -266,7 +668,9 @@ class MediaSendingManager(
|
||||
)
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
|
||||
meshService.sendFileBroadcast(filePacket)
|
||||
withContext(mediaWorkDispatcher) {
|
||||
meshService.sendFileBroadcast(filePacket)
|
||||
}
|
||||
Log.d(TAG, "✅ File broadcast completed successfully")
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import com.bitchat.android.ui.NotificationTextUtils
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
@ -26,7 +27,6 @@ class MeshDelegateHandler(
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
coroutineScope.launch {
|
||||
// FIXED: Deduplicate messages from dual connection paths
|
||||
val messageKey = messageManager.generateMessageKey(message)
|
||||
if (messageManager.isMessageProcessed(messageKey)) {
|
||||
return@launch // Duplicate message, ignore
|
||||
@ -111,88 +111,31 @@ class MeshDelegateHandler(
|
||||
// Clean up channel members who disconnected
|
||||
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
|
||||
|
||||
// Handle chat view migration based on current selection and new peer list
|
||||
runCatching { com.bitchat.android.services.AppStateStore.canonicalizePrivateChats() }
|
||||
|
||||
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
||||
val isNostrAlias = currentPeer.startsWith("nostr_")
|
||||
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
|
||||
if (isNostrAlias || isNoiseHex) {
|
||||
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
||||
// Resolve canonical target (prefer connected mesh peer if available)
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = currentPeer,
|
||||
connectedPeers = mergedPeers,
|
||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mergedPeers.contains(pid) },
|
||||
nostrPubHexForAlias = { alias ->
|
||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||
} else {
|
||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||
val prefix = alias.removePrefix("nostr_")
|
||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
||||
favs.firstNotNullOfOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { s ->
|
||||
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
||||
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
||||
}
|
||||
}
|
||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
}
|
||||
},
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
val canonical = ContactDirectory.canonicalConversationId(currentPeer)
|
||||
if (canonical != currentPeer) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = canonical,
|
||||
keysToMerge = ContactDirectory.aliasesForConversation(currentPeer).toList()
|
||||
)
|
||||
if (canonical != currentPeer) {
|
||||
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
|
||||
state.setSelectedPrivateChatPeer(canonical)
|
||||
}
|
||||
} else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) {
|
||||
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
|
||||
val favoriteRel = try {
|
||||
val info = getPeerInfo(currentPeer)
|
||||
val noiseKey = info?.noisePublicKey
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
if (favoriteRel?.isMutual == true) {
|
||||
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
if (noiseHex != currentPeer) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = noiseHex,
|
||||
keysToMerge = listOf(currentPeer)
|
||||
)
|
||||
state.setSelectedPrivateChatPeer(noiseHex)
|
||||
}
|
||||
} else {
|
||||
privateChatManager.cleanupDisconnectedPeer(currentPeer)
|
||||
}
|
||||
state.setSelectedPrivateChatPeer(canonical)
|
||||
}
|
||||
}
|
||||
|
||||
state.getPrivateChatSheetPeerValue()?.let { sheetPeer ->
|
||||
val canonical = ContactDirectory.canonicalConversationId(sheetPeer)
|
||||
if (canonical != sheetPeer) {
|
||||
state.setPrivateChatSheetPeer(canonical)
|
||||
}
|
||||
}
|
||||
|
||||
// Global unification: for each connected peer, merge any offline/stable conversations
|
||||
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
|
||||
mergedPeers.forEach { pid ->
|
||||
try {
|
||||
val info = getPeerInfo(pid)
|
||||
val noiseKey = info?.noisePublicKey ?: return@forEach
|
||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
// Derive temp nostr key from favorites npub
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||
val tempNostrKey: String? = try {
|
||||
if (npub != null) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
||||
val canonical = ContactDirectory.canonicalConversationId(pid)
|
||||
unifyChatsIntoPeer(canonical, ContactDirectory.aliasesForConversation(pid).toList())
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
@ -294,16 +237,30 @@ class MeshDelegateHandler(
|
||||
|
||||
// Send read receipt if user is currently focused on this specific chat
|
||||
val senderPeerID = message.senderPeerID
|
||||
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
|
||||
val senderConversationID = senderPeerID?.let { ContactDirectory.canonicalConversationId(it) }
|
||||
val focusedConversationID = currentPrivateChatPeer?.let { ContactDirectory.canonicalConversationId(it) }
|
||||
val shouldSendReadReceipt = !isAppInBackground &&
|
||||
senderConversationID != null &&
|
||||
focusedConversationID == senderConversationID
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||
android.util.Log.d(
|
||||
"MeshDelegateHandler",
|
||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
||||
)
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
val mesh = getMeshService()
|
||||
val sent = try {
|
||||
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
|
||||
if (hasMesh) {
|
||||
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
|
||||
val meshPeerID = senderConversationID
|
||||
?.let { ContactDirectory.resolve(it).meshPeerID }
|
||||
?: senderPeerID?.takeIf {
|
||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||
}
|
||||
if (meshPeerID != null &&
|
||||
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(meshPeerID)
|
||||
) {
|
||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@ -315,7 +272,8 @@ class MeshDelegateHandler(
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
if (current.remove(senderPeerID)) {
|
||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||
if (changed) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@ -324,9 +282,6 @@ class MeshDelegateHandler(
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
/**
|
||||
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
|
||||
*/
|
||||
|
||||
@ -18,6 +18,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@ -30,10 +31,16 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
|
||||
|
||||
/**
|
||||
@ -286,6 +293,11 @@ fun PeopleSection(
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val identityStateManager = remember(context) {
|
||||
SecureIdentityStateManager(context.applicationContext)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.people).uppercase(),
|
||||
@ -322,9 +334,8 @@ fun PeopleSection(
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
// Reactive favorite computation - same as ChatHeader
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -334,13 +345,35 @@ fun PeopleSection(
|
||||
}
|
||||
}
|
||||
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
// Build mapping of connected peerID -> Noise key hex to unify with offline favorites.
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
|
||||
?: identityStateManager.getCachedNoiseKey(pid)
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
val nostrHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
FavoritesPersistenceService.shared
|
||||
.findNostrPubkeyForPeerID(pid)
|
||||
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
val connectedNoiseHexes = noiseHexByPeerID.values.map { it.lowercase() }.toSet()
|
||||
val connectedNostrHexes = nostrHexByPeerID.values.map { it.lowercase() }.toSet()
|
||||
|
||||
fun isFavoriteMappedToConnected(favorite: FavoriteRelationship): Boolean {
|
||||
val noiseHex = ContactIdentityResolver.noiseKeyHex(favorite.peerNoisePublicKey).lowercase()
|
||||
if (connectedNoiseHexes.contains(noiseHex)) return true
|
||||
|
||||
val nostrHex = favorite.peerNostrPublicKey
|
||||
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
|
||||
?.lowercase()
|
||||
return nostrHex != null && connectedNostrHexes.contains(nostrHex)
|
||||
}
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
@ -369,11 +402,10 @@ fun PeopleSection(
|
||||
}
|
||||
|
||||
// Offline favorites (exclude ones mapped to connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (!isMappedToConnected) {
|
||||
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
||||
if (!isFavoriteMappedToConnected(fav)) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (b, _) = splitSuffix(dn)
|
||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||
@ -382,12 +414,11 @@ fun PeopleSection(
|
||||
|
||||
// Nostr-only conversations
|
||||
val connectedIds = sortedPeers.toSet()
|
||||
val appendedOfflineIds = mutableSetOf<String>()
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
||||
!connectedIds.contains(key) &&
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
!connectedNoiseHexes.contains(key.lowercase())
|
||||
}
|
||||
.forEach { convKey ->
|
||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
||||
@ -396,16 +427,17 @@ fun PeopleSection(
|
||||
}
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
|
||||
val meshUnread = hasUnreadPrivateMessages.contains(conversationID) || hasUnreadPrivateMessages.contains(peerID)
|
||||
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
|
||||
val combinedHasUnread = meshUnread || nostrUnread
|
||||
val combinedUnreadCount = (
|
||||
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
||||
privateChats[conversationID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
||||
) + (
|
||||
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
|
||||
)
|
||||
@ -421,7 +453,7 @@ fun PeopleSection(
|
||||
displayName = displayName,
|
||||
isDirect = isDirectLive,
|
||||
isWifiAware = peerID in wifiAwarePeerIDs,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
@ -440,29 +472,19 @@ fun PeopleSection(
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
||||
if (isFavoriteMappedToConnected(fav)) return@forEach
|
||||
|
||||
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||
val nostrConvKey: String? = try {
|
||||
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
hex?.let { "nostr_${it.take(16)}" }
|
||||
} else null
|
||||
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||
val conversationID = ContactDirectory.canonicalConversationId(favPeerID)
|
||||
val hasUnread = hasUnreadPrivateMessages.contains(conversationID) ||
|
||||
hasUnreadPrivateMessages.contains(favPeerID) ||
|
||||
(nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||
|
||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
||||
// open chat with the connected peerID instead of the noise hex for a seamless window
|
||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (bName, _) = splitSuffix(dn)
|
||||
@ -470,9 +492,8 @@ fun PeopleSection(
|
||||
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||
privateChats[conversationID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(conversationID) } ?: 0
|
||||
) + (
|
||||
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||
)
|
||||
@ -481,7 +502,7 @@ fun PeopleSection(
|
||||
peerID = favPeerID,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = hasUnread,
|
||||
@ -496,51 +517,7 @@ fun PeopleSection(
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
appendedOfflineIds.add(favPeerID)
|
||||
}
|
||||
|
||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||
/*
|
||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||
privateChats.keys
|
||||
.filter { key ->
|
||||
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||
hex64Regex.matches(key) &&
|
||||
!alreadyShownIds.contains(key) &&
|
||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
}
|
||||
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
|
||||
.forEach { convKey ->
|
||||
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
|
||||
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
|
||||
val (bName, _) = splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(convKey) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
||||
unreadCount = privateChats[convKey]?.count { msg ->
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
*/
|
||||
// End intentional removal
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -788,7 +765,11 @@ fun PrivateChatSheet(
|
||||
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val isWifiAware = peerID in wifiAwareConnected.keys
|
||||
val contactResolution = remember(peerID, connectedPeers, favoritePeers) {
|
||||
ContactDirectory.resolve(peerID)
|
||||
}
|
||||
val activeMeshPeerID = contactResolution.meshPeerID
|
||||
val isWifiAware = activeMeshPeerID in wifiAwareConnected.keys || peerID in wifiAwareConnected.keys
|
||||
|
||||
// Start private chat when screen opens
|
||||
LaunchedEffect(peerID) {
|
||||
@ -796,10 +777,27 @@ fun PrivateChatSheet(
|
||||
}
|
||||
|
||||
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
val favoriteRelationship = remember(peerID, favoritePeers) {
|
||||
try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDirect = activeMeshPeerID?.let { peerDirectMap[it] } == true || peerDirectMap[peerID] == true
|
||||
val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect
|
||||
val isNostrReachableFavorite =
|
||||
!isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null
|
||||
|
||||
// Compute display name and title text reactively
|
||||
val displayName = peerNicknames[peerID] ?: peerID.take(12)
|
||||
val titleText = remember(peerID, peerNicknames) {
|
||||
val displayName = remember(peerID, peerNicknames, favoriteRelationship) {
|
||||
peerNicknames[peerID]
|
||||
?: activeMeshPeerID?.let { peerNicknames[it] }
|
||||
?: contactResolution.displayName
|
||||
?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: viewModel.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
val titleText = remember(peerID, peerNicknames, favoriteRelationship) {
|
||||
if (isNostrPeer) {
|
||||
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
|
||||
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
|
||||
@ -810,16 +808,17 @@ fun PrivateChatSheet(
|
||||
}
|
||||
"#$gh/@$name"
|
||||
} else {
|
||||
peerNicknames[peerID] ?: peerID.take(12)
|
||||
displayName
|
||||
}
|
||||
}
|
||||
|
||||
val messages = privateChats[peerID] ?: emptyList()
|
||||
val isDirect = peerDirectMap[peerID] == true
|
||||
val isConnected = connectedPeers.contains(peerID) || isDirect
|
||||
val sessionState = peerSessionStates[peerID]
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
val isFavorite = remember(favoritePeers, fingerprint) {
|
||||
val conversationID = contactResolution.conversationID
|
||||
val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList()
|
||||
val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID]
|
||||
val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] }
|
||||
?: peerFingerprints[peerID]
|
||||
?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
|
||||
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
|
||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
||||
}
|
||||
|
||||
@ -827,7 +826,7 @@ fun PrivateChatSheet(
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
val securityModifier = if (!isNostrPeer) {
|
||||
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
@ -941,7 +940,7 @@ fun PrivateChatSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isNostrPeer -> {
|
||||
isNostrPeer || isNostrReachableFavorite -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
@ -981,14 +980,14 @@ fun PrivateChatSheet(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (isNostrPeer) Color(0xFFFF9500) else colorScheme.onSurface
|
||||
color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.then(securityModifier)
|
||||
) {
|
||||
if (!isNostrPeer) {
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
|
||||
@ -1,50 +1,57 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
@ -266,23 +273,21 @@ fun MessageItem(
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
AnnotatedClickableText(
|
||||
text = headerText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
// Try to load the file packet from the path
|
||||
@ -354,18 +359,16 @@ fun MessageItem(
|
||||
// Display message with matrix animation for content
|
||||
MessageWithMatrixAnimation(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onImageClick = onImageClick,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
// Normal message display
|
||||
} else if (message.sender == "system") {
|
||||
// Keep system messages on the compact legacy line.
|
||||
val annotatedText = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
@ -373,80 +376,12 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
|
||||
// Check if this message was sent by self to avoid click interactions on own nickname
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = modifier.pointerInput(message) {
|
||||
detectTapGestures(
|
||||
onTap = { position ->
|
||||
val layout = textLayoutResult ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
// Nickname click only when not self
|
||||
if (!isSelf && onNicknameClick != null) {
|
||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "nickname_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (nicknameAnnotations.isNotEmpty()) {
|
||||
val nickname = nicknameAnnotations.first().item
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(nickname)
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
// Geohash teleport (all messages)
|
||||
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "geohash_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (geohashAnnotations.isNotEmpty()) {
|
||||
val geohash = geohashAnnotations.first().item
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||
context
|
||||
)
|
||||
val level = when (geohash.length) {
|
||||
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||
}
|
||||
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
// URL open (all messages)
|
||||
val urlAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "url_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (urlAnnotations.isNotEmpty()) {
|
||||
val raw = urlAnnotations.first().item
|
||||
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
@ -458,8 +393,129 @@ fun MessageItem(
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
onTextLayout = { result -> textLayoutResult = result }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
TextMessageLayout(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TextMessageLayout(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyContent: String = message.content,
|
||||
) {
|
||||
val myPeerId = meshService.myPeerID
|
||||
val displayMessage = remember(message, bodyContent) {
|
||||
if (bodyContent == message.content) message else message.copy(content = bodyContent)
|
||||
}
|
||||
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
|
||||
formatTextMessageSender(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
}
|
||||
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
|
||||
formatTextMessageMetadata(
|
||||
message = message,
|
||||
timeFormatter = timeFormatter,
|
||||
)
|
||||
}
|
||||
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
|
||||
formatTextMessageBody(
|
||||
message = displayMessage,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val handleLongPress: () -> Unit = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnnotatedClickableText(
|
||||
text = senderText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = handleLongPress,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
AnnotatedClickableText(
|
||||
text = metadataText,
|
||||
annotationTags = emptyList(),
|
||||
onAnnotationClick = { _, _ -> false },
|
||||
onLongPress = handleLongPress,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
AnnotatedClickableText(
|
||||
text = bodyText,
|
||||
annotationTags = listOf("geohash_click", "url_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
when (tag) {
|
||||
"geohash_click" -> {
|
||||
navigateToGeohash(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
"url_click" -> {
|
||||
openMessageUrl(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
onLongPress = handleLongPress,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
internal fun BitchatMessage.isFromSelf(
|
||||
currentUserNickname: String,
|
||||
myPeerId: String,
|
||||
): Boolean =
|
||||
senderPeerID == myPeerId ||
|
||||
sender == currentUserNickname ||
|
||||
sender.startsWith("$currentUserNickname#")
|
||||
|
||||
internal fun normalizeMessageUrl(rawUrl: String): String =
|
||||
if (
|
||||
rawUrl.startsWith("http://", ignoreCase = true) ||
|
||||
rawUrl.startsWith("https://", ignoreCase = true)
|
||||
) {
|
||||
rawUrl
|
||||
} else {
|
||||
"https://$rawUrl"
|
||||
}
|
||||
|
||||
internal fun openMessageUrl(context: Context, rawUrl: String): Boolean =
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, normalizeMessageUrl(rawUrl).toUri())
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}.isSuccess
|
||||
|
||||
internal fun channelForGeohash(geohash: String): GeohashChannel {
|
||||
val level = when (geohash.length) {
|
||||
in 0..2 -> GeohashChannelLevel.REGION
|
||||
in 3..4 -> GeohashChannelLevel.PROVINCE
|
||||
5 -> GeohashChannelLevel.CITY
|
||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||
else -> GeohashChannelLevel.BLOCK
|
||||
}
|
||||
return GeohashChannel(level, geohash.lowercase())
|
||||
}
|
||||
|
||||
internal fun navigateToGeohash(context: Context, geohash: String): Boolean =
|
||||
runCatching {
|
||||
val locationManager = LocationChannelManager.getInstance(context)
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channelForGeohash(geohash)))
|
||||
}.isSuccess
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import java.util.*
|
||||
import java.util.Collections
|
||||
|
||||
@ -10,7 +11,7 @@ import java.util.Collections
|
||||
*/
|
||||
class MessageManager(private val state: ChatState) {
|
||||
|
||||
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
|
||||
// Message deduplication for duplicate deliveries from multiple local transports.
|
||||
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
||||
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
|
||||
@ -100,57 +101,63 @@ class MessageManager(private val state: ChatState) {
|
||||
// MARK: - Private Message Management
|
||||
|
||||
fun addPrivateMessage(peerID: String, message: BitchatMessage) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(peerID)) {
|
||||
currentPrivateChats[peerID] = mutableListOf()
|
||||
if (!currentPrivateChats.containsKey(conversationID)) {
|
||||
currentPrivateChats[conversationID] = mutableListOf()
|
||||
}
|
||||
|
||||
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
||||
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
currentPrivateChats[conversationID] = chatMessages
|
||||
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
|
||||
|
||||
// Mark as unread if not currently viewing this chat
|
||||
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
||||
if (state.getSelectedPrivateChatPeerValue() != conversationID && message.sender != state.getNicknameValue()) {
|
||||
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
currentUnread.add(peerID)
|
||||
currentUnread.add(conversationID)
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
}
|
||||
}
|
||||
|
||||
// Variant that does not mark unread (used when we know the message has been read already, e.g., persisted Nostr read store)
|
||||
fun addPrivateMessageNoUnread(peerID: String, message: BitchatMessage) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val currentPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
if (!currentPrivateChats.containsKey(peerID)) {
|
||||
currentPrivateChats[peerID] = mutableListOf()
|
||||
if (!currentPrivateChats.containsKey(conversationID)) {
|
||||
currentPrivateChats[conversationID] = mutableListOf()
|
||||
}
|
||||
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
||||
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
currentPrivateChats[conversationID] = chatMessages
|
||||
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun clearPrivateMessages(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
||||
updatedChats[peerID] = emptyList()
|
||||
updatedChats[conversationID] = emptyList()
|
||||
state.setPrivateChats(updatedChats)
|
||||
}
|
||||
|
||||
fun initializePrivateChat(peerID: String) {
|
||||
if (state.getPrivateChatsValue().containsKey(peerID)) return
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
if (state.getPrivateChatsValue().containsKey(conversationID)) return
|
||||
|
||||
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
||||
updatedChats[peerID] = emptyList()
|
||||
updatedChats[conversationID] = emptyList()
|
||||
state.setPrivateChats(updatedChats)
|
||||
}
|
||||
|
||||
fun clearPrivateUnreadMessages(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
updatedUnread.remove(peerID)
|
||||
updatedUnread.remove(conversationID)
|
||||
state.setUnreadPrivateMessages(updatedUnread)
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import androidx.core.app.Person
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.MainActivity
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@ -128,8 +129,8 @@ class NotificationManager(
|
||||
* Update current private chat peer - affects notification logic
|
||||
*/
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
currentPrivateChatPeer = peerID
|
||||
Log.d(TAG, "Current private chat peer changed: $peerID")
|
||||
currentPrivateChatPeer = peerID?.let { ContactDirectory.canonicalConversationId(it) }
|
||||
Log.d(TAG, "Current private chat peer changed: $currentPrivateChatPeer")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,28 +145,30 @@ class NotificationManager(
|
||||
* Show a notification for a private message with proper grouping and state awareness
|
||||
*/
|
||||
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
|
||||
// Only show notifications if app is in background OR user is not viewing this specific chat
|
||||
val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID)
|
||||
val shouldNotify = isAppInBackground ||
|
||||
(!isAppInBackground && currentPrivateChatPeer != conversationID)
|
||||
|
||||
if (!shouldNotify) {
|
||||
Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)")
|
||||
Log.d(TAG, "Showing notification for message from $senderNickname (conversationID: $conversationID)")
|
||||
|
||||
val notification = PendingNotification(
|
||||
senderPeerID = senderPeerID,
|
||||
senderPeerID = conversationID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = messageContent,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Add to pending notifications for this sender
|
||||
pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification)
|
||||
pendingNotifications.computeIfAbsent(conversationID) { mutableListOf() }.add(notification)
|
||||
|
||||
// Create or update notification for this sender
|
||||
showNotificationForSender(senderPeerID)
|
||||
showNotificationForSender(conversationID)
|
||||
|
||||
// Update summary notification if we have multiple senders
|
||||
if (pendingNotifications.size > 1) {
|
||||
@ -404,11 +407,15 @@ class NotificationManager(
|
||||
* Clear notifications for a specific sender (e.g., when user opens their chat)
|
||||
*/
|
||||
fun clearNotificationsForSender(senderPeerID: String) {
|
||||
pendingNotifications.remove(senderPeerID)
|
||||
|
||||
// Cancel the individual notification
|
||||
val notificationId = senderPeerID.hashCode()
|
||||
notificationManager.cancel(notificationId)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
|
||||
val matchingKeys = pendingNotifications.keys.filter { key ->
|
||||
ContactDirectory.canonicalConversationId(key) == conversationID
|
||||
}
|
||||
matchingKeys.forEach { key ->
|
||||
pendingNotifications.remove(key)
|
||||
notificationManager.cancel(key.hashCode())
|
||||
}
|
||||
notificationManager.cancel(conversationID.hashCode())
|
||||
|
||||
// Update or remove summary notification
|
||||
if (pendingNotifications.isEmpty()) {
|
||||
@ -421,7 +428,7 @@ class NotificationManager(
|
||||
showSummaryNotification()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Cleared notifications for sender: $senderPeerID")
|
||||
Log.d(TAG, "Cleared notifications for conversation: $conversationID")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.security.MessageDigest
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
|
||||
import java.util.*
|
||||
import android.util.Log
|
||||
@ -19,9 +21,13 @@ interface NoiseSessionDelegate {
|
||||
fun getMyPeerID(): String
|
||||
}
|
||||
|
||||
enum class PrivateMessageOrigin {
|
||||
MESH,
|
||||
NOSTR
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles private chat functionality including peer management and blocking
|
||||
* Now uses centralized PeerFingerprintManager for all fingerprint operations
|
||||
* Handles private chat functionality including peer management and blocking.
|
||||
*/
|
||||
class PrivateChatManager(
|
||||
private val state: ChatState,
|
||||
@ -34,7 +40,6 @@ class PrivateChatManager(
|
||||
private const val TAG = "PrivateChatManager"
|
||||
}
|
||||
|
||||
// Use centralized fingerprint management - NO LOCAL STORAGE
|
||||
private val fingerprintManager = PeerFingerprintManager.getInstance()
|
||||
|
||||
// Track received private messages that need read receipts
|
||||
@ -43,8 +48,12 @@ class PrivateChatManager(
|
||||
// MARK: - Private Chat Lifecycle
|
||||
|
||||
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val route = ContactDirectory.resolve(conversationID)
|
||||
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
|
||||
|
||||
if (isPeerBlocked(peerID)) {
|
||||
val peerNickname = getPeerNickname(peerID, meshService)
|
||||
val peerNickname = route.displayName ?: getPeerNickname(peerID, meshService)
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "cannot start chat with $peerNickname: user is blocked.",
|
||||
@ -55,24 +64,24 @@ class PrivateChatManager(
|
||||
return false
|
||||
}
|
||||
|
||||
// Establish Noise session if needed before starting the chat
|
||||
establishNoiseSessionIfNeeded(peerID, meshService)
|
||||
if (meshPeerID != null && meshService.getPeerInfo(meshPeerID)?.isConnected == true) {
|
||||
establishNoiseSessionIfNeeded(meshPeerID, meshService)
|
||||
}
|
||||
|
||||
// Consolidate any temporary Nostr conversation for this peer into the stable/current peerID
|
||||
try {
|
||||
consolidateNostrTempConversationIfNeeded(peerID)
|
||||
consolidateNostrTempConversationIfNeeded(conversationID, meshService)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
state.setSelectedPrivateChatPeer(peerID)
|
||||
state.setSelectedPrivateChatPeer(conversationID)
|
||||
|
||||
// Clear unread
|
||||
messageManager.clearPrivateUnreadMessages(peerID)
|
||||
messageManager.clearPrivateUnreadMessages(conversationID)
|
||||
|
||||
// Initialize chat if needed
|
||||
messageManager.initializePrivateChat(peerID)
|
||||
messageManager.initializePrivateChat(conversationID)
|
||||
|
||||
// Send read receipts for all unread messages from this peer
|
||||
sendReadReceiptsForPeer(peerID, meshService)
|
||||
sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -89,6 +98,7 @@ class PrivateChatManager(
|
||||
myPeerID: String,
|
||||
onSendMessage: (String, String, String, String) -> Unit
|
||||
): Boolean {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
if (isPeerBlocked(peerID)) {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
@ -111,8 +121,8 @@ class PrivateChatManager(
|
||||
deliveryStatus = DeliveryStatus.Sending
|
||||
)
|
||||
|
||||
messageManager.addPrivateMessage(peerID, message)
|
||||
onSendMessage(content, peerID, recipientNickname ?: "", message.id)
|
||||
messageManager.addPrivateMessage(conversationID, message)
|
||||
onSendMessage(content, conversationID, recipientNickname ?: "", message.id)
|
||||
|
||||
return true
|
||||
}
|
||||
@ -121,20 +131,19 @@ class PrivateChatManager(
|
||||
|
||||
fun isPeerBlocked(peerID: String): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
|
||||
?: ContactDirectory.resolve(peerID).noisePublicKey?.let { ContactIdentityResolver.fingerprintHex(it) }
|
||||
return fingerprint != null && dataManager.isUserBlocked(fingerprint)
|
||||
}
|
||||
|
||||
fun toggleFavorite(peerID: String) {
|
||||
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
|
||||
|
||||
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
|
||||
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
|
||||
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
if (fingerprint == null && ContactIdentityResolver.isNoiseKeyHex(peerID)) {
|
||||
try {
|
||||
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
val fpBytes = digest.digest(pubBytes)
|
||||
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
|
||||
val pubBytes = ContactIdentityResolver.bytesFromHex(peerID) ?: return
|
||||
fingerprint = ContactIdentityResolver.fingerprintHex(pubBytes)
|
||||
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
|
||||
@ -172,10 +181,26 @@ class PrivateChatManager(
|
||||
|
||||
|
||||
fun isFavorite(peerID: String): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
||||
val isFav = dataManager.isFavorite(fingerprint)
|
||||
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
|
||||
return isFav
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
?: ContactIdentityResolver.fingerprintFromContactConversationId(ContactDirectory.canonicalConversationId(peerID))
|
||||
?: if (ContactIdentityResolver.isNoiseKeyHex(peerID)) {
|
||||
ContactIdentityResolver.bytesFromHex(peerID)?.let { ContactIdentityResolver.fingerprintHex(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (fingerprint != null && dataManager.isFavorite(fingerprint)) {
|
||||
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=true")
|
||||
return true
|
||||
}
|
||||
|
||||
val persistedFavorite = try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isFavorite == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$persistedFavorite")
|
||||
return persistedFavorite
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? {
|
||||
@ -288,33 +313,44 @@ class PrivateChatManager(
|
||||
// MARK: - Message Handling
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage) {
|
||||
handleIncomingPrivateMessage(message, suppressUnread = false)
|
||||
handleIncomingPrivateMessage(
|
||||
message = message,
|
||||
suppressUnread = false,
|
||||
origin = PrivateMessageOrigin.MESH
|
||||
)
|
||||
}
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
||||
fun handleIncomingPrivateMessage(
|
||||
message: BitchatMessage,
|
||||
suppressUnread: Boolean,
|
||||
origin: PrivateMessageOrigin = PrivateMessageOrigin.MESH
|
||||
) {
|
||||
val senderPeerID = message.senderPeerID
|
||||
if (senderPeerID != null) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(senderPeerID)
|
||||
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
|
||||
if (!isPeerBlocked(senderPeerID)) {
|
||||
// Ensure chat exists
|
||||
messageManager.initializePrivateChat(senderPeerID)
|
||||
messageManager.initializePrivateChat(conversationID)
|
||||
|
||||
// Exception: Nostr messages (nostr_ prefix) originate in Kotlin layer and MUST be added here.
|
||||
if (senderPeerID.startsWith("nostr_")) {
|
||||
// Mesh messages are already reflected through AppStateStore by the mesh service.
|
||||
// Nostr messages originate here and must be added explicitly, even after their
|
||||
// sender alias has canonicalized to a contact_* conversation ID.
|
||||
if (origin == PrivateMessageOrigin.NOSTR) {
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
||||
messageManager.addPrivateMessageNoUnread(conversationID, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
messageManager.addPrivateMessage(conversationID, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Track as unread for read receipt purposes if not focused
|
||||
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
|
||||
unreadList.add(message)
|
||||
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
|
||||
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")
|
||||
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
currentUnread.add(senderPeerID)
|
||||
currentUnread.add(conversationID)
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
}
|
||||
}
|
||||
@ -333,24 +369,39 @@ class PrivateChatManager(
|
||||
* Send read receipts for all unread messages from a specific peer
|
||||
* Called when the user focuses on a private chat
|
||||
*/
|
||||
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
|
||||
fun sendReadReceiptsForPeer(
|
||||
conversationID: String,
|
||||
meshPeerID: String?,
|
||||
meshService: MeshService
|
||||
) {
|
||||
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
|
||||
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||
val messages = chats[peerID].orEmpty()
|
||||
val messages = chats[canonicalConversationID].orEmpty()
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
|
||||
Log.d(TAG, "No messages found for conversation $canonicalConversationID to send read receipts")
|
||||
}
|
||||
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
|
||||
val hasMesh = meshPeerID != null && try {
|
||||
meshService.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
meshService.hasEstablishedSession(meshPeerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
var sentCount = 0
|
||||
messages.forEach { msg ->
|
||||
// Only for incoming messages from this peer
|
||||
if (msg.senderPeerID == peerID) {
|
||||
val senderPeerID = msg.senderPeerID
|
||||
val isFromTarget = senderPeerID != null && (
|
||||
senderPeerID == meshPeerID ||
|
||||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
|
||||
)
|
||||
if (isFromTarget && meshPeerID != null) {
|
||||
try {
|
||||
if (hasMesh) {
|
||||
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
||||
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
|
||||
sentCount += 1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -360,10 +411,13 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
// Clear any locally tracked unread queue for this peer
|
||||
unreadReceivedMessages.remove(peerID)
|
||||
unreadReceivedMessages.remove(canonicalConversationID)
|
||||
// Also clear UI unread marker for this peer now that chat is focused/read
|
||||
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
|
||||
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
|
||||
try { messageManager.clearPrivateUnreadMessages(canonicalConversationID) } catch (_: Exception) { }
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sent $sentCount read receipts for conversation $canonicalConversationID via mesh peer $meshPeerID"
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanupDisconnectedPeer(peerID: String) {
|
||||
@ -426,65 +480,37 @@ class PrivateChatManager(
|
||||
|
||||
// MARK: - Consolidation
|
||||
|
||||
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String) {
|
||||
// If target is a mesh/noise-based peerID, merge any messages from its temp Nostr key
|
||||
if (targetPeerID.startsWith("nostr_")) return
|
||||
private fun consolidateNostrTempConversationIfNeeded(targetPeerID: String, meshService: MeshService) {
|
||||
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
|
||||
if (ContactIdentityResolver.isNostrAlias(targetPeerID)) return
|
||||
|
||||
// Find favorites mapping and corresponding temp key
|
||||
val tryMergeKeys = mutableListOf<String>()
|
||||
|
||||
// If we know the sender's Nostr pubkey for this peer via favorites, derive temp key
|
||||
try {
|
||||
val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes)
|
||||
if (npub != null) {
|
||||
// Normalize to hex to match how we formed temp keys (nostr_<pub16>)
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") {
|
||||
val pubHex = data.joinToString("") { "%02x".format(it) }
|
||||
tryMergeKeys.add("nostr_${pubHex.take(16)}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Also merge any directly-addressed temp key used by incoming messages (without mapping yet)
|
||||
// Search existing chats for keys that begin with "nostr_" and have messages from the same nickname
|
||||
state.getPrivateChatsValue().keys.filter { it.startsWith("nostr_") }.forEach { tempKey ->
|
||||
if (!tryMergeKeys.contains(tempKey)) tryMergeKeys.add(tempKey)
|
||||
val noiseKey = when {
|
||||
ContactIdentityResolver.isNoiseKeyHex(targetPeerID) ->
|
||||
ContactIdentityResolver.bytesFromHex(targetPeerID)
|
||||
ContactIdentityResolver.isMeshPeerId(targetPeerID) ->
|
||||
meshService.getPeerInfo(targetPeerID)?.noisePublicKey
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (tryMergeKeys.isEmpty()) return
|
||||
|
||||
val currentChats = state.getPrivateChatsValue().toMutableMap()
|
||||
val targetList = currentChats[targetPeerID]?.toMutableList() ?: mutableListOf()
|
||||
|
||||
var didMerge = false
|
||||
tryMergeKeys.forEach { tempKey ->
|
||||
val tempList = currentChats[tempKey]
|
||||
if (!tempList.isNullOrEmpty()) {
|
||||
targetList.addAll(tempList)
|
||||
currentChats.remove(tempKey)
|
||||
didMerge = true
|
||||
if (noiseKey != null) {
|
||||
val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseKey)
|
||||
if (!noiseHex.equals(targetPeerID, ignoreCase = true)) {
|
||||
tryMergeKeys.add(noiseHex)
|
||||
}
|
||||
try {
|
||||
FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
|
||||
?.let { tryMergeKeys.add(it) }
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
if (didMerge) {
|
||||
currentChats[targetPeerID] = targetList
|
||||
state.setPrivateChats(currentChats)
|
||||
|
||||
// Also remove unread flag from temp keys and apply to target
|
||||
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val hadUnread = tryMergeKeys.any { unread.remove(it) }
|
||||
if (hadUnread) {
|
||||
unread.add(targetPeerID)
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
}
|
||||
|
||||
// If we're currently viewing one of the temp aliases in the sheet, switch to the permanent ID
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (sheetPeer != null && tryMergeKeys.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetPeerID)
|
||||
}
|
||||
if (tryMergeKeys.isNotEmpty()) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = targetConversationID,
|
||||
keysToMerge = tryMergeKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,11 @@ import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bitchat.android.onboarding.PermissionManager
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
@ -226,6 +231,37 @@ fun DebugSettingsSheet(
|
||||
val bleEnabled by manager.bleEnabled.collectAsState()
|
||||
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
|
||||
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
|
||||
|
||||
// Onboarding only asks for these when the toggle is already on, and it defaults to off,
|
||||
// so enabling from here has to request them or the controller never starts.
|
||||
val wifiAwarePermissions = remember { PermissionManager(context).wifiAwarePermissions() }
|
||||
val wifiAwarePermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { _ ->
|
||||
// Check live state, not the result map — already-held permissions are filtered out
|
||||
// before launching. Only NEARBY_WIFI_DEVICES blocks startup; the other is defensive.
|
||||
val nearbyPermission = android.Manifest.permission.NEARBY_WIFI_DEVICES
|
||||
val nearbyGranted = nearbyPermission !in wifiAwarePermissions ||
|
||||
ContextCompat.checkSelfPermission(context, nearbyPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (nearbyGranted) {
|
||||
manager.setWifiAwareEnabled(true)
|
||||
} else {
|
||||
manager.addDebugMessage(
|
||||
DebugMessage.SystemMessage("Wi‑Fi Aware needs the Nearby devices permission")
|
||||
)
|
||||
}
|
||||
}
|
||||
val enableWifiAware: () -> Unit = {
|
||||
val missing = wifiAwarePermissions.filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
manager.setWifiAwareEnabled(true)
|
||||
} else {
|
||||
wifiAwarePermissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
}
|
||||
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
|
||||
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
|
||||
val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState()
|
||||
@ -454,7 +490,9 @@ fun DebugSettingsSheet(
|
||||
Switch(
|
||||
checked = wifiAwareEnabled && wifiAwareSupported,
|
||||
enabled = wifiSwitchEnabled,
|
||||
onCheckedChange = { manager.setWifiAwareEnabled(it) }
|
||||
onCheckedChange = { on ->
|
||||
if (on) enableWifiAware() else manager.setWifiAwareEnabled(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@ -752,7 +790,7 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AssistChip(
|
||||
onClick = { manager.setWifiAwareEnabled(true) },
|
||||
onClick = enableWifiAware,
|
||||
enabled = wifiAwareSupported,
|
||||
label = { Text("Start") }
|
||||
)
|
||||
|
||||
@ -2,25 +2,22 @@ package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
@ -58,23 +55,21 @@ fun AudioMessageItem(
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
AnnotatedClickableText(
|
||||
text = headerText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@ -3,13 +3,11 @@ package com.bitchat.android.ui.media
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -18,21 +16,18 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun ImageMessageItem(
|
||||
@ -58,23 +53,21 @@ fun ImageMessageItem(
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
AnnotatedClickableText(
|
||||
text = headerText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
/**
|
||||
* Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
|
||||
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
|
||||
*/
|
||||
internal object AuthenticatedIngressLinkPolicy {
|
||||
data class Claim(
|
||||
val relayAddress: String,
|
||||
val linkID: String
|
||||
)
|
||||
|
||||
data class Link<T : Any>(
|
||||
val relayAddress: String,
|
||||
val transport: T
|
||||
)
|
||||
|
||||
fun matches(
|
||||
expected: Claim?,
|
||||
authenticatedRelayAddress: String?,
|
||||
authenticatedLinkID: String?
|
||||
): Boolean =
|
||||
expected != null &&
|
||||
expected.relayAddress == authenticatedRelayAddress &&
|
||||
expected.linkID == authenticatedLinkID
|
||||
|
||||
fun <T : Any> resolve(
|
||||
authenticatedLinkID: String?,
|
||||
authenticatedRelayAddress: String?,
|
||||
links: Map<String, Link<T>>,
|
||||
currentTransportForRelay: (String) -> T?
|
||||
): Link<T>? {
|
||||
val linkID = authenticatedLinkID ?: return null
|
||||
val relayAddress = authenticatedRelayAddress ?: return null
|
||||
val link = links[linkID] ?: return null
|
||||
if (link.relayAddress != relayAddress) return null
|
||||
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ class WifiAwareConnectionTracker(
|
||||
// Active resources per peer
|
||||
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
|
||||
private val socketAliases = ConcurrentHashMap<String, String>()
|
||||
private val socketBindingLock = Any()
|
||||
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
|
||||
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
|
||||
|
||||
@ -32,24 +33,26 @@ class WifiAwareConnectionTracker(
|
||||
}
|
||||
|
||||
override fun disconnect(id: String) {
|
||||
Log.d(TAG, "Disconnecting peer $id")
|
||||
val canonicalId = resolveCanonicalPeerId(id)
|
||||
|
||||
// 1. Close client socket
|
||||
peerSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
|
||||
}
|
||||
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
|
||||
synchronized(socketBindingLock) {
|
||||
Log.d(TAG, "Disconnecting peer $id")
|
||||
val canonicalId = resolveCanonicalPeerId(id)
|
||||
|
||||
// 2. Close server socket
|
||||
serverSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
|
||||
}
|
||||
// 1. Close client socket
|
||||
peerSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
|
||||
}
|
||||
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
|
||||
|
||||
// Ensure any pending/active network request is explicitly released
|
||||
releaseNetworkRequest(canonicalId)
|
||||
removePendingConnection(id)
|
||||
removePendingConnection(canonicalId)
|
||||
// 2. Close server socket
|
||||
serverSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
|
||||
}
|
||||
|
||||
// Ensure any pending/active network request is explicitly released
|
||||
releaseNetworkRequest(canonicalId)
|
||||
removePendingConnection(id)
|
||||
removePendingConnection(canonicalId)
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseNetworkRequest(id: String) {
|
||||
@ -71,12 +74,16 @@ class WifiAwareConnectionTracker(
|
||||
* Successfully established a client connection
|
||||
*/
|
||||
fun onClientConnected(peerId: String, socket: SyncedSocket) {
|
||||
// Close previous socket if one exists to prevent zombie readers
|
||||
peerSockets[peerId]?.let {
|
||||
try { it.close() } catch (_: Exception) {}
|
||||
synchronized(socketBindingLock) {
|
||||
val canonicalPeerId = resolveCanonicalPeerId(peerId)
|
||||
// Close previous socket if one exists to prevent zombie readers
|
||||
peerSockets[canonicalPeerId]?.let {
|
||||
try { it.close() } catch (_: Exception) {}
|
||||
}
|
||||
peerSockets[canonicalPeerId] = socket
|
||||
removePendingConnection(peerId) // Clear retry state on success
|
||||
if (canonicalPeerId != peerId) removePendingConnection(canonicalPeerId)
|
||||
}
|
||||
peerSockets[peerId] = socket
|
||||
removePendingConnection(peerId) // Clear retry state on success
|
||||
}
|
||||
|
||||
fun getSocketForPeer(peerId: String): SyncedSocket? {
|
||||
@ -87,6 +94,37 @@ class WifiAwareConnectionTracker(
|
||||
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
|
||||
|
||||
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String {
|
||||
return synchronized(socketBindingLock) {
|
||||
rebindPeerIdLocked(previousPeerId, resolvedPeerId, socket)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically require that [expectedSocket] is still the active provisional transport and, only
|
||||
* then, promote it. This closes the gap where a replacement socket could land after validation
|
||||
* but before mutation and the stale authenticated socket would become canonical.
|
||||
*/
|
||||
fun rebindPeerIdIfCurrent(
|
||||
previousPeerId: String,
|
||||
resolvedPeerId: String,
|
||||
expectedSocket: SyncedSocket
|
||||
): Boolean = synchronized(socketBindingLock) {
|
||||
val previousCanonical = resolveCanonicalPeerId(previousPeerId)
|
||||
if (peerSockets[previousCanonical] !== expectedSocket) return@synchronized false
|
||||
val resolvedCanonical = resolveCanonicalPeerId(resolvedPeerId)
|
||||
val existingResolvedSocket = peerSockets[resolvedCanonical]
|
||||
if (existingResolvedSocket != null && existingResolvedSocket !== expectedSocket) {
|
||||
return@synchronized false
|
||||
}
|
||||
rebindPeerIdLocked(previousPeerId, resolvedPeerId, expectedSocket)
|
||||
true
|
||||
}
|
||||
|
||||
private fun rebindPeerIdLocked(
|
||||
previousPeerId: String,
|
||||
resolvedPeerId: String,
|
||||
socket: SyncedSocket
|
||||
): String {
|
||||
if (previousPeerId == resolvedPeerId) {
|
||||
peerSockets[resolvedPeerId] = socket
|
||||
return resolvedPeerId
|
||||
|
||||
@ -42,6 +42,7 @@ import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@ -74,6 +75,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
|
||||
private const val CLIENT_SOCKET_ATTEMPTS = 3
|
||||
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
|
||||
private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
|
||||
// Discovery freshness window for reconnection maintenance
|
||||
private const val DISCOVERY_STALE_MS = 5L * 60 * 1000
|
||||
private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000
|
||||
@ -121,6 +123,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
// Transport state
|
||||
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
|
||||
private val ingressLinks = ConcurrentHashMap<
|
||||
String,
|
||||
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
|
||||
>()
|
||||
private val provisionalWifiClaims =
|
||||
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
|
||||
private val authenticatedWifiLinks =
|
||||
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
|
||||
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
|
||||
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
|
||||
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
|
||||
@ -167,8 +177,40 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
onAnnounceProcessed = { routed, _ ->
|
||||
routed.peerID?.let { pid ->
|
||||
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
|
||||
|
||||
// Discovery IDs from older clients can be provisional. A verified direct
|
||||
// announce is enough to start a handshake for the canonical ID, but not to
|
||||
// rebind the socket. A fresh challenge is sent through the exact transport
|
||||
// generation, and only its same-link completion may promote that alias.
|
||||
val relay = routed.relayAddress
|
||||
val linkID = routed.ingressLinkID
|
||||
if (
|
||||
routed.packet.ttl == MAX_TTL &&
|
||||
relay != null &&
|
||||
linkID != null
|
||||
) {
|
||||
val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
|
||||
if (!AuthenticatedIngressLinkPolicy.matches(
|
||||
authenticatedWifiLinks[pid],
|
||||
relay,
|
||||
linkID
|
||||
)
|
||||
) {
|
||||
registerProvisionalWifiClaim(pid, claim)
|
||||
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
|
||||
provisionalWifiClaims.remove(pid, claim)
|
||||
Log.w(
|
||||
TAG,
|
||||
"Could not send Noise challenge on exact Wi-Fi link for ${pid.take(8)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
|
||||
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
|
||||
},
|
||||
announcementNicknameProvider = {
|
||||
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
|
||||
},
|
||||
@ -562,6 +604,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
subscribeHandles.clear()
|
||||
publishHandles.clear()
|
||||
discoveredTimestamps.clear()
|
||||
ingressLinks.clear()
|
||||
provisionalWifiClaims.clear()
|
||||
authenticatedWifiLinks.clear()
|
||||
|
||||
meshCore.shutdown()
|
||||
|
||||
@ -606,6 +651,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
subscribeHandles.clear()
|
||||
publishHandles.clear()
|
||||
discoveredTimestamps.clear()
|
||||
ingressLinks.clear()
|
||||
provisionalWifiClaims.clear()
|
||||
authenticatedWifiLinks.clear()
|
||||
}
|
||||
} finally {
|
||||
recoveryInProgress = false
|
||||
@ -1241,6 +1289,85 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
else -> myPeerID < peerId
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
|
||||
* Noise frame that completed authentication for the canonical peer ID.
|
||||
*/
|
||||
private fun promoteAuthenticatedIngressLink(
|
||||
canonicalPeerId: String,
|
||||
relayAddress: String,
|
||||
ingressLinkID: String
|
||||
) {
|
||||
val expectedClaim = provisionalWifiClaims[canonicalPeerId]
|
||||
if (!AuthenticatedIngressLinkPolicy.matches(
|
||||
expectedClaim,
|
||||
relayAddress,
|
||||
ingressLinkID
|
||||
)
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}"
|
||||
)
|
||||
return
|
||||
}
|
||||
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
|
||||
|
||||
val link = AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = ingressLinkID,
|
||||
authenticatedRelayAddress = relayAddress,
|
||||
links = ingressLinks,
|
||||
currentTransportForRelay = connectionTracker::getSocketForPeer
|
||||
) ?: run {
|
||||
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
|
||||
return
|
||||
}
|
||||
|
||||
val provisionalPeerId = link.relayAddress
|
||||
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
|
||||
if (existingCanonical == canonicalPeerId) {
|
||||
authenticatedWifiLinks[canonicalPeerId] =
|
||||
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
|
||||
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
|
||||
return
|
||||
}
|
||||
if (existingCanonical != provisionalPeerId) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on an existing alias"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed before rebind"
|
||||
)
|
||||
return
|
||||
}
|
||||
authenticatedWifiLinks[canonicalPeerId] =
|
||||
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
|
||||
handleToPeerId.forEach { (handle, peerId) ->
|
||||
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
|
||||
}
|
||||
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
|
||||
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
|
||||
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
|
||||
discoveredTimestamps[canonicalPeerId] = discoveredAt
|
||||
|
||||
try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { }
|
||||
try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { }
|
||||
try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { }
|
||||
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
|
||||
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)} on exact ingress link"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for incoming packets from a connected peer and dispatches them through
|
||||
* the packet processor.
|
||||
@ -1249,7 +1376,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
* @param initialLogicalPeerId Temporary identifier before peer ID resolution
|
||||
*/
|
||||
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
|
||||
var logicalPeerId = initialLogicalPeerId
|
||||
val logicalPeerId = initialLogicalPeerId
|
||||
val ingressLinkID = UUID.randomUUID().toString()
|
||||
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
|
||||
ingressLinks[ingressLinkID] = ingressLink
|
||||
while (isActive) {
|
||||
val raw = socket.read() ?: break
|
||||
|
||||
@ -1263,29 +1393,24 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
|
||||
|
||||
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
|
||||
val previousPeerId = logicalPeerId
|
||||
logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket)
|
||||
handleToPeerId.forEach { (handle, peerId) ->
|
||||
if (peerId == previousPeerId) {
|
||||
handleToPeerId[handle] = senderPeerHex
|
||||
}
|
||||
}
|
||||
subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it }
|
||||
discoveredTimestamps.remove(previousPeerId)
|
||||
discoveredTimestamps[senderPeerHex] = System.currentTimeMillis()
|
||||
try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { }
|
||||
try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { }
|
||||
try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { }
|
||||
publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it }
|
||||
Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}")
|
||||
// The socket's discovery identity remains provisional until Noise proves possession
|
||||
// of the claimed static key on this link. A canonical self-signed announcement is
|
||||
// only TOFU and cannot safely rebind/remove transport state on its own.
|
||||
Log.w(
|
||||
TAG,
|
||||
"RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof"
|
||||
)
|
||||
}
|
||||
|
||||
// Route the packet:
|
||||
// - peerID = Originator (who signed it)
|
||||
// - relayAddress = Neighbor (who sent it to us over this socket)
|
||||
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})")
|
||||
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId)
|
||||
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID)
|
||||
}
|
||||
|
||||
ingressLinks.remove(ingressLinkID, ingressLink)
|
||||
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
|
||||
|
||||
// Breaking out of the loop means the socket is dead or service is stopping.
|
||||
Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
|
||||
@ -1293,6 +1418,28 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
socket.close()
|
||||
}
|
||||
|
||||
private fun registerProvisionalWifiClaim(
|
||||
peerID: String,
|
||||
claim: AuthenticatedIngressLinkPolicy.Claim
|
||||
) {
|
||||
provisionalWifiClaims[peerID] = claim
|
||||
serviceScope.launch {
|
||||
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
|
||||
if (provisionalWifiClaims.remove(peerID, claim)) {
|
||||
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
|
||||
provisionalWifiClaims.entries.removeIf { (_, claim) ->
|
||||
claim.relayAddress == relayAddress && claim.linkID == linkID
|
||||
}
|
||||
authenticatedWifiLinks.entries.removeIf { (_, claim) ->
|
||||
claim.relayAddress == relayAddress && claim.linkID == linkID
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNetworkFailure(peerId: String) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Network failure cleanup for: $peerId")
|
||||
@ -1399,6 +1546,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendFilePrivate(recipientPeerID, file)
|
||||
}
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): com.bitchat.android.mesh.PrivateMediaPreparation = meshCore.prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
transferId,
|
||||
allowLegacyFallback
|
||||
)
|
||||
|
||||
/**
|
||||
* Attempts to cancel an in-flight file transfer identified by its transferId.
|
||||
*
|
||||
@ -1570,6 +1729,29 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
override fun sendPacketToLink(
|
||||
relayAddress: String,
|
||||
ingressLinkID: String,
|
||||
packet: BitchatPacket
|
||||
): Boolean {
|
||||
val link = AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = ingressLinkID,
|
||||
authenticatedRelayAddress = relayAddress,
|
||||
links = ingressLinks,
|
||||
currentTransportForRelay = connectionTracker::getSocketForPeer
|
||||
) ?: return false
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
return try {
|
||||
link.transport.write(data)
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"TX: exact-link write to ${relayAddress.take(8)} failed: ${e.message}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
override fun cancelTransfer(transferId: String): Boolean {
|
||||
return fragmentingSender.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">فتح قسم حول</string>
|
||||
<string name="nearby_notes_reveal">تحقّق من الملاحظات المتروكة هنا</string>
|
||||
<string name="nearby_notes_one">تُركت ملاحظة واحدة هنا — انقر للقراءة</string>
|
||||
<string name="nearby_notes_many">تُركت %d ملاحظات هنا — انقر للقراءة</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">পরিচিতি খুলুন</string>
|
||||
<string name="nearby_notes_reveal">এখানে রাখা নোট আছে কি না দেখুন</string>
|
||||
<string name="nearby_notes_one">এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
|
||||
<string name="nearby_notes_many">এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
|
||||
</resources>
|
||||
|
||||
@ -401,4 +401,8 @@
|
||||
<string name="verify_success_title">Verifiziert</string>
|
||||
<string name="verify_success_body">Du hast %1$s verifiziert</string>
|
||||
<string name="verify_success_system_message">verifiziert %1$s</string>
|
||||
<string name="cd_open_about">Info öffnen</string>
|
||||
<string name="nearby_notes_reveal">nachsehen, ob hier notizen hinterlassen wurden</string>
|
||||
<string name="nearby_notes_one">1 notiz hier hinterlassen — tippen zum lesen</string>
|
||||
<string name="nearby_notes_many">%d notizen hier hinterlassen — tippen zum lesen</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Verificaste a %1$s</string>
|
||||
<string name="verify_success_system_message">verificado %1$s</string>
|
||||
<string name="cd_open_about">Abrir Acerca de</string>
|
||||
<string name="nearby_notes_reveal">buscar notas dejadas aquí</string>
|
||||
<string name="nearby_notes_one">1 nota dejada aquí — toca para leer</string>
|
||||
<string name="nearby_notes_many">%d notas dejadas aquí — toca para leer</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">باز کردن درباره</string>
|
||||
<string name="nearby_notes_reveal">یادداشتهای باقیمانده در اینجا را بررسی کنید</string>
|
||||
<string name="nearby_notes_one">۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
|
||||
<string name="nearby_notes_many">%d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
|
||||
</resources>
|
||||
|
||||
@ -280,7 +280,6 @@
|
||||
<string name="grant_permissions">Payagan</string>
|
||||
<string name="location_tracking_warning">HINDI sinusubaybayan ng bitchat ang lokasyon mo</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="geohash_people_header">TAO</string>
|
||||
<string name="nobody_around">walang tao sa paligid…</string>
|
||||
@ -400,4 +399,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Buksan ang Tungkol</string>
|
||||
<string name="nearby_notes_reveal">tingnan kung may mga note na naiwan dito</string>
|
||||
<string name="nearby_notes_one">1 note ang naiwan dito — i-tap para basahin</string>
|
||||
<string name="nearby_notes_many">%d note ang naiwan dito — i-tap para basahin</string>
|
||||
</resources>
|
||||
|
||||
@ -281,7 +281,6 @@
|
||||
<string name="grant_permissions">Accorder les autorisations</string>
|
||||
<string name="location_tracking_warning">bitchat ne suit PAS ta position</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="geohash_people_header">PERSONNES</string>
|
||||
<string name="nobody_around">personne aux alentours…</string>
|
||||
@ -414,4 +413,8 @@
|
||||
<string name="verify_success_title">Vérifié</string>
|
||||
<string name="verify_success_body">Vous avez vérifié %1$s</string>
|
||||
<string name="verify_success_system_message">vérifié %1$s</string>
|
||||
<string name="cd_open_about">Ouvrir À propos</string>
|
||||
<string name="nearby_notes_reveal">vérifier s\'il y a des notes laissées ici</string>
|
||||
<string name="nearby_notes_one">1 note laissée ici — appuyez pour lire</string>
|
||||
<string name="nearby_notes_many">%d notes laissées ici — appuyez pour lire</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">פתיחת אודות</string>
|
||||
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
|
||||
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
|
||||
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">परिचय खोलें</string>
|
||||
<string name="nearby_notes_reveal">देखें कि यहाँ नोट छोड़े गए हैं या नहीं</string>
|
||||
<string name="nearby_notes_one">यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें</string>
|
||||
<string name="nearby_notes_many">यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Buka Tentang</string>
|
||||
<string name="nearby_notes_reveal">periksa catatan yang ditinggalkan di sini</string>
|
||||
<string name="nearby_notes_one">1 catatan ditinggalkan di sini — ketuk untuk membaca</string>
|
||||
<string name="nearby_notes_many">%d catatan ditinggalkan di sini — ketuk untuk membaca</string>
|
||||
</resources>
|
||||
|
||||
@ -340,7 +340,6 @@
|
||||
|
||||
<!-- Simboli e stringhe speciali -->
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
@ -434,4 +433,8 @@
|
||||
<string name="verify_success_title">Verificato</string>
|
||||
<string name="verify_success_body">Hai verificato %1$s</string>
|
||||
<string name="verify_success_system_message">verificato %1$s</string>
|
||||
<string name="cd_open_about">Apri Informazioni</string>
|
||||
<string name="nearby_notes_reveal">controlla se ci sono note lasciate qui</string>
|
||||
<string name="nearby_notes_one">1 nota lasciata qui — tocca per leggere</string>
|
||||
<string name="nearby_notes_many">%d note lasciate qui — tocca per leggere</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">検証済み</string>
|
||||
<string name="verify_success_body">%1$s を検証しました</string>
|
||||
<string name="verify_success_system_message">%1$s を検証しました</string>
|
||||
<string name="cd_open_about">このアプリについてを開く</string>
|
||||
<string name="nearby_notes_reveal">ここに残されたメモを確認</string>
|
||||
<string name="nearby_notes_one">ここに1件のメモがあります — タップして読む</string>
|
||||
<string name="nearby_notes_many">ここに%d件のメモがあります — タップして読む</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">აპის შესახებ გახსნა</string>
|
||||
<string name="nearby_notes_reveal">აქ დატოვებული ჩანაწერების შემოწმება</string>
|
||||
<string name="nearby_notes_one">აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
|
||||
<string name="nearby_notes_many">აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">정보 열기</string>
|
||||
<string name="nearby_notes_reveal">여기 남겨진 쪽지 확인</string>
|
||||
<string name="nearby_notes_one">여기 남겨진 쪽지 1개 — 탭하여 읽기</string>
|
||||
<string name="nearby_notes_many">여기 남겨진 쪽지 %d개 — 탭하여 읽기</string>
|
||||
</resources>
|
||||
|
||||
@ -289,7 +289,6 @@
|
||||
<string name="grant_permissions">Omeo Alalana</string>
|
||||
<string name="location_tracking_warning">Ny bitchat dia TSY manaraka ny toerananao</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="geohash_people_header">OLONA</string>
|
||||
<string name="nobody_around">tsy misy olona manodidina...</string>
|
||||
@ -414,4 +413,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Sokafy ny momba</string>
|
||||
<string name="nearby_notes_reveal">hizaha raha misy naoty navela teto</string>
|
||||
<string name="nearby_notes_one">naoty 1 no navela teto — tsindrio raha hamaky</string>
|
||||
<string name="nearby_notes_many">naoty %d no navela teto — tsindrio raha hamaky</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Buka Perihal</string>
|
||||
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
|
||||
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -280,7 +280,6 @@
|
||||
<string name="grant_permissions">अनुमति दिनुहोस्</string>
|
||||
<string name="location_tracking_warning">bitchat ले तपाईंको स्थान पछ्याउँदैन</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="geohash_people_header">मानिसहरू</string>
|
||||
<string name="nobody_around">वरिपरि कोही छैन…</string>
|
||||
@ -400,4 +399,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
|
||||
<string name="nearby_notes_reveal">यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्</string>
|
||||
<string name="nearby_notes_one">यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्</string>
|
||||
<string name="nearby_notes_many">यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्</string>
|
||||
</resources>
|
||||
|
||||
@ -340,7 +340,6 @@
|
||||
|
||||
<!-- Symbolen & speciale strings -->
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="mention_suggestion_at">@%1$s</string>
|
||||
<string name="image_counter">%1$d / %2$d</string>
|
||||
@ -432,4 +431,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Info openen</string>
|
||||
<string name="nearby_notes_reveal">kijk of hier notities zijn achtergelaten</string>
|
||||
<string name="nearby_notes_one">1 notitie hier achtergelaten — tik om te lezen</string>
|
||||
<string name="nearby_notes_many">%d notities hier achtergelaten — tik om te lezen</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">ایپ بارے کھولو</string>
|
||||
<string name="nearby_notes_reveal">ایتھے چھڈے نوٹس ویکھو</string>
|
||||
<string name="nearby_notes_one">ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو</string>
|
||||
<string name="nearby_notes_many">ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,8 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
<string name="cd_open_about">Otwórz informacje</string>
|
||||
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
|
||||
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
|
||||
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -400,4 +400,7 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Você verificou %1$s</string>
|
||||
<string name="verify_success_system_message">verificou %1$s</string>
|
||||
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
|
||||
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
|
||||
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,8 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Você verificou %1$s</string>
|
||||
<string name="verify_success_system_message">verificou %1$s</string>
|
||||
<string name="cd_open_about">Abrir Sobre</string>
|
||||
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
|
||||
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
|
||||
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
|
||||
</resources>
|
||||
|
||||
@ -264,7 +264,6 @@
|
||||
<string name="grant_permissions">Выдать разрешения</string>
|
||||
<string name="location_tracking_warning">bitchat НЕ отслеживает вашу геопозицию</string>
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</string>
|
||||
<string name="channel_count_prefix"> · ⧉ </string>
|
||||
<string name="geohash_people_header">ЛЮДИ</string>
|
||||
<string name="nobody_around">никого рядом…</string>
|
||||
@ -390,4 +389,8 @@
|
||||
<string name="verify_success_title">Проверено</string>
|
||||
<string name="verify_success_body">Вы проверили %1$s</string>
|
||||
<string name="verify_success_system_message">проверен %1$s</string>
|
||||
<string name="cd_open_about">Открыть раздел «О приложении»</string>
|
||||
<string name="nearby_notes_reveal">проверить, есть ли здесь заметки</string>
|
||||
<string name="nearby_notes_one">здесь оставлена 1 заметка — нажмите, чтобы прочитать</string>
|
||||
<string name="nearby_notes_many">здесь оставлено заметок: %d — нажмите, чтобы прочитать</string>
|
||||
</resources>
|
||||
|
||||
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