Merge remote-tracking branch 'origin/main' into enhancement/apk-sharing

# Conflicts:
#	app/src/main/AndroidManifest.xml
#	gradle.properties
This commit is contained in:
callebtc 2026-07-27 02:20:23 +02:00
commit d4f4a89fad
144 changed files with 9876 additions and 2322 deletions

View File

@ -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"
)
}

View File

@ -29,6 +29,8 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

View File

@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

@ -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

View File

@ -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")

View File

@ -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) }}")

View File

@ -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()

View File

@ -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,

View File

@ -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)

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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?

View File

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

View File

@ -1,6 +1,8 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@ -17,7 +19,11 @@ data class PeerInfo(
var noisePublicKey: ByteArray?,
var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification
var isVerifiedNickname: Boolean, // NEW: Verification status flag
var lastSeen: Long // Using Long instead of Date for simplicity
var lastSeen: Long, // Using Long instead of Date for simplicity
var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05
var hasVerifiedAnnouncement: Boolean = false,
/** Noise key that the preserved capability state was actually signed alongside. */
var verifiedAnnouncementNoisePublicKey: ByteArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -39,6 +45,14 @@ data class PeerInfo(
} else if (other.signingPublicKey != null) return false
if (isVerifiedNickname != other.isVerifiedNickname) return false
if (lastSeen != other.lastSeen) return false
if (capabilities != other.capabilities) return false
if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false
val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey
val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey
if (thisVerifiedAnnouncementKey != null) {
if (otherVerifiedAnnouncementKey == null) return false
if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false
} else if (otherVerifiedAnnouncementKey != null) return false
return true
}
@ -52,6 +66,9 @@ data class PeerInfo(
result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0)
result = 31 * result + isVerifiedNickname.hashCode()
result = 31 * result + lastSeen.hashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + hasVerifiedAnnouncement.hashCode()
result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0)
return result
}
}
@ -108,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
}

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

@ -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 }
}
}
}
}

View File

@ -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()
}

View File

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

View File

@ -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)

View File

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

View File

@ -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

View File

@ -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"
)
}

View File

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

View File

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

View File

@ -2,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}")
}
}

View File

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

View File

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

View File

@ -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()
}

View File

@ -39,6 +39,26 @@ class PermissionManager(private val context: Context) {
}
}
/**
* Runtime permissions for the WiFi Aware transport, version-gated because neither exists
* at minSdk 26 requesting an unknown permission comes back permanently denied.
*
* ACCESS_LOCAL_NETWORK is defensive: Android 17 gates local network access, and the
* transport reaches peers over link-local IPv6 sockets. It is granted separately from
* NEARBY_WIFI_DEVICES but shares its permission group, so the two prompt only once.
*/
fun wifiAwarePermissions(): List<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
// API 37 == Android 17; no named VERSION_CODES constant is available yet.
if (Build.VERSION.SDK_INT >= 37) {
permissions.add(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
return permissions
}
/**
* Check if this is the first time the user is launching the app
*/
@ -87,7 +107,7 @@ class PermissionManager(private val context: Context) {
// WiFi 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) {
// WiFi Aware category (Android 13+)
if (shouldRequireWifiAwarePermission()) {
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
val wifiAwarePermissions = wifiAwarePermissions()
categories.add(
PermissionCategory(
type = PermissionType.WIFI_AWARE,

View File

@ -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}")

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -2,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) }
}

View File

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

View File

@ -12,19 +12,35 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.zIndex
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
/**
@ -59,6 +75,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@ -85,6 +102,67 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val nearbyNotesController = remember { NearbyNotesController.shared }
val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nearbyNotes by remember { LocationNotesManager.getInstance() }
.notes
.collectAsStateWithLifecycle()
val buildingGeohash = availableLocationChannels
.firstOrNull { it.level == GeohashChannelLevel.BUILDING }
?.geohash
val isMeshTimeline =
currentChannel == null &&
selectedLocationChannel is ChannelID.Mesh &&
selectedPrivatePeer == null &&
privateChatSheetPeer == null
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
val lifecycle = processLifecycleOwner.lifecycle
val observer = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(false)
}
}
lifecycle.addObserver(observer)
nearbyNotesController.updateAppForeground(
lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
)
onDispose {
lifecycle.removeObserver(observer)
nearbyNotesController.updateAppForeground(false)
}
}
DisposableEffect(
isMeshTimeline,
locationEnabled,
locationPermissionState,
buildingGeohash,
nearbyNotesController,
) {
nearbyNotesController.updateAvailability(
locationEnabled = locationEnabled,
locationAuthorized =
locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED,
buildingGeohash = buildingGeohash,
)
if (isMeshTimeline) nearbyNotesController.activate()
onDispose {
if (isMeshTimeline) nearbyNotesController.deactivate()
}
}
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
@ -130,58 +208,91 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
// Messages area - takes up available space, will compress when keyboard appears
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.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

View File

@ -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
))

View File

@ -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()

View File

@ -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()

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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)
*/

View File

@ -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)

View File

@ -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),
)
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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")
}
/**

View File

@ -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
)
}
}

View File

@ -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("WiFi 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") }
)

View File

@ -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) {

View File

@ -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

View File

@ -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 }
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -340,7 +340,6 @@
<!-- Symbolen &amp; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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